# Dropzone

A file drop area with a dithered dashed border that marches round its edge, thickens and pulls a glow in on drag-over, and turns red for files it can't take.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

> **This is an example, not the only option.** It is one component built on the dithercn engine. Install it when it is what you were asked for. For any other look, don't stretch its props to fit: write your own field on `DitherShader` ([Write your own dither](/docs/engine/write-your-own.md)), and read this example's source for technique. Its full source is in [https://dithercn.dev/r/dropzone.json](https://dithercn.dev/r/dropzone.json), under `files[].content`.

## Installation [#installation]

```bash
npx shadcn@latest add https://dithercn.dev/r/dropzone.json
```

## Usage [#usage]

```tsx
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 [#file-list]

`DropzoneFileList` shows files with a [progress bar](/docs/charts/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.

```tsx
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 [#examples]

### Profile photo [#profile-photo]

One image at a time (`multiple={false}`), PNG, JPG or WEBP up to 2 MB. `preview` replaces the illustration: a generative [avatar](/docs/interface/avatar) until a photo is chosen, then the photo itself, with its name as the title.

```tsx title="examples/dropzone-avatar.tsx"
"use client";

import { useEffect, useState } from "react";

import { Avatar } from "@/components/dithercn/avatar";
import { DitherButton } from "@/components/dithercn/dither-button";
import { Dropzone, formatBytes } from "@/components/dithercn/dropzone";

interface Photo {
  url: string;
  name: string;
  size: number;
}

export default function DropzoneAvatar() {
  const [photo, setPhoto] = useState<Photo | null>(null);

  // Release the preview URL when the photo changes or the page goes.
  useEffect(
    () => () => {
      if (photo) {
        URL.revokeObjectURL(photo.url);
      }
    },
    [photo]
  );

  return (
    <div className="bg-background ring-border flex w-full max-w-md flex-col gap-4 rounded-xl p-5 ring-1">
      <div className="flex items-baseline justify-between gap-4">
        <span className="text-sm font-medium whitespace-nowrap">
          Profile photo
        </span>
        <span className="text-muted-foreground font-mono text-xs whitespace-nowrap">
          Up to 2 MB
        </span>
      </div>
      <Dropzone
        color="purple"
        accept="image/png,image/jpeg,image/webp"
        maxSize={2 * 1024 * 1024}
        multiple={false}
        title={photo ? photo.name : "Drop a photo here"}
        description={
          photo
            ? `${formatBytes(photo.size)} · Drop another to replace it`
            : undefined
        }
        browseLabel={photo ? "Choose another" : "Choose a photo"}
        className="min-h-56"
        preview={
          photo ? (
            // A local object URL, so there is nothing for next/image to optimise.
            // oxlint-disable-next-line nextjs/no-img-element
            <img
              src={photo.url}
              alt=""
              className="ring-dither-purple ring-offset-background size-20 rounded-full object-cover ring-2 ring-offset-2"
            />
          ) : (
            <Avatar name="Ada Lovelace" size={80} color="purple" ring />
          )
        }
        onFilesChange={({ accepted }) => {
          const [file] = accepted;
          if (file) {
            setPhoto({
              name: file.name,
              size: file.size,
              url: URL.createObjectURL(file),
            });
          }
        }}
      />
      <div className="flex items-center justify-between gap-3">
        <span className="text-muted-foreground text-xs text-pretty">
          Shown on your profile and next to your comments.
        </span>
        <DitherButton
          variant="outline"
          color="grey"
          size="sm"
          disabled={photo === null}
          onClick={() => setPhoto(null)}
        >
          Remove
        </DitherButton>
      </div>
    </div>
  );
}
```

### Uploads with progress [#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.

```tsx title="examples/dropzone-uploads.tsx"
"use client";

import { useEffect, useState } from "react";

import { Dropzone, DropzoneFileList } from "@/components/dithercn/dropzone";
import type { DropzoneFileItem } from "@/components/dithercn/dropzone";

const STARTER: readonly DropzoneFileItem[] = [
  {
    id: "brand-guide",
    name: "brand-guide.pdf",
    size: 3_870_000,
    status: "done",
  },
  {
    id: "launch-reel",
    name: "launch-reel.mp4",
    progress: 38,
    size: 41_200_000,
    status: "uploading",
  },
];

// Simulated upload speed: a share of the file per tick, bigger files move slower.
const TICK_MS = 260;
const stepFor = (size: number) =>
  Math.max(2, Math.round(9 - Math.log10(size / 1e5) * 2));

export default function DropzoneUploads() {
  const [files, setFiles] = useState<readonly DropzoneFileItem[]>(STARTER);
  const uploading = files.some((file) => file.status === "uploading");

  useEffect(() => {
    if (!uploading) {
      return;
    }
    const timer = window.setInterval(() => {
      setFiles((now) =>
        now.map((file): DropzoneFileItem => {
          if (file.status !== "uploading" || file.progress === null) {
            return file;
          }
          const progress = file.progress + stepFor(file.size);
          return progress >= 100
            ? { id: file.id, name: file.name, size: file.size, status: "done" }
            : { ...file, progress };
        })
      );
    }, TICK_MS);
    return () => window.clearInterval(timer);
  }, [uploading]);

  return (
    <div className="bg-background ring-border flex w-full max-w-lg flex-col gap-5 rounded-xl p-5 ring-1">
      <div className="flex items-baseline justify-between gap-4">
        <span className="text-sm font-medium">Campaign assets</span>
        <span className="text-muted-foreground font-mono text-xs tabular-nums">
          {files.filter((file) => file.status === "done").length} of{" "}
          {files.length} uploaded
        </span>
      </div>
      <Dropzone
        color="blue"
        maxSize={50 * 1024 * 1024}
        maxFiles={8}
        title="Drop assets to upload"
        className="min-h-56"
        onFilesChange={({ accepted, rejected }) =>
          setFiles((now) => [
            ...accepted.map(
              (file): DropzoneFileItem => ({
                id: crypto.randomUUID(),
                name: file.name,
                progress: 0,
                size: file.size,
                status: "uploading",
              })
            ),
            ...rejected.map(
              ({ file, message }): DropzoneFileItem => ({
                id: crypto.randomUUID(),
                message,
                name: file.name,
                size: file.size,
                status: "error",
              })
            ),
            ...now,
          ])
        }
      />
      <DropzoneFileList
        files={files}
        color="blue"
        onRemove={(id) =>
          setFiles((now) => now.filter((file) => file.id !== id))
        }
      />
    </div>
  );
}
```

## Props [#props]

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `accept` | `string` |  | File types, as the accept attribute of a file input: MIME types, wildcards and extensions, such as "image/*,.pdf". Default: any. |
| `maxSize` | `number` |  | Largest file in bytes. Default: no limit. |
| `maxFiles` | `number` |  | Most files per drop or pick. A drop with more is turned away whole, each file with too-many-files. Default: no limit. |
| `multiple` | `boolean` | `true` | Take more than one file at a time. False means one. |
| `disabled` | `boolean` | `false` | Turn the zone off: no picker, no drops, out of the tab order and greyed out. |
| `onFilesChange` | `(files: { accepted: File[]; rejected: DropzoneRejection[] }) => void` |  | Called after every drop or pick. A rejection is { file, reason, message }. |
| `title` | `ReactNode` | `"Drop files here"` | The heading at rest. "Drop a file here" when the zone takes one. |
| `description` | `ReactNode` | `From the rules` | The line under the title. Replaced by the reason while a drag is refused. |
| `browseLabel` | `string` | `"Browse files"` | Text of the browse button. |
| `preview` | `ReactNode` |  | Shown in place of the illustration, such as the chosen image. |
| `name` | `string` |  | Name of a hidden file input that holds the accepted files from the last drop or pick, so a surrounding form submits them. |
| `color` | `DitherColor` | `"ink"` | Colour of the border, glow and illustration. Rejects are always red. |
| `matrix` | `"bayer2" \| "bayer4" \| "bayer8" \| "blue-noise"` | `"bayer4"` | The threshold pattern. |
| `cell` | `number` | `2` | Size of one dither cell of the border in CSS pixels. |
| `radius` | `number` | `16` | Corner radius in pixels. The border follows it. |
| `bloom` | `"off" \| "low" \| "high"` | `"low"` | Soft glow round the border in its colour. |
| `animate` | `boolean` | `true` | March the border and bob the illustration. Drag-over still changes the look when off. |
| `forceState` | `"active" \| "reject"` |  | Show the drag-over or refused look without a drag, for docs and design reviews. |

`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 [#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. With `name`, 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.