Skip to content

Dropzone

A drop target for files, with a browse fallback and clear accepted types.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { Dropzone } from "@oration/canon/components/dropzone";
packages/canon/src/components/dropzone.tsx

Import remittances

Match bank remittances to open invoices. Export the file from your bank portal.

import { Button } from "@oration/canon/components/button";import { Dropzone } from "@oration/canon/components/dropzone";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { FileSpreadsheetIcon, XIcon } from "lucide-react";import * as React from "react";export function Hero() {    const [file, setFile] = React.useState<{        name: string;        size: string;        rows: number | null;    } | null>(null);    const [error, setError] = React.useState<string | null>(null);    return (        <div className="flex w-full max-w-lg flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex flex-col gap-1">                <p className="text-sm font-semibold text-foreground">                    Import remittances                </p>                <p className="text-13 text-muted-foreground">                    Match bank remittances to open invoices. Export the file                    from your bank portal.                </p>            </div>            {file ? (                <div className="flex items-center gap-3 rounded-[10px] bg-muted/70 px-3 py-2.5">                    <FileSpreadsheetIcon                        aria-hidden="true"                        className="size-4 shrink-0 text-muted-foreground"                    />                    <div className="flex min-w-0 flex-1 flex-col">                        <span className="truncate text-13 font-medium text-foreground">                            {file.name}                        </span>                        <span className="text-xs text-muted-foreground tabular-nums">                            {file.size}                            {file.rows !== null                                ? `, ${file.rows.toLocaleString("en-US")} rows`                                : ", counting rows"}                        </span>                    </div>                    <Tooltip>                        <TooltipTrigger                            render={                                <Button                                    type="button"                                    variant="ghost"                                    size="icon-sm"                                    aria-label={`Remove ${file.name}`}                                    onClick={() => setFile(null)}                                    className="text-muted-foreground"                                />                            }                        >                            <XIcon />                        </TooltipTrigger>                        <TooltipContent>Remove file</TooltipContent>                    </Tooltip>                </div>            ) : (                <div className="flex flex-col gap-2">                    <Dropzone                        accept=".csv,text/csv"                        title="Drop a CSV here, or browse"                        description="Up to 10 MB. Needs invoice number, amount and payment date columns."                        onFiles={(files) => {                            const picked = files[0];                            if (!picked) return;                            if (!/\.csv$/i.test(picked.name)) {                                setError(                                    `${picked.name} isn't a CSV. Save it as .csv from your spreadsheet app and try again.`,                                );                                return;                            }                            if (picked.size > 10 * 1024 * 1024) {                                setError(                                    `${picked.name} is over 10 MB. Split it by month and import each part.`,                                );                                return;                            }                            setError(null);                            setFile({                                name: picked.name,                                size: `${Math.max(1, Math.round(picked.size / 1024))} KB`,                                rows: null,                            });                            void picked.text().then((text) => {                                const rows = Math.max(                                    0,                                    text.split(/\r?\n/).filter(Boolean).length -                                        1,                                );                                setFile((current) =>                                    current?.name === picked.name                                        ? { ...current, rows }                                        : current,                                );                            });                        }}                    />                    {error ? (                        <p role="alert" className="text-13 text-destructive">                            {error}                        </p>                    ) : null}                </div>            )}            <div className="flex justify-end">                <Button                    type="button"                    disabled={!file}                    onClick={() =>                        file &&                        toast.add({                            title: "Mapping columns",                            description: `${file.name} is ready to match against open invoices.`,                        })                    }                >                    Map columns                </Button>            </div>        </div>    );}

Usage#

Dropzone is the dashed target for uploading files: drag files onto it, or click it (or press Space while it has focus) to browse. Oration uses it for CSV imports, OpenAPI specs, knowledge base documents and deal files. It hands you File[] and nothing more: no file list, no progress, no size limit. The mistake people make is trusting accept. It only filters the browse dialog; dropped files arrive unfiltered, so check type and size in onFiles and say exactly what went wrong.

When to use

  • For importing a file that starts a flow: a remittance CSV, a supplier list, an OpenAPI spec.
  • For attaching documents to a record, such as W-9s or signed order forms on a supplier.
  • For adding sources to a knowledge base, several files at a time by drag and drop.
  • In a dialog or an empty state, where the upload is the main thing to do.

