Reactで、フォルダーを選んで中のファイルを一覧にする方法
<input type="file"> に webkitdirectory 属性を付けると、ファイルではなくフォルダーを選べるようになります。選んだフォルダーの中のファイルは、サブフォルダーの分も含めて files に平らに並び、それぞれの webkitRelativePath から、フォルダーの中のどこにあったかが分かります。
React と TypeScript で、選んだフォルダーの中身をフォルダーごとに一覧にするコンポーネントを作り、Chromium で動きを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。フォルダーは、ファイル選択のダイアログを使わず、Playwright の setInputFiles にフォルダーのパスを渡して選びました。このとき Playwright はパスを Chromium に渡すだけで、フォルダーの中を読むのは Chromium です。ダイアログで人が選ぶ操作、Firefox と Safari、スマートフォンのブラウザ、Windows と macOS、本番用ビルドでは試していません。

webkitdirectory を TypeScript の JSX に書く
@types/react 19.3.0 の型には webkitdirectory がありません。そのまま <input type="file" webkitdirectory="" /> と書くと、tsc が次のエラーを出しました。
error TS2322: Type '{ type: "file"; webkitdirectory: string; onChange: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>) => void; }' is not assignable to type 'DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>'.
Property 'webkitdirectory' does not exist on type 'DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>'.
型定義のファイルを1つ足して、React の InputHTMLAttributes に webkitdirectory を加えます。src の中に置けば、tsc がそのまま読み込みます。
// react-webkitdirectory.d.ts
// @types/react の input の属性に webkitdirectory を足す
import 'react';
declare module 'react' {
interface InputHTMLAttributes<T> {
webkitdirectory?: string;
}
}
型を boolean ではなく string にしたのは、true を渡すと React が属性を付けなかったためです。webkitdirectory="" と書いた input には、DOM にも webkitdirectory="" が付き、input.webkitdirectory は true になりました。webkitdirectory={true} に当たる値を渡すと、開発用ビルドでは次の警告が出て、属性は DOM に付かず、input.webkitdirectory は false のままでした。
Received `true` for a non-boolean attribute `webkitdirectory`.
React は、webkitdirectory を true / false を受け取る属性として扱っていません。空の文字列を渡せば、属性が付いて input.webkitdirectory が true になります。
MDN の互換性データによると、対応しているブラウザは、パソコン向けの Chrome、Edge、Firefox、Safari のほか、Android 版の Chrome 132 以降、Android 版の Firefox 142 以降、iOS の Safari 18.4 以降です。
選んだファイルのパスは webkitRelativePath で分かる
検証用に、次のフォルダーを作りました。. で始まるファイルとフォルダー、空のフォルダー、シンボリックリンクを入れてあります。
cat-album/
├── .hiddenrc
├── .thumbs/a.bin
├── 2026/夏/data.json
├── 2026/夏/hammock.png
├── 2026/春/memo.txt
├── 2026/春/桜と猫.jpg
├── README.md
├── link-to-2026 → 2026 を指すシンボリックリンク
├── link-to-readme.md → README.md を指すシンボリックリンク
└── 空のフォルダー/
cat-album を選んだときの change イベントで、files の中身を出しました。並びは files に入っていた順のままです。
webkitRelativePath |
name |
size |
type |
|---|---|---|---|
cat-album/.hiddenrc |
.hiddenrc |
7 | (空) |
cat-album/README.md |
README.md |
21 | text/markdown |
cat-album/2026/夏/data.json |
data.json |
8 | application/json |
cat-album/2026/夏/hammock.png |
hammock.png |
250000 | image/png |
cat-album/2026/春/桜と猫.jpg |
桜と猫.jpg |
120000 | image/jpeg |
cat-album/2026/春/memo.txt |
memo.txt |
3500 | text/plain |
cat-album/.thumbs/a.bin |
a.bin |
2048 | application/octet-stream |
webkitRelativePath は、選んだフォルダーの名前から始まるパスです。MDN の webkitdirectory の説明にあるとおり、files 自体は平らな並びなので、フォルダーの階層はこのパスから組み立てます。name はパスの最後の部分だけです。
表から、次のことが分かりました。
.で始まるファイルも、.thumbsのように.で始まるフォルダーの中のファイルも入る空のフォルダーは、ファイルがないので、どこにも出てこない- シンボリックリンクの
link-to-2026とlink-to-readme.mdは入らない filesの並びは、パスの順にはなっていない
type は、ファイルの中身ではなく拡張子で決まっていました。hammock.png と 桜と猫.jpg は、中身がすべて 0 のバイトでできた、画像ではないファイルです。それでも type は image/png と image/jpeg でした。
フォルダーごとにまとめて表示する
files を配列に移してパスの順に並べ、フォルダーのパスごとにまとめる関数を、React から切り離して書きました。
// fileTree.ts
export type FileRow = {
path: string; // 選んだフォルダーからの相対パス(例:photos/2026/cat.png)
size: number;
type: string;
file: File;
};
// FileList は input の値を消すと空になるので、先に配列へ移す
export function toRows(files: FileList | null): FileRow[] {
return Array.from(files ?? [], (file) => ({
path: file.webkitRelativePath || file.name,
size: file.size,
type: file.type,
file,
})).sort((a, b) => a.path.localeCompare(b.path, 'ja'));
}
// パスの途中に「.」で始まる名前があるもの(.git/ や .DS_Store など)
export const isHidden = (path: string) =>
path.split('/').some((part) => part.startsWith('.'));
// フォルダーごとにまとめ、フォルダーのパス(例:photos/2026)の順に並べる
export function groupByFolder(rows: FileRow[]): [string, FileRow[]][] {
const groups = new Map<string, FileRow[]>();
for (const row of rows) {
const folder = row.path.split('/').slice(0, -1).join('/');
const list = groups.get(folder) ?? [];
list.push(row);
groups.set(folder, list);
}
return [...groups].sort(([a], [b]) => a.localeCompare(b, 'ja'));
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
toRows で FileList を配列に移しているのは、input の値を消すと、取っておいた FileList まで空になったためです。フォルダーを選んだ後に input.files を変数に取っておき、input.value = '' で値を消すと、取っておいた FileList の length も 0 になりました。先に Array.from で移した配列には、7つの File が残っていました。
webkitRelativePath が空の文字列のときは、name を使います。MDN の互換性データによると、Android 版の Firefox 141 では、フォルダーを選んでも webkitRelativePath が空の文字列になる、と書かれています。
コンポーネントは、選んだファイルを state に持ち、. で始まるものを出すかどうかをチェックボックスで切り替えます。
// FolderFileList.tsx
import { useState } from 'react';
import { toRows, isHidden, groupByFolder, formatBytes, type FileRow } from './fileTree';
export function FolderFileList() {
const [rows, setRows] = useState<FileRow[]>([]);
const [showHidden, setShowHidden] = useState(false);
const visible = showHidden ? rows : rows.filter((r) => !isHidden(r.path));
const total = visible.reduce((sum, r) => sum + r.size, 0);
const groups = groupByFolder(visible);
return (
<div className="demo">
<label className="picker">
フォルダーを選ぶ
<input
type="file"
webkitdirectory=""
onChange={(e) => setRows(toRows(e.currentTarget.files))}
/>
</label>
<label>
<input
type="checkbox"
checked={showHidden}
onChange={(e) => setShowHidden(e.currentTarget.checked)}
/>
「.」で始まるファイルも出す
</label>
<p className="summary">
{visible.length} 個のファイル(合計 {formatBytes(total)})
{rows.length > visible.length && `、ほかに隠しファイル ${rows.length - visible.length} 個`}
</p>
{groups.map(([folder, list]) => (
<section key={folder}>
<h3>{folder}/</h3>
<ul>
{list.map((r) => (
<li key={r.path}>
<span className="name">{r.file.name}</span>
<span className="size">{formatBytes(r.size)}</span>
</li>
))}
</ul>
</section>
))}
</div>
);
}
cat-album を選ぶと、冒頭の画像のように「5 個のファイル(合計 364.8 KB)、ほかに隠しファイル 2 個」と出ました。フォルダーの見出しは cat-album/、cat-album/2026/夏/、cat-album/2026/春/ の順です。チェックボックスをオンにすると、.hiddenrc と cat-album/.thumbs/ の a.bin が加わって「7 個のファイル(合計 366.8 KB)」になりました。
.git のような . で始まるフォルダーを一覧に出したくないときは、isHidden のような関数で絞ります。
中身のないフォルダーを選んだとき
ファイルが1つもない empty-folder を選ぶと、change イベントは起きましたが、files.length は 0 でした。画面には「0 個のファイル(合計 0 B)」と出ました。
files が空なので、webkitRelativePath から選んだフォルダーの名前を知ることはできません。「フォルダーが空でした」のような表示を出したいときは、files.length が 0 かどうかで分けます。
Playwright でフォルダーを選ぶテスト
検証では、Playwright の setInputFiles にフォルダーのパスを渡して、フォルダーを選びました。
await page.locator('input[type=file]').setInputFiles('fixture/cat-album');
webkitdirectory のない input に同じようにフォルダーを渡すと、setInputFiles は次のエラーで止まりました。属性が付いていないことを、テストで見つけられます。
locator.setInputFiles: Error: File input does not support directories, pass individual files instead





