Forms
File Dropzone
An accessible file picker with drag and drop, type and size checks, removable selections, and a real integration callback.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/file-dropzone.json'use client';
import { useId, useRef, useState } from 'react';
const css = `.fd-root {
--fd-ink: #28372e;
--fd-muted: #647063;
--fd-surface: #f7f8f2;
--fd-line: #d7dfd0;
--fd-accent: #486733;
--fd-soft: #e9efdf;
--fd-error: #a33624;
width: min(100%, 380px);
color: var(--fd-ink);
font: 13px/1.5 system-ui, sans-serif;
}
.fd-root * { box-sizing: border-box; }
.fd-panel { padding: 18px; border: 1px solid var(--fd-line); border-radius: 20px; background: var(--fd-surface); box-shadow: 0 12px 30px #1724170d; }
.fd-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.fd-title { margin: 0; font-size: 14px; font-weight: 650; letter-spacing: -.02em; }
.fd-count { color: var(--fd-muted); font: 11px/1.5 ui-monospace, monospace; }
.fd-zone { display: grid; justify-items: center; gap: 7px; padding: 22px 12px; border: 1px dashed var(--fd-line); border-radius: 13px; background: var(--fd-soft); text-align: center; transition: background 160ms, border-color 160ms; }
.fd-root[data-dragging="true"] .fd-zone { border-color: var(--fd-accent); background: var(--fd-surface); outline: 2px solid var(--fd-accent); outline-offset: 3px; }
.fd-icon { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid var(--fd-line); border-radius: 11px; background: var(--fd-surface); color: var(--fd-accent); }
.fd-icon svg { width: 20px; height: 20px; }
.fd-copy { margin: 0; font-size: 12px; font-weight: 550; }
.fd-help { margin: 0; color: var(--fd-muted); font-size: 11px; overflow-wrap: anywhere; }
.fd-root button { font: inherit; cursor: pointer; }
.fd-browse { padding: 7px 14px; margin-top: 3px; border: 1px solid var(--fd-line); border-radius: 8px; background: var(--fd-surface); color: var(--fd-ink); font-size: 12px !important; font-weight: 550 !important; }
.fd-root button:hover { background: var(--fd-soft); }
.fd-root button:focus-visible { outline: 2px solid var(--fd-accent); outline-offset: 3px; }
.fd-files { display: grid; gap: 6px; max-height: 190px; overflow-y: auto; list-style: none; margin: 12px 0 0; padding: 0; }
.fd-files:empty { display: none; }
.fd-file { display: flex; align-items: center; gap: 9px; padding: 8px 7px 8px 10px; border: 1px solid var(--fd-line); border-radius: 10px; }
.fd-file-icon { color: var(--fd-accent); flex: none; width: 18px; height: 18px; }
.fd-info { flex: 1; min-width: 0; }
.fd-name { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 550; }
.fd-size { margin: 0; font-size: 10px; color: var(--fd-muted); }
.fd-remove { display: grid; place-items: center; width: 30px; height: 30px; flex: none; border: 0; border-radius: 7px; background: transparent; color: var(--fd-muted); font-size: 18px !important; }
.fd-footer { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 11px; }
.fd-local { margin: 0; color: var(--fd-muted); font-size: 10px; }
.fd-clear { border: 0; padding: 2px 0; background: none; color: var(--fd-accent); font-size: 10px !important; text-decoration: underline; text-underline-offset: 3px; }
.fd-clear[hidden] { display: none; }
.fd-errors { margin: 9px 0 0; color: var(--fd-error); font-size: 11px; white-space: pre-line; overflow-wrap: anywhere; }
.fd-errors:empty { display: none; }
.fd-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:root[data-theme="dark"] .fd-root { --fd-ink: #e7edde; --fd-muted: #a4b09b; --fd-surface: #242d22; --fd-line: #414e3a; --fd-accent: #b9d891; --fd-soft: #2c3826; --fd-error: #ffab96; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .fd-root { --fd-ink: #e7edde; --fd-muted: #a4b09b; --fd-surface: #242d22; --fd-line: #414e3a; --fd-accent: #b9d891; --fd-soft: #2c3826; --fd-error: #ffab96; }
}
@media (prefers-reduced-motion: reduce) { .fd-zone { transition: none; } }
`;
function formatSize(bytes: number) {
return bytes < 1024 * 1024 ? `${Math.max(1, Math.ceil(bytes / 1024))} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fileKey(file: File) {
return `${file.name}:${file.size}:${file.lastModified}:${file.type}`;
}
export function FileDropzone({
label = 'Project files',
accept = '.png,.jpg,.jpeg,.webp,.pdf',
maxFiles = 5,
maxSize = 10 * 1024 * 1024,
onFilesChange,
}: {
label?: string;
/** Comma-separated extensions, MIME types or wildcards, matching input.accept. */
accept?: string;
maxFiles?: number;
/** Maximum bytes per file. Browser checks are convenience, not server validation. */
maxSize?: number;
/** Receives the full selection after additions/removals. No upload is performed. */
onFilesChange?: (files: File[]) => void;
}) {
const id = useId();
const input = useRef<HTMLInputElement>(null);
const browse = useRef<HTMLButtonElement>(null);
const depth = useRef(0);
const [files, setFiles] = useState<File[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [announcement, setAnnouncement] = useState('');
const [dragging, setDragging] = useState(false);
const limit = Number.isFinite(maxFiles) ? Math.max(1, Math.floor(maxFiles)) : 5;
const sizeLimit = Number.isFinite(maxSize) ? Math.max(1, maxSize) : 10 * 1024 * 1024;
function commit(next: File[], message: string) {
setFiles(next);
setAnnouncement(message);
onFilesChange?.([...next]);
}
function add(incoming: File[]) {
const next = [...files];
const rejected: string[] = [];
const rules = accept.toLowerCase().split(',').map(value => value.trim()).filter(Boolean);
for (const file of incoming) {
const matches = !rules.length || rules.some(rule => rule.startsWith('.') ? file.name.toLowerCase().endsWith(rule) : rule.endsWith('/*') ? file.type.toLowerCase().startsWith(rule.slice(0, -1)) : file.type.toLowerCase() === rule);
if (!matches) rejected.push(`${file.name}: this file type is not accepted.`);
else if (file.size > sizeLimit) rejected.push(`${file.name}: exceeds ${formatSize(sizeLimit)}.`);
else if (next.some(item => fileKey(item) === fileKey(file))) rejected.push(`${file.name}: already selected.`);
else if (next.length >= limit) rejected.push(`${file.name}: choose up to ${limit} files.`);
else next.push(file);
}
setErrors(rejected);
commit(next, `${next.length} ${next.length === 1 ? 'file' : 'files'} selected. Files stay on your device.`);
}
return <>
<style>{css}</style>
<section className="fd-root" aria-labelledby={`${id}-title`} data-dragging={dragging}
onDragEnter={event => { event.preventDefault(); if (event.dataTransfer.types.includes('Files')) { depth.current++; setDragging(true); } }}
onDragOver={event => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; }}
onDragLeave={event => { event.preventDefault(); depth.current = Math.max(0, depth.current - 1); if (!depth.current) setDragging(false); }}
onDrop={event => { event.preventDefault(); depth.current = 0; setDragging(false); add(Array.from(event.dataTransfer.files)); }}>
<div className="fd-panel">
<div className="fd-heading"><h3 className="fd-title" id={`${id}-title`}>{label}</h3><span className="fd-count">{files.length} / {limit}</span></div>
<div className="fd-zone">
<span className="fd-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 16V4m-4 4 4-4 4 4M5 15v5h14v-5" /></svg></span>
<p className="fd-copy">Drop files here, or choose them below</p>
<p className="fd-help" id={`${id}-help`}>{accept || 'Any file type'} · Up to {formatSize(sizeLimit)} each</p>
<button ref={browse} type="button" className="fd-browse" aria-describedby={`${id}-help ${id}-local`} onClick={() => input.current?.click()}>Browse files</button>
<input ref={input} type="file" accept={accept} multiple hidden aria-label={label} onChange={event => { add(Array.from(event.currentTarget.files || [])); event.currentTarget.value = ''; }} />
</div>
<ul className="fd-files" aria-label="Selected files">{files.map(file => <li className="fd-file" key={fileKey(file)}>
<svg className="fd-file-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><path d="M14 3H5v18h14V8l-5-5Zm0 0v5h5M8 13h8M8 17h5" /></svg>
<div className="fd-info"><p className="fd-name" title={file.name}>{file.name}</p><p className="fd-size">{formatSize(file.size)} · Selected locally</p></div>
<button type="button" className="fd-remove" aria-label={`Remove ${file.name}`} onClick={() => { setErrors([]); commit(files.filter(item => item !== file), `${file.name} removed.`); browse.current?.focus(); }}>×</button>
</li>)}</ul>
<p className="fd-errors" role="alert">{errors.join('\n')}</p>
<div className="fd-footer"><p className="fd-local" id={`${id}-local`}>Files stay on your device. Nothing is uploaded.</p>{files.length > 0 && <button type="button" className="fd-clear" onClick={() => { setErrors([]); commit([], 'Selection cleared.'); browse.current?.focus(); }}>Clear all</button>}</div>
<p className="fd-sr" role="status">{announcement}</p>
</div>
</section>
</>;
}