概要
onDragOver / onDrop イベントを使い、ファイルをドラッグ&ドロップで受け付けるドロップゾーン UI を実装する。ドラッグ中の視覚フィードバックとクリックによるファイル選択を併用し、ライブラリ不要で構築する。
インストール
# 追加インストールは不要
実装
// components/FileDropZone.tsx
"use client";
import { useRef, useState } from "react";
type Props = {
accept?: string;
onFilesSelected: (files: File[]) => void;
};
export function FileDropZone({ accept = "*", onFilesSelected }: Props) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function handleDragOver(e: React.DragEvent) {
e.preventDefault();
setIsDragging(true);
}
function handleDragLeave(e: React.DragEvent) {
// relatedTarget が子要素への移動のとき誤発火しないようにする
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setIsDragging(false);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) onFilesSelected(files);
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) onFilesSelected(files);
}
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors
${isDragging
? "border-blue-500 bg-blue-50 text-blue-600"
: "border-gray-300 bg-gray-50 text-gray-500 hover:border-gray-400"
}`}
>
<p className="text-sm font-medium">
{isDragging ? "ここにドロップ" : "ファイルをドラッグ&ドロップ"}
</p>
<p className="mt-1 text-xs">または クリックして選択</p>
<input
ref={inputRef}
type="file"
accept={accept}
multiple
className="hidden"
onChange={handleInputChange}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
}
ページでの使用例
// app/page.tsx
"use client";
import { useState } from "react";
import { FileDropZone } from "@/components/FileDropZone";
export default function Page() {
const [files, setFiles] = useState<File[]>([]);
function handleFilesSelected(selected: File[]) {
setFiles((prev) => [...prev, ...selected]);
}
return (
<main className="mx-auto max-w-lg p-8">
<h1 className="mb-6 text-2xl font-bold">ファイルアップロード</h1>
<FileDropZone accept="image/*" onFilesSelected={handleFilesSelected} />
{files.length > 0 && (
<ul className="mt-4 space-y-2">
{files.map((file, i) => (
<li key={i} className="flex items-center justify-between rounded border p-3 text-sm">
<span className="truncate text-gray-700">{file.name}</span>
<span className="ml-4 shrink-0 text-xs text-gray-400">
{(file.size / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
)}
</main>
);
}
ポイント
onDragOverでe.preventDefault()を呼ばないとブラウザのデフォルト動作(ファイルを開く)が発生する。必須onDragLeaveは子要素へのマウス移動でも発火するため、e.currentTarget.contains(e.relatedTarget)で内部移動を除外するとドロップゾーンが点滅しなくなるe.dataTransfer.filesはFileListなのでArray.from()で配列に変換するinput[type="file"]を hidden で持ち、クリック時にinputRef.current.click()でプログラム的に開くことで D&D とクリック選択を両立できるreact-hook-form-file-uploadとの使い分け: フォームバリデーションが必要なら RHF 版、ファイル受け付け UI のみ独立して使いたい場合はこのパターン