Drop Zone
A file drop target with a browse affordance, built on React Aria's DropZone and FileTrigger — pointer drag, keyboard drag and the file picker all reach the same list.
Usage
DropZone is a target you drop files onto and a button you pick them with, and the two paths end in
the same place: a list of files under the zone, each with its name, its size, and a remove button.
Both halves are React Aria's. DropZone
owns the drag session — the native pointer drag and the keyboard / screen-reader drag mode it
registers with React Aria's own drag manager — and
FileTrigger owns the file picker.
There is no drag handling in this component: no dragover listener, no dataTransfer, and no
counter to work around dragleave firing on every child element.
Anatomy
DropZone is a single component with props, not a set of parts. A drop zone is one arrangement —
a glyph, a line of copy, a trigger, and a list underneath — and unlike a settings row it never grows
a chip or a second column.
import {DropZone} from "@blakeui/pro-react";
<DropZone
accept={["image/png"]}
description="PNG only."
label="Drop an image"
maxSize={256 * 1024}
multiple
onChange={(files) => console.log(files)}
/>;The zone holds its own list. onChange reports the whole list every time it changes — a drop, a
pick, or a removal — so state outside always matches what the zone is showing.
With File List
- quarterly-report.pdf180 KB
- handover-notes.txt51 KB
Sizes
Disabled
- signed-contract.pdf96 KB
Accepted Types
Max Size Limit
Multiple Files
Nothing attached yet.
Custom Icon
Custom Triggers
CSS Classes
Base Classes
.drop-zone— The root, and a plain<div>wrapping the drop target, the error message and the file list.display: flexin a column with a12pxgap, full width,min-width: 0. It declares the three variables the size modifier retunes —--drop-zone-pad,--drop-zone-glyph,--drop-zone-gap— so every descendant reads one variable instead of matching a modifier of its own, plus two colour variables described below. Carriesdata-invalid="true"while a rejection is showing.--drop-zone-stroke— The idle stroke, and deliberately not the bare--bordera card gets away with. A card's stroke only has to hint at an edge a fill already draws; here the dashed stroke is the affordance, and--bordermeasures1.35:1against the surface in light and1.21:1in dark — invisible at exactly the moment the component has to say "target". Mixed 30/70 toward--muted, which is restated per theme, so the step holds in both: measured3.17:1light and3.99:1dark, over the3:1floor for a non-text UI component either way.--drop-zone-invalid-fill— The error tint,--danger-softmixed back55%toward the surface. At full strength it washes the whole zone pink, which is a louder alarm than a one-line validation message earns — the stroke and the message already carry it..drop-zone--md— The default size.32pxsurface padding, a40pxglyph box, an8pxstack gap..drop-zone--sm—20pxpadding, a32pxglyph box, a6pxgap, and a0.8125remlabel. The file rows keep their44pxfloor either way; a row with a button in it is a touch target at both sizes.
Element Classes
.drop-zone__zone— React Aria'sDropZone. It paints nothing: it carries the state attributes —data-drop-target,data-hovered,data-focused,data-focus-visible,data-disabled— and the visually hidden<button>that React Aria's keyboard drag mode focuses.position: relative,display: block..drop-zone__surface— The bordered box a reader sees, and the element that animates.1px dashed var(--drop-zone-stroke),24pxradius,var(--surface)fill, a centred column, and no elevation. The dashed stroke is the deliberate exception to the card-family bordered-flat rule — it is the convention that says "target, not card" — and the rest of the rule still holds: a stroke and a fill, never a shadow. Its own transition isbackground-colorover 180ms, colour only..drop-zone__icon— The glyph in a roundedvar(--default)tile, sized from--drop-zone-glyph. Whatevericonrenders is sized to20px(16pxatsm) by a.drop-zone__icon svgrule, so a swapped-in icon matches the built-in one without being told its size. The element is not rendered at all wheniconisnull..drop-zone__label— The headline.0.875rem/1.25rem,font-weight: 500,color: var(--foreground). Rendered as React Aria'sText slot="label", so its text becomes part of the zone's accessible name without a secondaria-label..drop-zone__description— Supporting copy.0.75rem/1rem,color: var(--muted),text-wrap: pretty..drop-zone__trigger— The built-in browse button, an ordinarysecondaryButtoninside theFileTrigger, with8pxof top margin. A replacement supplied throughtriggerdoes not carry this class: it is yours to style, and inheriting the built-in button's margin would be a surprise..drop-zone__status— A clippedrole="status"region, notdisplay: none, because the point is that it stays in the accessibility tree. It is the only place the drag state is put into words: React Aria reportsisDropTargetand leaves the wording to the component..drop-zone__error— The rejection message,0.75reminvar(--danger-soft-foreground). Deliberately not the raw--danger, which is declared once and never restated for dark, so it is substituted at the document root and fails against a dark surface..drop-zone__list— The file list, a real<ul>with its list styling removed and an8pxgap..drop-zone__file— One file's row.1px solid var(--border)overvar(--surface),16pxradius, and a44pxminimum height — the row height this codebase settled on for the Cell family..drop-zone__file-icon,.drop-zone__file-text,.drop-zone__file-name,.drop-zone__file-size— The row's parts. The name truncates with an ellipsis rather than wrapping the row to two lines; themin-width: 0on the text column is what lets it, since a flex item otherwise refuses to shrink below its content. The size istabular-nums, so a column of them stays aligned..drop-zone__file-remove— The remove button, pushed to the end withmargin-inline-start: autorather thanjustify-content: space-betweenon the row, so it stays at the end if the row grows a third child.
Interactive States
- Drag over —
.drop-zone__zone[data-drop-target] .drop-zone__surfaceswapsborder-styletosolidand the fill tovar(--accent-soft). The box stops being an invitation and becomes a target. - Focus —
.drop-zone__zone[data-focus-visible] .drop-zone__surfacedrawsoutline: 2px solid var(--focus)atvar(--ring-offset-width). One indicator, on the surface rather than on the visually hidden button React Aria actually focuses — that button has no box to draw around. Anoutlinerather than a ring, so it follows the surface'sborder-radius. - Error —
.drop-zone[data-invalid="true"] .drop-zone__surfacetints the fill tovar(--drop-zone-invalid-fill), and the stroke goes tovar(--danger-soft-foreground)through theanimatetarget. - Disabled —
.drop-zone__zone[data-disabled]takesopacity: var(--disabled-opacity)andpointer-events: none, so the whole target leaves the hit path rather than only the trigger.document.elementFromPointover a disabled zone returns the ancestor, not the surface.
Motion
Border colour and a 1.5% scale on drag-over are animated in JavaScript, by motion's m.div
inside a LazyMotion with domAnimation. Border style is not animatable and stays in CSS, which
is why dashed → solid is a rule and the colour is not: any border-color written in
drop-zone.css is overridden the moment motion mounts.
Reduced motion drops the scale and keeps the colour change — colour is not movement, so removing it
would be reduction past the point of usefulness. Because the animation is motion's rather than a
CSS transition, both halves of the opt-out are read in JavaScript: useReducedMotion covers
@media (prefers-reduced-motion: reduce), and a closest('[data-reduce-motion="true"]') probe
covers the explicit hook, matching that selector's own reach of the element or any ancestor. A
MutationObserver keeps a toolbar toggle live, and the probe runs in a layout effect so a reader
whose preference is already in the served HTML never sees one un-reduced frame.
API Reference
DropZone
A single component with props. Also accepts every native <div> attribute, plus aria-label, which
defaults to "File drop zone". React Aria always supplies a label on the drop target and its own
fallback is the literal string "DropZone" — a component name in the accessibility tree — so this
one cannot be omitted, only replaced. The zone's full accessible name is that half plus the label
copy, which React Aria appends: "File drop zone Drop files here". One half says what the control
is, the other what to do with it.
| Prop | Type | Default | Description |
|---|---|---|---|
accept | string[] | — | Accepted file types, in the three shapes the native accept attribute takes: an extension (".pdf"), a type wildcard ("image/*"), or an exact MIME type ("application/pdf"). The list reaches both paths at once — the file picker filters to it, and a dropped file that misses it is rejected with the type message. Omit to accept anything. |
maxSize | number | — | Largest accepted size per file, in bytes. Checked after accept, and only the first failing reason is surfaced — one message is a fix the reader can act on, two at once is a puzzle. Omit for no limit. |
multiple | boolean | false | Whether the zone holds more than one file. When set, each drop or pick appends to the list and a file already in it is not added twice; when unset, the newest file replaces whatever was there. |
isDisabled | boolean | false | Dims the zone, sets pointer-events: none on the drop target, and disables both the browse button and every remove button in the list. |
onChange | (files: File[]) => void | — | Fires with the whole list every time it changes — a drop, a pick, or a removal — so the state outside always matches what the zone is showing. It does not fire for a rejected file, which never joins the list. |
size | "sm" | "md" | "md" | Surface padding, glyph box, type scale and trigger size. It does not change the file rows, which keep their 44px floor at both sizes. |
defaultFiles | File[] | [] | Files the zone starts with — a form re-rendered with its previous attachments. The list is uncontrolled: the zone owns it from then on. These files are taken as given and are not re-checked against accept or maxSize. |
label | ReactNode | "Drop files here" | The headline. Rendered as React Aria's Text slot="label", so it becomes part of the zone's accessible name — pass aria-label on the root when that name should read differently. |
description | ReactNode | — | Supporting copy under the label. This is where the rule the zone enforces belongs, in words — accept and maxSize are not self-describing. |
icon | ReactNode | CloudArrowUpIn | The glyph in the tile above the label. Sized by CSS rather than by the icon, so a replacement matches the built-in one without being told its size. Pass null to drop the tile along with the glyph, which is what a dense form wants — an empty rounded square is worse than no square. Decorative either way: the tile is aria-hidden, so an icon carrying meaning belongs in label or description instead. This is the zone's glyph only; the one on each file row is .drop-zone__file-icon and is not configurable. |
trigger | (props: {isDisabled, onPress, size}) => ReactNode | — | Replaces the browse button. Receives onPress, which opens the file picker, plus the zone's own isDisabled and size so the replacement can track it rather than being told the same thing twice. Render whatever you like, including more than one control. A function rather than a node, deliberately: React Aria's FileTrigger passes its press handler down through context, so a node would only work when the pressable you hand it comes from the same copy of react-aria-components as this package — something a consumer cannot see, and which fails by rendering a button that simply never fires. Supersedes triggerLabel. |
triggerLabel | ReactNode | "Browse files" | Label on the built-in browse button. Ignored when trigger is given, since that replaces the button outright. |
errorMessage | ReactNode | ((error: DropZoneError) => ReactNode) | — | The error state's message. A node replaces the built-in copy outright; a function receives the rejection — its kind and the files that failed — and returns the copy for it. Left unset, the zone names the file and the rule it broke. |
className | string | — | Class name for the root. |
DropZoneError
The value handed to errorMessage when it is a function.
| Field | Type | Default | Description |
|---|---|---|---|
kind | "type" | "size" | — | "type" when accept rejected the files, "size" when maxSize did. Type is checked first, so a file that fails both reports "type". |
files | File[] | — | The rejected files, in the order they arrived. Their accepted neighbours still join the list — dropping five images and one video attaches the five rather than throwing the drop away. |
Checkbox Button Group
A multi-select group whose options are cards, built on a real checkbox group with keyboard selection and a per-item indicator.
Inline Select
A minimal select that blends into surrounding text — no stroke, no fill, sized by its own type scale — for a contextual choice that does not need a form field.