When not to use

  • For showing files already attached, with their size and a remove action. Use Attachment
  • For an attach button beside a message or a note. Use Prompt bar
  • For upload progress once files are on their way. Use Progress
  • For an empty list whose first action is an upload. Put the dropzone inside the empty state, not instead of it. Use Empty

Say what fits before it fails

The description names the types, the size limit and anything about the contents: CSV up to 50,000 rows. The first row should be column names.

Errors name the file

A rejected file is named in the error with the fix: remittance.xlsx isn't a CSV. Save it as .csv from your spreadsheet app and try again.

Anatomy#

  1. Zone. A <label> with a 1px dashed Firm Hairline border, 12px corners and a Row Mist fill, 40px of vertical padding.
  2. Icon tile. A 36px White Plane tile with the hairline lift and a 16px upload icon in Slate Meta.
  3. Title. 14px weight 500: Drop a file here, or browse by default.
  4. Description. Optional 13px Slate Meta text, up to 20rem wide.
  5. File input. A visually hidden <input type="file"> inside the label; it takes focus and opens the browser.

Examples#

Title and description

Name the type in the title and the limits in the description. Once a file is picked, the title can become its name, as the new tool dialog does with its spec.

import { Dropzone } from "@oration/canon/components/dropzone";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function WithDescription() {    const [name, setName] = React.useState<string | null>(null);    return (        <div className="flex w-full max-w-md flex-col gap-2">            <Dropzone                accept=".pdf,application/pdf"                title={name ?? "Drop a W-9 here, or browse"}                description="PDF up to 10 MB. Signed and dated within the last three years."                onFiles={(files) => {                    const picked = files[0];                    if (!picked) return;                    setName(picked.name);                    toast.add({                        type: "success",                        title: "W-9 added to Halcyon Logistics",                        description: picked.name,                    });                }}            />        </div>    );}

Several files

Drops can carry many files at once; list them under the zone with their size and a remove button. Browsing picks one at a time today.

import { Button } from "@oration/canon/components/button";import { Dropzone } from "@oration/canon/components/dropzone";import { toast } from "@oration/canon/components/toast";import { FileTextIcon, XIcon } from "lucide-react";import * as React from "react";export function SeveralFiles() {    const [files, setFiles] = React.useState<File[]>([]);    return (        <div className="flex w-full max-w-md flex-col gap-3">            <Dropzone                accept=".pdf,.docx,.csv,.txt,.md"                title="Drop files here, or browse"                description="PDF, Word, CSV, Markdown or text, up to 50 MB each. Drop several at once."                onFiles={(next) => {                    setFiles((current) => [...current, ...next]);                    toast.add({                        title:                            next.length === 1                                ? "Added 1 file"                                : `Added ${next.length} files`,                    });                }}                className="py-8"            />            {files.length ? (                <ul aria-label="Files to add" className="flex flex-col gap-1">                    {files.map((file, index) => (                        <li                            key={`${file.name}-${file.size}-${file.lastModified}`}                            className="flex h-9 items-center gap-2.5 rounded-lg px-2 text-13 hover:bg-muted"                        >                            <FileTextIcon                                aria-hidden="true"                                className="size-4 shrink-0 text-muted-foreground"                            />                            <span className="min-w-0 flex-1 truncate text-foreground">                                {file.name}                            </span>                            <span className="text-xs text-muted-foreground tabular-nums">                                {Math.max(1, Math.round(file.size / 1024))} KB                            </span>                            <Button                                type="button"                                variant="ghost"                                size="icon-xs"                                aria-label={`Remove ${file.name}`}                                onClick={() =>                                    setFiles((current) =>                                        current.filter((_, i) => i !== index),                                    )                                }                                className="text-muted-foreground"                            >                                <XIcon />                            </Button>                        </li>                    ))}                </ul>            ) : null}        </div>    );}

States#

