Forms

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

Drop files herePDF, DOCX or PNG, one file at a time.
No files selected.

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

Drop more files hereEverything here is attached to the claim.
2 files selected.
  • quarterly-report.pdf180 KB
  • handover-notes.txt51 KB

Sizes

SmallThe compact zone, for a dense form.
No files selected.
MediumThe default zone.
No files selected.

Disabled

Uploads closedThis claim was submitted, so its attachments are frozen.
1 file selected.
  • signed-contract.pdf96 KB

Accepted Types

Drop an imagePNG, JPEG or WebP.
No files selected.

Max Size Limit

Drop a receiptUp to 256 KB per file.
No files selected.

Multiple Files

Drop files hereAdd as many as you need.
No files selected.

Nothing attached yet.

Custom Icon

Drop an imagePNG, JPEG or WebP.
No files selected.
Drop the contractOne signed PDF.
No files selected.
CompactNo glyph, no tile.
No files selected.

Custom Triggers

Drag a file here, orThe affordance sits in the copy rather than under it.
No files selected.
Add attachmentsA wide primary button for a form's main upload step.
No files selected.
Uploads closedisDisabled reaches the replacement, so it dims with the zone.
No files selected.
Compactsize comes through too, so the replacement matches without being told.
No files selected.

CSS Classes

Base Classes

  • .drop-zone — The root, and a plain <div> wrapping the drop target, the error message and the file list. display: flex in a column with a 12px gap, 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. Carries data-invalid="true" while a rejection is showing.
  • --drop-zone-stroke — The idle stroke, and deliberately not the bare --border a 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 --border measures 1.35:1 against the surface in light and 1.21:1 in 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: measured 3.17:1 light and 3.99:1 dark, over the 3:1 floor for a non-text UI component either way.
  • --drop-zone-invalid-fill — The error tint, --danger-soft mixed back 55% 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. 32px surface padding, a 40px glyph box, an 8px stack gap.
  • .drop-zone--sm20px padding, a 32px glyph box, a 6px gap, and a 0.8125rem label. The file rows keep their 44px floor either way; a row with a button in it is a touch target at both sizes.

Element Classes

  • .drop-zone__zone — React Aria's DropZone. 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), 24px radius, 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 is background-color over 180ms, colour only.
  • .drop-zone__icon — The glyph in a rounded var(--default) tile, sized from --drop-zone-glyph. Whatever icon renders is sized to 20px (16px at sm) by a .drop-zone__icon svg rule, so a swapped-in icon matches the built-in one without being told its size. The element is not rendered at all when icon is null.
  • .drop-zone__label — The headline. 0.875rem / 1.25rem, font-weight: 500, color: var(--foreground). Rendered as React Aria's Text slot="label", so its text becomes part of the zone's accessible name without a second aria-label.
  • .drop-zone__description — Supporting copy. 0.75rem / 1rem, color: var(--muted), text-wrap: pretty.
  • .drop-zone__trigger — The built-in browse button, an ordinary secondary Button inside the FileTrigger, with 8px of top margin. A replacement supplied through trigger does 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 clipped role="status" region, not display: 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 reports isDropTarget and leaves the wording to the component.
  • .drop-zone__error — The rejection message, 0.75rem in var(--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 an 8px gap.
  • .drop-zone__file — One file's row. 1px solid var(--border) over var(--surface), 16px radius, and a 44px minimum 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; the min-width: 0 on the text column is what lets it, since a flex item otherwise refuses to shrink below its content. The size is tabular-nums, so a column of them stays aligned.
  • .drop-zone__file-remove — The remove button, pushed to the end with margin-inline-start: auto rather than justify-content: space-between on 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__surface swaps border-style to solid and the fill to var(--accent-soft). The box stops being an invitation and becomes a target.
  • Focus.drop-zone__zone[data-focus-visible] .drop-zone__surface draws outline: 2px solid var(--focus) at var(--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. An outline rather than a ring, so it follows the surface's border-radius.
  • Error.drop-zone[data-invalid="true"] .drop-zone__surface tints the fill to var(--drop-zone-invalid-fill), and the stroke goes to var(--danger-soft-foreground) through the animate target.
  • Disabled.drop-zone__zone[data-disabled] takes opacity: var(--disabled-opacity) and pointer-events: none, so the whole target leaves the hit path rather than only the trigger. document.elementFromPoint over 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.

PropTypeDefaultDescription
acceptstring[]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.
maxSizenumberLargest 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.
multiplebooleanfalseWhether 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.
isDisabledbooleanfalseDims 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[]) => voidFires 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.
defaultFilesFile[][]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.
labelReactNode"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.
descriptionReactNodeSupporting copy under the label. This is where the rule the zone enforces belongs, in words — accept and maxSize are not self-describing.
iconReactNodeCloudArrowUpInThe 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}) => ReactNodeReplaces 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.
triggerLabelReactNode"Browse files"Label on the built-in browse button. Ignored when trigger is given, since that replaces the button outright.
errorMessageReactNode | ((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.
classNamestringClass name for the root.

DropZoneError

The value handed to errorMessage when it is a function.

FieldTypeDefaultDescription
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".
filesFile[]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.

On this page