Dropzone
Drag-and-drop file input built on the native HTML5 drag-drop API plus a visually-hidden <input type='file'> for keyboard + screen-reader access. Filters by accept/maxSize/maxFiles before emitting.
Image upload — multi-file, ≤2 MB each, image/* only
Resume upload — single file, PDF only
Installation
pnpm dlx @hex-core/cli add dropzoneCustom body via render prop
Take full control of the drop area visuals
import { Dropzone } from "@/components/ui/dropzone";
export function Example() {
return (
<Dropzone aria-label="Upload" onFilesSelected={(f) => console.log(f)}>
{({ isDragOver }) => (
<span>{isDragOver ? "Release to upload" : "Drop a file or click"}</span>
)}
</Dropzone>
);
}CSV upload with preview
Single-file CSV import that shows the first three rows after parsing. The teaching split is naive (newline + comma) — swap in PapaParse or csv-parse for real-world quoting / escapes.
import { useState } from "react";
import { Dropzone } from "@/components/ui/dropzone";
interface Preview {
fileName: string;
rows: string[][];
}
export function CsvImport() {
const [preview, setPreview] = useState<Preview | null>(null);
async function handleFiles(files: File[]) {
const file = files[0];
if (!file) return;
const text = await file.text();
const rows = text
.split(/\r?\n/)
.filter((line) => line.length > 0)
.slice(0, 3)
.map((line) => line.split(","));
setPreview({ fileName: file.name, rows });
}
return (
<div className="space-y-3">
<Dropzone
accept="text/csv"
multiple={false}
onFilesSelected={handleFiles}
aria-label="Upload CSV"
/>
{preview ? (
<div className="rounded-md border">
<div className="border-b bg-muted/40 px-3 py-2 text-xs font-medium">
{preview.fileName} - first {preview.rows.length} rows
</div>
<table className="w-full text-sm">
<tbody>
{preview.rows.map((row, rowIdx) => (
<tr key={rowIdx} className="border-b last:border-0">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="px-3 py-1.5 font-mono text-xs">{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
);
}API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
onFilesSelected | function | — | Callback fired with the accepted File[] on pick or drop. |
onFilesRejected | function | — | Callback fired with the rejected File[] when files fail accept/maxSize/maxFiles, OR when multiple={false} and extras were sliced off. Receives the rejected File[]. Use to surface 'wrong type' / 'too large' toasts. |
accept | string | — | Accept attribute forwarded to the hidden file input. Three forms: MIME type ('application/pdf'), MIME wildcard ('image/*'), or extension with leading dot ('.csv'). Extension matching is suffix-based (mirrors HTML5) — '.csv' will match 'notes.md.csv' as well. |
multiple | boolean | true | Allow multiple files. With `multiple={false}`, the first accepted file flows to onFilesSelected and any extras flow to onFilesRejected so consumers can toast them. |
maxFiles | number | — | Cap on the number of accepted files. Excess files are dropped silently — surface in your handler if needed. |
maxSize | number | — | Maximum size per file in bytes. Files over the cap are filtered before onFilesSelected fires. |
disabled | boolean | false | Disable interaction (click + drop + keyboard). |
children | object | — | Render override for the body. Pass a node, or a function receiving { isDragOver, isDisabled, openFileDialog } for layout control. |
aria-labelrequired | string | — | Required accessible name for the drop area. |
AI Guidance
When to use
Use for any file upload UX where drag-drop is helpful (image uploaders, CSV import, attachment pickers). Built on native HTML5 + a hidden <input type='file'> so keyboard and screen-reader users get the same affordance.
When not to use
Don't use for camera/microphone capture (use <input type='file' capture> directly or a media-specific component). Don't use for chunked/resumable uploads — Dropzone only emits File[]; pair with a real upload pipeline (tus, S3 multipart). Don't use for clipboard paste image upload — listen on document for that.
Common mistakes
- Forgetting aria-label — without it the drop area is announced as just 'button' to screen readers
- Setting accept='image' (missing the slash or wildcard) — must be a MIME type, MIME prefix with /*, or a .extension
- Forgetting that extension matching is suffix-based — '.csv' will match 'notes.md.csv'. Acceptable (mirrors HTML5) but document upstream if business logic requires strict suffix-only
- Treating maxFiles as enforcement — files past the cap are dropped silently; if business rules require it, validate again on submit
- Forgetting to clear the file input value after a successful upload — Dropzone resets the hidden input automatically; if you wrap it, do the same
- Calling preventDefault on parent containers' onDragOver — without it the browser opens the file when dropped on the window outside the dropzone
- Treating empty drops as no-op silently when the user expected feedback — pair with onFilesRejected to surface filter failures
Accessibility
The drop area is a role='button' div with tabIndex=0 and the required aria-label; Enter and Space open the file dialog. The file input is visually-hidden but live in the DOM so keyboard focus + assistive-tech file pickers work. Drag-state is exposed via data-drag-over for CSS-only state styling. When disabled, the drop area is removed from the tab order and aria-disabled is set.
Token budget: 1500
Verified against @hex-core/components@1.12.0