Installation
pnpm dlx shadcn@latest add https://dithercn.dev/r/dropzone.jsonUsage
import { Dropzone } from "@/components/dithercn/dropzone";
export function Attachments() {
return (
<Dropzone
accept="image/*,application/pdf"
maxSize={10 * 1024 * 1024}
maxFiles={5}
onFilesChange={({ accepted }) => upload(accepted)}
/>
);
}Drag files over the zone, click it, or focus it and press Enter or Space to open the file picker. Each drop or pick is checked against accept, maxSize and maxFiles, and onFilesChange gets both lists: the accepted File objects, and the rejected ones with a reason ("file-type", "file-too-large" or "too-many-files") and a short message. The zone keeps no list of its own, so you decide what to keep, upload or show.
The border is a row of dithered dashes, each a solid head with a tail that thins through the Bayer levels, marching slowly clockwise round the edge. With files over the zone the dashes lengthen and speed up, a second ring of cells fills in, and a glow is pulled in from the edges, its front moving inward. The sheet in the illustration drops into the tray in whole-cell steps. While a drag is over it, the zone can see how many files it carries and their types, so a drag it would refuse turns red, stops marching and shakes the sheet. Sizes are only known on drop, so a file that is too large turns the zone red after the drop and names the file. A rejection stays until the next drag or pick, so a long filename can be read in full.
Without a description, the line under the title is written from the rules, such as "Images or PDF, up to 10 MB each, 5 files at most". Hover and keyboard focus light the border a little, so the zone reads as something you can act on.
File list
DropzoneFileList shows files with a progress bar each: the zone's colour while uploading, solid green when done, hatched red with the message when it failed. Leave progress as null for an upload whose size you can't measure, and the bar becomes indeterminate.
import { DropzoneFileList } from "@/components/dithercn/dropzone";
<DropzoneFileList
files={[
{ id: "1", name: "brand-guide.pdf", size: 3_870_000, status: "done" },
{
id: "2",
name: "reel.mp4",
size: 41_200_000,
status: "uploading",
progress: 38,
},
]}
onRemove={(id) => remove(id)}
/>;Examples
Profile photo
One image at a time (multiple={false}), PNG, JPG or WEBP up to 2 MB. preview replaces the illustration: a generative avatar until a photo is chosen, then the photo itself, with its name as the title.
Uploads with progress
Up to 8 files of 50 MB each, in blue. Accepted files join the list and upload on a timer; rejected ones join it too, in red, with the reason. Drop a few files to watch them fill.
Props
| Prop | Type | Default |
|---|---|---|
| accept File types, as the accept attribute of a file input: MIME types, wildcards and extensions, such as "image/*,.pdf". Default: any. | Type: string | Default โ |
| maxSize Largest file in bytes. Default: no limit. | Type: number | Default โ |
| maxFiles Most files per drop or pick. A drop with more is turned away whole, each file with too-many-files. Default: no limit. | Type: number | Default โ |
| multiple Take more than one file at a time. False means one. | Type: boolean | Default true |
| disabled Turn the zone off: no picker, no drops, out of the tab order and greyed out. | Type: boolean | Default false |
| onFilesChange Called after every drop or pick. A rejection is { file, reason, message }. | Type: (files: { accepted: File[]; rejected: DropzoneRejection[] }) => void | Default โ |
| title The heading at rest. "Drop a file here" when the zone takes one. | Type: ReactNode | Default "Drop files here" |
| description The line under the title. Replaced by the reason while a drag is refused. | Type: ReactNode | Default From the rules |
| browseLabel Text of the browse button. | Type: string | Default "Browse files" |
| preview Shown in place of the illustration, such as the chosen image. | Type: ReactNode | Default โ |
| name Name of a hidden file input that holds the accepted files from the last drop or pick, so a surrounding form submits them. | Type: string | Default โ |
| color Colour of the border, glow and illustration. Rejects are always red. | Type: DitherColor | Default "ink" |
| matrix The threshold pattern. | Type: "bayer2" | "bayer4" | "bayer8" | "blue-noise" | Default "bayer4" |
| cell Size of one dither cell of the border in CSS pixels. | Type: number | Default 2 |
| radius Corner radius in pixels. The border follows it. | Type: number | Default 16 |
| bloom Soft glow round the border in its colour. | Type: "off" | "low" | "high" | Default "low" |
| animate March the border and bob the illustration. Drag-over still changes the look when off. | Type: boolean | Default true |
| forceState Show the drag-over or refused look without a drag, for docs and design reviews. | Type: "active" | "reject" | Default โ |
DropzoneFileList takes files (each { id, name, size } plus status: "uploading" with progress from 0 to 100 or null, status: "done", or status: "error" with a message), onRemove, color and fill.
Notes
- The zone is one control, a native
<button>in the tab order, named by its title and the browse label and described by the hint. Enter and Space open the picker. The real<input type="file">sits next to it, hidden from view and from assistive technology, and is emptied after every pick so choosing the same file again still counts. Withname, a second hidden input holds the accepted files from the last drop or pick for the form. - A polite live region announces every result, such as "Added 3 files: a.png, b.png, c.pdf." or "Did not add report.exe: only images or PDF."
- The border is a canvas drawn one pixel per cell and scaled up without smoothing, with no WebGL. It pauses offscreen and in hidden tabs. With reduced motion the dashes hold still, the drag-over and reject looks switch in one step, and the illustration stops bobbing and shaking. Before the first frame it shows a plain dashed edge.
- The file list is a list of progress bars, each with
role="progressbar", and every remove button is labelled with the file's name.