Rest
Hover
Focus
Drag over
import { Dropzone } from "@oration/canon/components/dropzone";import { cn } from "@oration/canon/lib/utils";export function StatesRow() {    const states = [        { name: "Rest", className: "" },        { name: "Hover", className: "bg-muted/60" },        { name: "Focus", className: "ring-3 ring-ring/40" },        { name: "Drag over", className: "border-primary bg-primary/5" },    ];    return (        <div            inert            className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"        >            {states.map((state) => (                <div key={state.name} className="flex min-w-0 flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <Dropzone                        onFiles={() => undefined}                        title="Drop a CSV here"                        className={cn("px-4 py-6", state.className)}                    />                </div>            ))}        </div>    );}
States
StateTreatment
RestDashed Firm Hairline border on Row Mist.
HoverWell Gray at 60%, over 150ms.
Focus visibleWhen the hidden input has keyboard focus, the zone takes a 3px Focus Indigo ring at 40%.
Drag overWhile files are over it, data-over turns the border Quiet Indigo and the fill a 5% indigo tint.
ErrorNot drawn by the component. Show a Signal Red message under the zone, naming the file.
File pickedNot drawn by the component. Replace the title with the file name, or show the file in a row below.

Behavior#

  • Dropping calls onFiles with every dropped file. Browsing calls it with the one file picked; the input has no multiple.
  • accept is passed to the file input, so it filters the browse dialog only.
  • The zone is a <label> for the hidden input, so a click anywhere on it opens the browser.
  • Drag over sets data-over, which you can target from className; leaving or dropping clears it.
  • The input keeps its value after a pick, so choosing the same file again doesn't call onFiles.
  • Every file is handed over as-is. Read, parse and upload them yourself, and show progress outside the zone.

Do and don't#

Do. Check type and size in onFiles, and put an error under the zone that names the file and the fix.
Don't. Rely on accept and let a dropped spreadsheet fail later, deep in the import, with no message.
remittance-2026-09.csv86 KB, 1,284 rows
Do. Once a file is picked, show its name, size and a way to remove it before starting the import.
Importing 1,284 rows
Don't. Start importing the moment a file lands, with no chance to check it was the right one.

Content#

  • Title: the action with the type, ending in or browse: Drop a CSV here, or browse.
  • Description: types, limits and what the contents should be, in one or two short sentences.
  • After a pick, the title can become the file name, as the new tool dialog does with its spec.
  • Errors: {file} isn't a CSV. or {file} is 62 MB. Upload files up to 50 MB.

Accessibility#

  • The hidden file input is the control. It is named by the zone's text, so the title and description are read together.
  • Keyboard users Tab to it and press Space or Enter to browse; dragging has no keyboard equivalent, which is why browse is always offered.
  • Put errors in a Field error or a role="alert" region right after the zone; the component has no aria-describedby to link them.
  • The upload icon is decorative; lucide marks it hidden from assistive tech.
  • Announce what happened after a pick: remittance-2026-09.csv added, 1,284 rows.
Keyboard interactions
KeysAction
TabFocuses the zone's file input and shows the ring.
SpaceOpens the file browser. Enter does the same in most browsers.

Design tokens#

Design tokens
TokenUsed for
--border-strongDashed border
--surfaceResting fill
--mutedHover fill at 60%
--primaryDrag-over border and 5% fill
--ringFocus ring at 40%
shadow-borderIcon tile lift
--radius-xl12px zone corners

API reference#

Dropzone

A file drop target with a browse fallback.

Props of Dropzone
PropTypeDefaultDescription
onFilesRequired(files: File[]) => voidNo defaultCalled with the dropped files, or the one browsed file.
acceptstringNo defaultFile types for the browse dialog, such as .csv,text/csv. Doesn't filter drops.
titlestring"Drop a file here, or browse"The main line.
descriptionstringNo defaultTypes, limits and contents.
classNamestringNo defaultOn the zone. data-over: targets the drag state.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The file input has no multiple, so browsing picks one file even where drops accept many. The knowledge base upload dialog expects several.

accept doesn't filter dropped files; each call site checks the type itself, and the import section is the only one that shows an error.

The input isn't reset after a pick, so choosing the same file twice in a row does nothing.

No disabled, id, name, aria-describedby or invalid state; errors can't be linked to the input.

Focus draws only the ring, without the indigo border other fields add, and drag over uses an indigo border and tint, which DESIGN.md doesn't list among indigo's uses.

dragleave fires when the pointer crosses the icon and text inside the zone, so the drag-over state can flicker.