Dropzone
File acquisition — drop, browse, or paste — as a thin shell over the use-file-drop hook; the drop surface is the named, focusable control.
- Status
- Since
0.4.0- Accessibility pattern
- button-role drop surface
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Dropzone before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify dropzoneInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/dropzoneRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/use-file-drop.
Usage
import { Dropzone } from "@/components/ui/dropzone";
<Dropzone
accept={{ "image/*": [".png", ".jpg"] }}
onFilesAccepted={(files) => stageUploads(files)}
>
<Empty className="border">
<EmptyHeader>
<EmptyTitle>Drop images here</EmptyTitle>
<EmptyDescription>or click to browse</EmptyDescription>
</EmptyHeader>
</Empty>
</Dropzone>;Dropzone is acquisition only — everything behavioural lives in the
use-file-drop hook (over the sanctioned react-dropzone engine), and
everything after acquisition belongs to
Attachment, which "owns no upload logic, only
the visual state machine". The two meet at a plain File[] callback.
Scope
| Behaviour | Where it lives |
|---|---|
| Upload lifecycle, progress | Host + Attachment's state machine |
| Drop/paste/browse behaviour | use-file-drop — compose it directly for chrome-less surfaces (a rich-text composer accepting pasted images) |
| The dashed idle affordance | Children — compose Empty className="border" for the classic drop-zone look |
| Rendering staged files | Host, never internal — no attachments prop by design |
Examples
Typed rejections
Refusals arrive as FileDropRejection — the file plus every reason
(file-too-large, file-invalid-type, too-many-files, …), aligned with the
per-file state vocabulary Attachment renders.
Drag-over states
The surface carries the drag feedback — an inset primary stroke while a valid payload hovers,
an inset destructive one when it cannot be accepted — so it works with any child, not only an
Empty. An absolutely positioned border-box paints the stroke one stroke-width inside the surface,
so it costs no layout and stays within WebKit's scrollable box. An Empty className="border" child
can tint its dashed border in step through group-data-dragging/dropzone.
dragState paints the two states without a live DataTransfer, which neither a static example nor
an automated check can synthesise. It is presentation only: a real drag always wins.
Upload queue
Dropzone only acquires files; the host owns the queue. This example hands each accepted file to an
uploadFile(file, { onProgress, signal }) call — a stub here, fetch or XMLHttpRequest in an app
— and renders the queue as Attachment tiles in an AttachmentGroup layout="grid". One file
and Several files switch multiple: with one, a new file replaces the last.
- In flight —
AttachmentProgressand a Cancel upload action that aborts the signal and drops the file, so a cancelled single upload returns the surface to idle. - Failed — Retry upload and Discard file. A refused file joins the queue as an error tile whose description says why ("… is larger than 25 MB.", "… isn't a supported file type."), with Discard only, since there is nothing to retry.
- Details — every file that is not yet saved gets a
FieldSet, legend the file name, with a NameInput(the file name without its extension) and a CategorySelect, each in aField. An empty name blocks the save with aFieldError. - Uploaded but not saved — the save that attaches the uploads to the record is a second call.
When it fails, a destructive
Alertsays how many files are uploaded but not saved, and the button becomes Retry save, which saves again without uploading again.
A polite status line announces each upload that finishes or fails, each cancel and discard, and the save.
Folders
directories turns on folder mode: a dropped folder is walked (it always is) and every accepted
file comes back through onEntriesAccepted with its relativePath inside the drop —
"brand/logos/mark.svg", the bare name for a loose file — so the host can recreate the folders.
maxFiles then caps the whole drop: a folder with more files is refused entirely as
too-many-files, never cut short. A folder picker opens from a button beside the surface through
actionsRef.current.openDirectory() — never inside it, because the surface is itself the control.
const actions = React.useRef<DropzoneActions>(null);
<Dropzone directories maxFiles={500} actionsRef={actions} onEntriesAccepted={stageTree}>
…
</Dropzone>
<Button onClick={() => actions.current?.openDirectory()}>Upload folder</Button>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
children* | React.ReactNode | — | The idle affordance — typically an Empty className="border" block. |
accept | Accept | — | Accepted types, MIME-to-extensions (react-dropzone's Accept shape):
{ "image/*": [".png", ".jpg"] }. |
actionsRef | React.Ref<DropzoneActions> | — | Imperative handle: open() / openDirectory() the picker from a control outside the surface. |
aria-label | string | "Upload files" | Accessible name for the drop surface (the focusable control). |
className | string | — | Extra classes for the drop surface. |
directories | boolean | false | Folder mode: openDirectory() opens a folder picker, every accepted file reports its
relativePath through onEntriesAccepted, and maxFiles applies to the whole flattened drop —
a folder with more files than that is refused ENTIRELY (too-many-files), never cut short. A
dropped folder is walked either way; this option is what keeps its paths and its cap honest. |
disabled | boolean | false | Disable acquisition entirely. |
dragState | "drag-invalid" | "dragging" | — | Force the drag-over presentation without a real drag. A drag-over state can only be produced
by a live DataTransfer, which a static documentation example and the behaviour-contract lane
cannot synthesise — so the two states would otherwise be undocumented and unverified. It
paints only: the engine still owns the real data-dragging/data-drag-invalid attributes and
a live drag always wins over this prop. |
maxFiles | number | — | Maximum files per batch. |
maxSize | number | — | Per-file maximum size in bytes. |
minSize | number | — | Per-file minimum size in bytes. |
multiple | boolean | true | Allow more than one file per batch. |
onEntriesAccepted | ((entries: FileDropEntry[]) => void) | — | Receives the accepted files of each batch with their path inside a dropped or picked folder — the callback a folder upload wants, so the host can recreate the folder tree. |
onFilesAccepted | ((files: File[]) => void) | — | Receives the accepted files of each drop/paste/browse batch. Pass it, onEntriesAccepted,
or both. |
onFilesRejected | ((rejections: FileDropRejection[]) => void) | — | Receives the refused files of a batch, with typed reasons. |
paste | boolean | true | Also accept files pasted from the clipboard while focus is inside the drop surface. |
preventWindowDrop | boolean | true | Keep a document-level guard armed so a missed FILE drop never navigates the browser away. The guard is shared and ref-counted across every mounted hook on the page (it stays armed while ANY instance wants it), and it is payload-scoped: only drags carrying files are cancelled — text dragged into an unrelated input keeps working. |
ref | React.Ref<HTMLDivElement> | — | Ref forwarded to the drop surface (data-slot="dropzone"). |
Data attributes and CSS variables on Dropzone
| Attribute | Values |
|---|---|
data-disabled | "" |
data-drag-invalid | "" |
data-dragging | "" |
data-slot | "dropzone" |
Rejection shape
| Prop | Type | Default | Description |
|---|---|---|---|
file* | File | — | The refused file. |
reasons* | FileDropRejectionReason[] | — | Every reason that applied. |
Folder entry
| Prop | Type | Default | Description |
|---|---|---|---|
file* | File | — | The accepted file. |
relativePath* | string | — | Its path relative to the drop: "a/c/d.png" for a file inside a dropped folder a, the bare
name for a loose file. Forward slashes, no leading ./ or /. |
Actions
| Prop | Type | Default | Description |
|---|---|---|---|
open* | () => void | — | Open the file picker from a control outside the surface. |
openDirectory* | () => void | — | Open a folder picker (a directories dropzone) — for an "Upload folder" button beside the
surface, never inside it: the surface is itself the control. |
Accessibility
- The accessible control is the drop surface itself —
role="button", named viaaria-label,tabIndex=0, with Enter/Space opening the picker through the engine's keyboard handler. The real<input type="file">behind it is the display:none form/picker bridge, deliberately not a tab stop (an interactive control may not nest another). - Accepted and refused batches announce through a polite live region ("Added shot.png", "2 files were refused — too large").
- The surface reflects drag state as
data-dragging/data-drag-invalid(primary vs destructive border tint) — state is never colour alone: the refusal is also announced and reported throughonFilesRejected. - A missed drop never navigates the browser away — a document-level guard,
scoped to file-bearing drags only (text dragged into your other inputs
keeps working) and shared page-wide with ref-counting: it stays armed
while any mounted Dropzone wants it, and disarms when the last one
unmounts. Opt a surface out with
preventWindowDrop.
| Key | Action |
|---|---|
| Tab | Focus the drop surface. |
| Enter / Space | Open the file browser. |
| ⌘+V | Paste files while inside the surface. |
| Contract | States tested |
|---|---|
| Behaviour | idle, dragging, drag-invalid, accepted, rejected, disabled |
| Accessibility | keyboard, labeled, status-announcement, semantic-html |
| Visual | default, dragging, drag-invalid, disabled |