Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
Since
0.4.0
Accessibility pattern
button-role drop surface

Last updated

Drop images here
or click to browse — PNG, JPG, or WebP

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 dropzone

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/dropzone

Run 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

BehaviourWhere it lives
Upload lifecycle, progressHost + Attachment's state machine
Drop/paste/browse behaviouruse-file-drop — compose it directly for chrome-less surfaces (a rich-text composer accepting pasted images)
The dashed idle affordanceChildren — compose Empty className="border" for the classic drop-zone look
Rendering staged filesHost, 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.

Files up to 200 KB
Anything larger is refused with a typed reason

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.

Release to upload
A valid payload is hovering
Not accepted
This file type is refused
A non-Empty child still shows the drag state — the stroke belongs to the surface.

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 — AttachmentProgress and 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 Name Input (the file name without its extension) and a Category Select, each in a Field. An empty name blocks the save with a FieldError.
  • Uploaded but not saved — the save that attaches the uploads to the record is a second call. When it fails, a destructive Alert says 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.

Drop files here or browse
PDF or images, up to 25 MB each
beam-angle-spec.pdfUploaded, not saved · 2.3 MB
fixture-photo.pngThe connection dropped.
beam-angle-spec.pdf
fixture-photo.png

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>
Drop a folder
Up to 500 files; subfolders keep their place

API Reference

PropTypeDefaultDescription
children*React.ReactNode—The idle affordance — typically an Empty className="border" block.
acceptAccept—Accepted types, MIME-to-extensions (react-dropzone's Accept shape): { "image/*": [".png", ".jpg"] }.
actionsRefReact.Ref<DropzoneActions>—Imperative handle: open() / openDirectory() the picker from a control outside the surface.
aria-labelstring"Upload files"Accessible name for the drop surface (the focusable control).
classNamestring—Extra classes for the drop surface.
directoriesbooleanfalseFolder 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.
disabledbooleanfalseDisable 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.
maxFilesnumber—Maximum files per batch.
maxSizenumber—Per-file maximum size in bytes.
minSizenumber—Per-file minimum size in bytes.
multiplebooleantrueAllow 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.
pastebooleantrueAlso accept files pasted from the clipboard while focus is inside the drop surface.
preventWindowDropbooleantrueKeep 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.
refReact.Ref<HTMLDivElement>—Ref forwarded to the drop surface (data-slot="dropzone").

Data attributes and CSS variables on Dropzone

AttributeValues
data-disabled""
data-drag-invalid""
data-dragging""
data-slot"dropzone"

Rejection shape

PropTypeDefaultDescription
file*File—The refused file.
reasons*FileDropRejectionReason[]—Every reason that applied.

Folder entry

PropTypeDefaultDescription
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

PropTypeDefaultDescription
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 via aria-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 through onFilesRejected.
  • 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.
KeyAction
TabFocus the drop surface.
Enter / SpaceOpen the file browser.
⌘+VPaste files while inside the surface.
ContractStates tested
Behaviouridle, dragging, drag-invalid, accepted, rejected, disabled
Accessibilitykeyboard, labeled, status-announcement, semantic-html
Visualdefault, dragging, drag-invalid, disabled

Do / Don't

Do
Meet Attachment at a plain File[] — stage files in your state and render their upload lifecycle with Attachment's state machine.
Don't
Ask Dropzone to render attachments or own uploads — acquisition and upload state are separate jobs on purpose.

On this page