Dropzone
A drop target for files, with a browse fallback and clear accepted types.
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
Errors name the file
Anatomy#
- Zone. A
<label>with a 1px dashed Firm Hairline border, 12px corners and a Row Mist fill, 40px of vertical padding. - Icon tile. A 36px White Plane tile with the hairline lift and a 16px upload icon in Slate Meta.
- Title. 14px weight 500: Drop a file here, or browse by default.
- Description. Optional 13px Slate Meta text, up to 20rem wide.
- 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#
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> );}| State | Treatment |
|---|---|
| Rest | Dashed Firm Hairline border on Row Mist. |
| Hover | Well Gray at 60%, over 150ms. |
| Focus visible | When the hidden input has keyboard focus, the zone takes a 3px Focus Indigo ring at 40%. |
| Drag over | While files are over it, data-over turns the border Quiet Indigo and the fill a 5% indigo tint. |
| Error | Not drawn by the component. Show a Signal Red message under the zone, naming the file. |
| File picked | Not drawn by the component. Replace the title with the file name, or show the file in a row below. |
Behavior#
- Dropping calls
onFileswith every dropped file. Browsing calls it with the one file picked; the input has nomultiple. acceptis 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 fromclassName; 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#
remittance-sept.xlsx isn't a CSV. Save it as .csv and try again.
onFiles, and put an error under the zone that names the file and the fix.accept and let a dropped spreadsheet fail later, deep in the import, with no message.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 noaria-describedbyto 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.
| Keys | Action |
|---|---|
| Tab | Focuses the zone's file input and shows the ring. |
| Space | Opens the file browser. Enter does the same in most browsers. |
Design tokens#
| Token | Used for |
|---|---|
--border-strong | Dashed border |
--surface | Resting fill |
--muted | Hover fill at 60% |
--primary | Drag-over border and 5% fill |
--ring | Focus ring at 40% |
shadow-border | Icon tile lift |
--radius-xl | 12px zone corners |
API reference#
Dropzone
A file drop target with a browse fallback.
| Prop | Type | Default | Description |
|---|---|---|---|
onFilesRequired | (files: File[]) => void | No default | Called with the dropped files, or the one browsed file. |
accept | string | No default | File types for the browse dialog, such as .csv,text/csv. Doesn't filter drops. |
title | string | "Drop a file here, or browse" | The main line. |
description | string | No default | Types, limits and contents. |
className | string | No default | On 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.