"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export type DropzoneFileStatus = "ready" | "uploading" | "done" | "error";
export interface DropzoneFile {
id: string;
file: File;
status: DropzoneFileStatus;
/** Upload progress from 0 to 1. */
progress: number;
/** Validation or upload error message. */
error?: string;
/** True when validation failed: the file was never accepted and is not uploaded. */
rejected?: boolean;
/** Object URL for image thumbnails (revoked by the component on remove/unmount). */
preview?: string;
}
export type UploadHandler = (
file: File,
reportProgress: (fraction: number) => void,
signal: AbortSignal,
) => Promise<void>;
export interface FileDropzoneHandle {
/** Opens the native file picker. */
open: () => void;
/** Adds files programmatically (validated like dropped files). */
addFiles: (files: File[] | FileList) => void;
/** Removes every file, aborting uploads in flight. */
clear: () => void;
}
export interface FileDropzoneProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children" | "onChange"> {
/** Same syntax as <input accept>: ".pdf,image/*". Empty accepts any type. */
accept?: string;
/** Maximum size per file in bytes. 0 means no limit. */
maxSize?: number;
/** Maximum number of accepted files. */
maxFiles?: number;
/** Controlled file list. Pair with onFilesChange; you may set progress/status yourself. */
files?: DropzoneFile[];
/** Called with the full list after every add, remove and progress update. */
onFilesChange?: (files: DropzoneFile[]) => void;
/** Uploads one file. Report progress (0–1), resolve when done, reject with an Error to fail. */
onUpload?: UploadHandler;
/** Show thumbnails for image files. */
previews?: boolean;
/** Main call to action inside the zone. */
label?: string;
size?: "sm" | "md";
/** Drag highlight, progress and focus colour (any CSS colour). */
accent?: string;
disabled?: boolean;
/** id for the file input (generated when omitted). */
inputId?: string;
}
/* -------------------------------- Helpers -------------------------------- */
export function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let n = bytes / 1024;
let u = 0;
while (n >= 1024 && u < units.length - 1) {
n /= 1024;
u++;
}
return `${n >= 10 ? Math.round(n) : n.toFixed(1).replace(/\.0$/, "")} ${units[u]}`;
}
const parseAccept = (accept: string) =>
accept
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
function matchesAccept(name: string, type: string, tokens: string[]) {
if (!tokens.length) return true;
const lowerName = name.toLowerCase();
const lowerType = type.toLowerCase();
return tokens.some((t) =>
t.startsWith(".") ? lowerName.endsWith(t) : t.endsWith("/*") ? lowerType.startsWith(t.slice(0, -1)) : lowerType === t,
);
}
function describeAccept(tokens: string[]) {
if (!tokens.length) return "Any file type";
return tokens
.map((t) => (t.startsWith(".") ? t.slice(1).toUpperCase() : t.endsWith("/*") ? `${t[0].toUpperCase()}${t.slice(1, -2)}s` : (t.split("/")[1] ?? t).toUpperCase()))
.join(", ");
}
const extensionOf = (name: string) => {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1, dot + 5).toUpperCase() : "FILE";
};
const sameFile = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
/* -------------------------------------------------------------------------- */
/**
* Drag-and-drop or click-to-browse file picker built on a real <input type="file">. Files are
* validated against accept / maxSize / maxFiles with per-file messages, listed with icons or
* thumbnails, and uploaded through an optional onUpload(file, reportProgress, signal) callback —
* or driven entirely by a controlled `files` list. Summaries are announced in a live region.
*/
export const FileDropzone = React.forwardRef<FileDropzoneHandle, FileDropzoneProps>(function FileDropzone(
{
accept = "",
maxSize = 5 * 1024 * 1024,
maxFiles = 5,
files,
onFilesChange,
onUpload,
previews = true,
label = "Drop files here",
size = "md",
accent = "#FF7448",
disabled = false,
inputId: inputIdProp,
className = "",
style,
...props
},
ref,
) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const inputId = inputIdProp ?? `dz-${uid}`;
const titleId = `${inputId}-title`;
const rulesId = `${inputId}-rules`;
const [inner, setInner] = React.useState<DropzoneFile[]>([]);
const entries = files ?? inner;
// Source of truth for async updates (progress callbacks outlive renders).
const entriesRef = React.useRef<DropzoneFile[]>(files ?? []);
React.useLayoutEffect(() => {
if (files) entriesRef.current = files;
}, [files]);
const [drag, setDrag] = React.useState<"idle" | "accept" | "reject">("idle");
const [announcement, setAnnouncement] = React.useState("");
const dragDepth = React.useRef(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const counter = React.useRef(0);
const controllers = React.useRef(new Map<string, AbortController>());
const objectUrls = React.useRef(new Set<string>());
const removeButtons = React.useRef(new Map<string, HTMLButtonElement>());
// Abort uploads and release thumbnails on unmount.
React.useEffect(() => {
const ctrls = controllers.current;
const urls = objectUrls.current;
return () => {
ctrls.forEach((c) => c.abort());
ctrls.clear();
urls.forEach((u) => URL.revokeObjectURL(u));
urls.clear();
};
}, []);
const tokens = parseAccept(accept);
const limit = Math.max(1, maxFiles);
const acceptedCount = entries.filter((e) => !e.rejected).length;
const totalBytes = entries.reduce((sum, e) => (e.rejected ? sum : sum + e.file.size), 0);
const full = acceptedCount >= limit;
const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));
const commit = (next: DropzoneFile[]) => {
entriesRef.current = next;
if (files === undefined) setInner(next);
onFilesChange?.(next);
};
const patch = (id: string, change: Partial<DropzoneFile>) => {
if (!entriesRef.current.some((e) => e.id === id)) return;
commit(entriesRef.current.map((e) => (e.id === id ? { ...e, ...change } : e)));
};
const startUpload = (entry: DropzoneFile) => {
if (!onUpload) return;
const controller = new AbortController();
controllers.current.set(entry.id, controller);
const report = (fraction: number) => {
if (!controller.signal.aborted) patch(entry.id, { progress: Math.min(1, Math.max(0, fraction)) });
};
onUpload(entry.file, report, controller.signal).then(
() => {
if (controller.signal.aborted) return;
patch(entry.id, { status: "done", progress: 1 });
announce(`${entry.file.name} uploaded.`);
},
(reason: unknown) => {
if (controller.signal.aborted) return;
const message = reason instanceof Error && reason.message ? reason.message : "Upload failed";
patch(entry.id, { status: "error", error: message });
announce(`${entry.file.name} failed to upload: ${message}.`);
},
).finally(() => {
if (controllers.current.get(entry.id) === controller) controllers.current.delete(entry.id);
});
};
const addFiles = (incoming: File[] | FileList) => {
const list = Array.from(incoming);
if (!list.length || disabled) return;
const current = entriesRef.current;
let accepted = current.filter((e) => !e.rejected).length;
const added: DropzoneFile[] = [];
const rejected: DropzoneFile[] = [];
for (const file of list) {
let error: string | undefined;
if (!matchesAccept(file.name, file.type, tokens)) error = "File type not accepted";
else if (maxSize > 0 && file.size > maxSize) error = `Larger than ${formatBytes(maxSize)}`;
else if ([...current, ...added].some((e) => !e.rejected && sameFile(e.file, file))) error = "Already added";
else if (accepted >= limit) error = `Limit of ${limit} ${limit === 1 ? "file" : "files"} reached`;
let preview: string | undefined;
if (!error && previews && file.type.startsWith("image/")) {
preview = URL.createObjectURL(file);
objectUrls.current.add(preview);
}
const entry: DropzoneFile = {
id: `${uid}-${++counter.current}`,
file,
status: error ? "error" : onUpload ? "uploading" : "ready",
progress: 0,
error,
rejected: Boolean(error),
preview,
};
if (error) rejected.push(entry);
else {
accepted++;
added.push(entry);
}
}
// Accepted files are listed before this batch's rejections.
commit([...current, ...added, ...rejected]);
added.forEach(startUpload);
const parts: string[] = [];
if (added.length) parts.push(`${added.length} ${added.length === 1 ? "file" : "files"} added${onUpload ? ", uploading" : ""}.`);
if (rejected.length)
parts.push(
`${rejected.length} ${rejected.length === 1 ? "file" : "files"} rejected: ${rejected.map((r) => `${r.file.name}, ${r.error}`).join("; ")}.`,
);
announce(parts.join(" "));
};
const releaseEntry = (entry: DropzoneFile) => {
controllers.current.get(entry.id)?.abort();
controllers.current.delete(entry.id);
if (entry.preview) {
URL.revokeObjectURL(entry.preview);
objectUrls.current.delete(entry.preview);
}
};
const remove = (id: string) => {
const list = entriesRef.current;
const index = list.findIndex((e) => e.id === id);
const entry = list[index];
if (!entry) return;
releaseEntry(entry);
const next = list.filter((e) => e.id !== id);
commit(next);
announce(`${entry.status === "uploading" ? "Cancelled" : "Removed"} ${entry.file.name}.`);
// Keep keyboard focus in the list: next item, previous item, or the picker.
const target = next[index] ?? next[index - 1];
requestAnimationFrame(() => {
const el = target ? removeButtons.current.get(target.id) : null;
(el ?? inputRef.current)?.focus();
});
};
const retry = (id: string) => {
const entry = entriesRef.current.find((e) => e.id === id);
if (!entry || entry.rejected || !onUpload) return;
patch(id, { status: "uploading", progress: 0, error: undefined });
startUpload(entry);
};
const clear = () => {
entriesRef.current.forEach(releaseEntry);
commit([]);
announce("All files removed.");
};
React.useImperativeHandle(ref, () => ({ open: () => inputRef.current?.click(), addFiles, clear }));
/* ---------------------------- Drag and drop ---------------------------- */
const evaluateDrag = (data: DataTransfer): "accept" | "reject" => {
if (disabled || full) return "reject";
const types = Array.from(data.items)
.filter((i) => i.kind === "file")
.map((i) => i.type);
// File names are hidden until drop and MIME types can be empty, so only a definite
// MIME mismatch rejects early; everything else is validated per file on drop.
if (!tokens.length || tokens.some((t) => t.startsWith(".")) || types.some((t) => !t)) return "accept";
return types.some((t) => matchesAccept("", t, tokens)) ? "accept" : "reject";
};
const dragHandlers = {
onDragEnter: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
dragDepth.current++;
if (dragDepth.current === 1) setDrag(evaluateDrag(e.dataTransfer));
},
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = drag === "reject" ? "none" : "copy";
},
onDragLeave: () => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrag("idle");
},
onDrop: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
dragDepth.current = 0;
setDrag("idle");
addFiles(e.dataTransfer.files);
},
};
const rules = [describeAccept(tokens), maxSize > 0 ? `up to ${formatBytes(maxSize)} each` : null, `max ${limit} ${limit === 1 ? "file" : "files"}`]
.filter(Boolean)
.join(" · ");
const sm = size === "sm";
const title = drag === "accept" ? "Release to add files" : drag === "reject" ? (full ? "File limit reached" : "These files can't be added") : label;
return (
<div className={`w-full ${className}`} style={{ ["--dz-accent" as string]: accent, ...style }} {...props}>
<label
htmlFor={inputId}
{...dragHandlers}
data-state={drag}
className={`group relative flex flex-col items-center justify-center rounded-2xl text-center transition-colors duration-200 motion-reduce:transition-none has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_srgb,var(--dz-accent)_30%,transparent)] ${
sm ? "gap-2 px-4 py-6" : "gap-3 px-6 py-10"
} ${
drag === "accept"
? "bg-[color-mix(in_srgb,var(--dz-accent)_9%,transparent)] text-[var(--dz-accent)]"
: drag === "reject"
? "bg-rose-500/[0.07] text-rose-500"
: "bg-zinc-900/[0.02] text-zinc-900/25 hover:bg-zinc-900/[0.04] hover:text-zinc-900/40 dark:bg-white/[0.02] dark:text-white/20 dark:hover:bg-white/[0.04] dark:hover:text-white/35"
} ${disabled ? "pointer-events-none opacity-50" : "cursor-pointer"}`}
>
{/* Dashed border drawn in SVG so it can march while files hover over the zone. */}
<svg aria-hidden="true" className="pointer-events-none absolute inset-0 size-full overflow-visible">
<motion.rect
x="0.75"
y="0.75"
rx="15"
fill="none"
stroke="currentColor"
strokeWidth={drag === "idle" ? 1.5 : 2}
strokeDasharray="7 6"
style={{ width: "calc(100% - 1.5px)", height: "calc(100% - 1.5px)" }}
animate={drag !== "idle" && !reduceMotion ? { strokeDashoffset: [0, -26] } : { strokeDashoffset: 0 }}
transition={drag !== "idle" && !reduceMotion ? { duration: 0.9, ease: "linear", repeat: Infinity } : { duration: 0 }}
/>
</svg>
<input
ref={inputRef}
id={inputId}
type="file"
multiple={limit > 1}
accept={accept || undefined}
disabled={disabled}
aria-labelledby={titleId}
aria-describedby={rulesId}
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = ""; // allow picking the same file again
}}
className="sr-only"
/>
<motion.span
aria-hidden="true"
animate={reduceMotion ? undefined : { y: drag === "accept" ? -4 : 0, scale: drag === "accept" ? 1.06 : 1 }}
transition={{ type: "spring", stiffness: 400, damping: 24 }}
className={`grid place-items-center rounded-xl border bg-white text-zinc-700 shadow-[0_1px_0_rgba(0,0,0,0.04)] dark:bg-zinc-900 dark:text-zinc-200 ${
sm ? "size-9" : "size-11"
} ${drag === "reject" ? "border-rose-500/40" : drag === "accept" ? "border-[var(--dz-accent)]" : "border-zinc-900/10 dark:border-white/10"}`}
>
<svg viewBox="0 0 20 20" className="size-5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13V3.5M6.25 7.25 10 3.5l3.75 3.75" />
<path d="M3.5 12.5v2.25c0 .97.78 1.75 1.75 1.75h9.5c.97 0 1.75-.78 1.75-1.75V12.5" />
</svg>
</motion.span>
<span className="flex flex-col gap-1">
<span id={titleId} className={`font-medium tracking-[-0.01em] text-zinc-950 dark:text-zinc-50 ${sm ? "text-[14px]" : "text-[15px]"}`}>
{title}
{drag === "idle" && (
<span className="font-normal text-zinc-500 dark:text-zinc-400">
{" "}
or{" "}
<span className="text-zinc-950 underline decoration-[var(--dz-accent)] decoration-2 underline-offset-[3px] dark:text-white">browse</span>
</span>
)}
</span>
<span id={rulesId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{rules}
</span>
</span>
</label>
{entries.length > 0 && (
<ul aria-label="Selected files" className="mt-3 flex flex-col gap-2">
<AnimatePresence initial={false}>
{entries.map((entry) => {
const pct = Math.round(entry.progress * 100);
const failed = entry.status === "error";
return (
<motion.li
key={entry.id}
layout={reduceMotion ? false : "position"}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0, pointerEvents: "none" } : { opacity: 0, scale: 0.96, pointerEvents: "none", transition: { duration: 0.16 } }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
className={`flex items-center gap-3 rounded-xl border bg-white p-2.5 dark:bg-zinc-950 ${
failed ? "border-rose-500/30 dark:border-rose-400/30" : "border-zinc-900/10 dark:border-white/10"
}`}
>
<span
aria-hidden="true"
className="relative grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg border border-zinc-900/10 bg-zinc-900/[0.03] dark:border-white/10 dark:bg-white/[0.04]"
>
{entry.preview ? (
// eslint-disable-next-line @next/next/no-img-element -- object URL thumbnail; the shipped file must not depend on next/image
<img src={entry.preview} alt="" className="size-full object-cover" />
) : (
<>
<svg viewBox="0 0 24 24" className="size-6 text-zinc-400 dark:text-zinc-500" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round">
<path d="M6 2.75h8.25L19 7.5v13.75H6z" />
<path d="M14 2.75V7.75h5" />
</svg>
<span className="absolute bottom-1 font-mono text-[7px] font-semibold tracking-[0.06em] text-zinc-600 dark:text-zinc-300">
{extensionOf(entry.file.name)}
</span>
</>
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-[13px] font-medium text-zinc-900 dark:text-zinc-100">{entry.file.name}</span>
<span className="shrink-0 font-mono text-[10px] tabular-nums tracking-[0.06em] text-zinc-500 dark:text-zinc-400">
{formatBytes(entry.file.size)}
</span>
</div>
<div className="mt-1.5 flex h-4 items-center gap-2.5">
{entry.status === "uploading" && (
<>
<span
role="progressbar"
aria-label={`Uploading ${entry.file.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
className="relative h-1 flex-1 overflow-hidden rounded-full bg-zinc-900/[0.08] dark:bg-white/[0.1]"
>
<span
className="absolute inset-0 origin-left rounded-full bg-[var(--dz-accent)] transition-[scale] duration-200 ease-out motion-reduce:transition-none"
style={{ scale: `${entry.progress} 1` }}
/>
</span>
<span className="w-9 text-right font-mono text-[10px] tabular-nums text-zinc-500 dark:text-zinc-400">{pct}%</span>
</>
)}
{entry.status === "done" && (
<span className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-emerald-600 dark:text-emerald-400">
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.5 6.2 5 8.5l4.5-5" />
</svg>
Uploaded
</span>
)}
{entry.status === "ready" && (
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">Ready</span>
)}
{failed && (
<span className="flex min-w-0 items-center gap-2 text-[12px] font-medium text-rose-600 dark:text-rose-400">
<span className="truncate">{entry.error}</span>
{!entry.rejected && onUpload && (
<button
type="button"
onClick={() => retry(entry.id)}
className="shrink-0 rounded font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-900 underline decoration-[var(--dz-accent)] underline-offset-2 outline-none focus-visible:ring-2 focus-visible:ring-[var(--dz-accent)] dark:text-white"
>
Retry<span className="sr-only"> {entry.file.name}</span>
</button>
)}
</span>
)}
</div>
</div>
<button
ref={(el) => {
if (el) removeButtons.current.set(entry.id, el);
else removeButtons.current.delete(entry.id);
}}
type="button"
onClick={() => remove(entry.id)}
aria-label={`${entry.status === "uploading" ? "Cancel upload of" : "Remove"} ${entry.file.name}`}
className="grid size-8 shrink-0 place-items-center rounded-lg text-zinc-500 outline-none transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--dz-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white"
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
)}
{entries.length > 0 && (
<div className="mt-2.5 flex items-center justify-between gap-3 px-1">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{acceptedCount} of {limit} {limit === 1 ? "file" : "files"} · {formatBytes(totalBytes)}
</span>
<button
type="button"
onClick={clear}
className="rounded font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 outline-none transition-colors hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--dz-accent)] dark:text-zinc-400 dark:hover:text-white"
>
Clear all
</button>
</div>
)}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</span>
</div>
);
});