Dropzone
Part of the extras bundle, not included by default.
Intent
A dropzone gives the user a generous click and drag target for
a file input. Click anywhere inside it to open the native file
picker, or drag a file from the desktop and drop it on the
surface. The component validates against the input's
accept attribute, paints state classes for hover /
drag / accept / reject, and offers integration paths so a
consumer can either submit the file with a regular
<form> or drive the upload with custom JS.
The custom element <brio-dropzone> carries
the behavior; the .dropzone class on it carries
the styling. Without the JS, the file input is hidden and the
surface is non-functional, so the dropzone is an
extras-bundle component -- consumers that want it import
brio-extras.css and brio-extras.js
alongside the core bundles, or pull
dropzone.css /
brio-dropzone.js directly.
Basic usage
Wrap a file input in <brio-dropzone class="dropzone">
and add an icon plus a prompt. Mark the prompt span with
data-prompt so the element can rewrite its text on
accept (file name) or reject (error message).
<docs-resize>
<brio-dropzone class="dropzone">
<input type="file" id="ex-attachment" accept=".png,.jpg,.jpeg,.pdf"/>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<span data-prompt="">Drag and drop a file here, or click to browse</span>
<span class="text-subtle">PNG, JPG, or PDF up to 10 MB</span>
</brio-dropzone>
</docs-resize>
The file input stays in the DOM (visually hidden via clip-path,
still focusable via Tab) so screen readers and keyboard users
reach it. Click anywhere on the surface forwards to the input
and opens the picker. Pair the input with a standard
<label for> outside the dropzone for an
accessible name.
Integration paths
Both paths fire on every accepted file, automatically. Pick whichever fits the surrounding application; they don't conflict.
Native form submission
On accept, the element writes the dropped or picked files to
input.files and dispatches a bubbling native
change event on the input. A surrounding
<form> picks them up at submit time the same
way it picks up files from a regular file input. Frameworks
that observe form-element changes (htmx form bindings, datastar
data-on-change, alpine x-on:change)
see the event without extra wiring.
<form method="post" enctype="multipart/form-data" action="#" onsubmit="return false" class="stack">
<label class="label" for="ex-form-attachment">Attachment</label>
<docs-resize>
<brio-dropzone class="dropzone">
<input type="file" id="ex-form-attachment" name="attachment" accept=".pdf"/>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<span data-prompt="">Drop a PDF or click to browse</span>
</brio-dropzone>
</docs-resize>
<button type="submit" class="button is-primary">Submit</button>
</form>Custom event
On every accept, the element dispatches
brio:dropzone-change on itself (bubbles, with
detail.files as a FileList). Subscribe directly
or via a framework binding to drive the upload from JS -- a
fetch call, an SSE start, an instrumentation event.
document.querySelector("brio-dropzone")
.addEventListener("brio:dropzone-change", (e) => {
const body = new FormData();
for (const file of e.detail.files) body.append("attachment", file);
fetch("/upload", { method: "POST", body });
});
Validation
The input's accept attribute drives validation
for both paths. Dropped files run through the same matcher
the browser uses for the file picker -- extensions
(.png), MIME types (image/png), and
wildcards (image/*) all work. A file that doesn't
match flips the dropzone into .is-rejected and
rewrites the prompt with a short error. The next valid drop
swaps it back to .is-accepted; clear it manually
by removing .is-rejected if the consumer wants
immediate reset.
Server-side or app-level validation paints the danger border
via aria-invalid on the input -- the same pattern
the rest of the .input family uses. Set
aria-invalid="true" after a failed submission and
pair it with an adjacent error message; clear it when the
field becomes valid again.
An attachment is required.
<docs-resize>
<brio-dropzone class="dropzone">
<input type="file" id="ex-invalid-input" accept=".png,.jpg,.pdf" aria-invalid="true" aria-describedby="ex-invalid-msg"/>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span data-prompt="">Drag and drop a file here, or click to browse</span>
</brio-dropzone>
</docs-resize>
<p id="ex-invalid-msg" class="text-danger">An attachment is required.</p>States
The element toggles state classes automatically as the user interacts. Below they're shown statically for visual reference; in normal use only one is ever present at a time.
<div class="stack">
<docs-resize>
<brio-dropzone class="dropzone is-dragover">
<input type="file" id="ex-state-dragover"/>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
<span data-prompt="">Drop file to upload</span>
</brio-dropzone>
</docs-resize>
<docs-resize>
<brio-dropzone class="dropzone is-accepted">
<input type="file" id="ex-state-accepted"/>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>
<span data-prompt="">report-q1-2026.pdf</span>
</brio-dropzone>
</docs-resize>
<docs-resize>
<brio-dropzone class="dropzone is-rejected">
<input type="file" id="ex-state-rejected"/>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>
<span data-prompt="">File type not accepted: sketch-draft.psd</span>
</brio-dropzone>
</docs-resize>
</div>Programmatic access
The current FileList is on the file input; reach for it
directly or use the getFiles helper that
brio-dropzone.js exports.
import { getFiles } from "/assets/brio-extras.js";
const zone = document.querySelector("brio-dropzone");
const files = getFiles(zone); // FileList | null
// Or, for consumers who want to skip the aggregator and import a
// single extra, point at the prod-only standalone:
// import { getFiles } from "/extras/brio-dropzone.js";
//
// Or use the global namespace from a non-module context:
// const files = brio.dropzone.getFiles(zone);
Customization
Border, surface, text, and icon colors all flow through scoped
--dropzone-* tokens that remap inside the state
classes -- override them on .dropzone (or a parent
scope) to retone hover, accept, and reject without touching
selector specificity. Padding and gap pull from brio's density
grammar (--ui-pad-*, --gap-*); pick a
different tier on the parent if a tighter or looser dropzone
fits the surrounding form. The dashed border, radius, font size,
and transition are all token-driven -- see the dropzone source
for the full list.
Accessibility
-
The file input stays in the DOM and remains focusable via
Tab; assistive tech reads it as a regular file input. Pair
it with a
<label for>outside the dropzone for an accessible name. -
Keyboard focus on the input lights up
:focus-withinon the dropzone, painting a focus ring on the whole surface so the user can see where focus landed. -
Click anywhere inside the dropzone opens the picker, but
the input's own
Enter/Spaceactivation works too -- consumers can author a smaller, focusable trigger inside the dropzone if the design calls for it. -
Reject feedback is visual only by default; pair the input
with
aria-describedbyand an adjacent error message for assistive-tech announcements when validation is server- or app-driven.
Markup
<label for="attachment">Attachment</label>
<brio-dropzone class="dropzone">
<input type="file" id="attachment" name="attachment" accept=".png,.jpg,.pdf">
<svg aria-hidden="true">...</svg>
<span data-prompt>Drag and drop a file here, or click to browse</span>
<span class="text-subtle">PNG, JPG, or PDF up to 10 MB</span>
</brio-dropzone>
CSS Reference: Dropzone
Drag-and-drop file upload area
Source: src/css/extras/brio-dropzone.css
Tokens
| Token | Default | Description |
|---|---|---|
--dropzone-border-color | var(--color-border-strong) | Border color for the dropzone container |
--dropzone-border-style | dashed | Border style, dashed by default to signal a drop target |
--dropzone-border-width | 2px | Border width for the dropzone container |
--dropzone-bg | var(--color-surface) | Background color for the dropzone surface |
--dropzone-color | var(--color-text-muted) | Text color for the dropzone prompt |
--dropzone-icon-color | var(--color-text-subtle) | Color for SVG icons inside the dropzone |
--dropzone-radius | var(--radius-md) | Corner radius for the dropzone |
--dropzone-padding-x | var(--ui-pad-x-spacious) | Horizontal padding inside the dropzone |
--dropzone-padding-y | var(--ui-pad-y-spacious) | Vertical padding inside the dropzone |
--dropzone-gap | var(--gap-snug) | Gap between stacked children inside the dropzone |
--dropzone-font-size | var(--ui-text-sm) | Font size for the prompt and hint text |
--dropzone-transition | border-color var(--duration-fast) var(--ease-out), background-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out) | Transition shorthand for state color changes |
Classes
| Class | Description |
|---|---|
.dropzone | component root |
.is-dragover | Active drag state when a file is over the dropzone |
.is-accepted | Files validated and adopted successfully |
.is-rejected | Files rejected by the accept-pattern check |