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).

Drag and drop a file here, or click to browse PNG, JPG, or PDF up to 10 MB
<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.

Drop a PDF or click to browse
<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.

Drag and drop a file here, or click to browse

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.

Drop file to upload report-q1-2026.pdf File type not accepted: sketch-draft.psd
<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-within on 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 / Space activation 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-describedby and 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

TokenDefaultDescription
--dropzone-border-colorvar(--color-border-strong)Border color for the dropzone container
--dropzone-border-styledashedBorder style, dashed by default to signal a drop target
--dropzone-border-width2pxBorder width for the dropzone container
--dropzone-bgvar(--color-surface)Background color for the dropzone surface
--dropzone-colorvar(--color-text-muted)Text color for the dropzone prompt
--dropzone-icon-colorvar(--color-text-subtle)Color for SVG icons inside the dropzone
--dropzone-radiusvar(--radius-md)Corner radius for the dropzone
--dropzone-padding-xvar(--ui-pad-x-spacious)Horizontal padding inside the dropzone
--dropzone-padding-yvar(--ui-pad-y-spacious)Vertical padding inside the dropzone
--dropzone-gapvar(--gap-snug)Gap between stacked children inside the dropzone
--dropzone-font-sizevar(--ui-text-sm)Font size for the prompt and hint text
--dropzone-transitionborder-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

ClassDescription
.dropzonecomponent root
.is-dragoverActive drag state when a file is over the dropzone
.is-acceptedFiles validated and adopted successfully
.is-rejectedFiles rejected by the accept-pattern check