Duck Upload
The queue behind the drop zone: concurrency, per-file progress, retry, cancel that aborts, and one announcement per outcome. You still own the transport.
BlockClient componentlucide-react
Preview
Installation
One command writes the section and every component it renders.
$
pnpm dlx shadcn@latest add @duck/duck-uploadThe block lands in components/blocks/duck-upload.tsx. It builds on sticker-drop, sticker-progress and quack-button, which the CLI installs alongside it. Already installed items are skipped.
Usage
import { DuckUpload } from "@/components/blocks/duck-upload"A block is a starting point, not a widget. The props exist so the example renders with real content — once the file is in your project, hard-code what never changes and delete the rest.
Props
Everything not listed here is forwarded to the root element.
| Prop | Type | Default | Description |
|---|---|---|---|
| onUpload | (file: File, ctx: { onProgress: (percent: number) => void; signal: AbortSignal }) => Promise<unknown> | - | Sends one file. Report progress if the transport can, honour the signal if it can, and reject to fail the row with the message. |
| onQueueChange | (items: DuckUploadItem[]) => void | - | A fresh list every time the queue changes. |
| onSettled | (items: DuckUploadItem[]) => void | - | Fires on the edge, when nothing is left queued or uploading. |
| concurrency | number | 3 | Files in flight at once. Twelve files should not open twelve sockets. |
| accept | string | - | Passed to the drop zone, which enforces it and announces rejections. |
| multiple | boolean | true | Accept more than one file per drop. |
| maxSize | number | - | Largest file in bytes, enforced by the drop zone. |
| label | string | "Drop files here" | The zone's own label. |
| hint | string | - | Second line in the zone: the accepted types, the size limit. |
| clearDoneAfter | number | - | Milliseconds before finished rows drop off on their own. |
Rules
What keeps this section from turning into noise.
- The drop zone is kept controlled at files={[]}. Its sheet and the queue would otherwise show the same file twice, with two remove buttons and only one attached to an upload.
- The block owns the queue; you own the transport. onProgress and an AbortSignal are the two things a transport has to be handed rather than asked for.
- A row is only started once. An effect that runs twice — which in development it always does — must not upload the same file twice.
- An abort is a decision, not a failure. The row says Canceled and offers retry rather than turning red.
- Progress is a track, not an announcement. A value that moves sixty times per file is chrome; the announcement is "uploaded" or "failed", once each.
- A queued row's bar is indeterminate. A bar sitting at 0% reads as a stall, which is a different state.
