File Upload List
An upload queue — per-file progress, pause/resume, retry, cancel and remove, with a byte-weighted batch summary and throttled announcements.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/file-upload-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FileUploadList" component (lucide-react
icons; no upload library). It is the QUEUE view for files that were already
picked elsewhere — it never opens a file picker, never handles drag & drop and
never performs a transfer.
Contract
- export type UploadStatus =
"queued" | "uploading" | "paused" | "success" | "error" | "canceled"
- export interface UploadItem { id: string; name: string; size: number (bytes,
0 is legal); type?: string (MIME); status: UploadStatus; progress: number
(0-100); error?: string; url?: string; file?: File }
- export const FileUploadList = forwardRef<HTMLDivElement, FileUploadListProps>
where FileUploadListProps extends
Omit<HTMLAttributes<HTMLDivElement>, "onCancel" | "onPause" | "onResume">
(those three collide with DOM dialog/media events) and adds:
items: UploadItem[]; onRetry(id); onCancel(id); onRemove(id); onPause?(id);
onResume?(id); onRetryAll?(); concurrencyHint?: number (display only, the
scheduler lives in the consumer); showThumbnails?: boolean (default false);
emptyState?: ReactNode; className. Spread the rest on the root, merge
className with cn().
- The component is a pure view: it holds no queue state and mutates nothing.
Every action calls back with the item id; the consumer's upload layer owns
transport, concurrency and state transitions.
Behavior
- items.length === 0 → render emptyState if provided, else a built-in panel
(icon + "No files in the queue" + one explanatory line). No header, no
scroller in that branch.
- Otherwise: a summary header + a plain scrolling <ul role="list"> whose direct
children are the <li> rows (no wrapper div between them, or screen readers
read an empty list). Key rows by item.id, never by index, and de-duplicate by
id before rendering — two rows sharing a key make a removal unmount the wrong
element and strand its object URL.
- Header: "<total>% · <success>/<count> uploaded" plus a muted detail line
joining the non-zero buckets ("3 uploading · 5 queued · 1 paused · 2 failed ·
1 canceled · 6 at a time" — the last part only when concurrencyHint > 0), an
overall role="progressbar", and a "Retry all (N)" button rendered only when
onRetryAll is given and at least one row failed.
- Total progress is BYTE-WEIGHTED (weight = max(size, 1) so 0-byte files still
count; success counts as 100), and canceled rows drop out of both numerator
and denominator — a batch of 3 done + 1 canceled reads 100%, and a 4 GB video
at 10% does not weigh the same as a 4 KB icon at 10%.
- Row layout: 36px thumbnail/icon box + name + size + status area + actions.
Icon is chosen from MIME first, extension second (image / video / audio /
archive / document / generic).
- File names truncate in the MIDDLE with the extension preserved, without
measuring anything: split the name into a shrinkable head and a tail
(extension + ~4 characters), render head in a `truncate` span and tail in a
`shrink-0 whitespace-pre` span inside a flex row. CSS decides when the
ellipsis appears, so it stays correct at every container width; names shorter
than ~12 chars are not split at all. Keep the full name in title=.
- Sizes are formatted locally (B / KB / MB / GB / TB, one decimal below 10) —
no Intl call, so server and client always print the same string.
- Status area per row: queued → "Queued"; uploading → role="progressbar" with
aria-valuenow/min/max plus the rounded percentage; paused → the same bar at
reduced opacity plus "Paused · N%"; success → check icon + "Uploaded" (if the
item has a url, the name becomes a real link, target=_blank rel=noreferrer);
error → the reason in text-destructive, wrapping rather than truncated;
canceled → "Canceled". Clamp progress into 0-100 and treat non-finite as 0.
- Row actions by status: uploading → pause (when onPause) + cancel; paused →
resume (when onResume) + cancel; queued → cancel; error/canceled → retry +
remove; success → remove. Icon buttons carry aria-label + title naming the
file ("Pause upload of report.pdf").
- ANNOUNCEMENTS ARE THROTTLED AND TERMINAL-ONLY. Keep an always-mounted
aria-live="polite" aria-atomic sr-only region. Diff each render's statuses
against the previous map in an effect (the first pass only records a
baseline, so rows that mounted already-settled are not announced); when a row
reaches success/error/canceled, add it to a pending bucket and schedule ONE
flush ~900ms later. While a flush is scheduled, later transitions join the
same bucket. One file → "report.pdf finished uploading."; many →
"8 uploaded, 2 failed." Progress ticks never announce; ten simultaneous
finishes produce one line, not ten interruptions. Clear the timer on unmount.
- Thumbnails: with showThumbnails and an image row carrying `file`, render an
object-URL preview. Create the URL in a ref callback on the <img> and revoke
it in the React 19 ref cleanup, so creation and revocation are paired
one-to-one and unmount / row removal / a changed file all release it (the
lazy-useState shortcut leaks one URL per StrictMode double mount). Rows
without a local file fall back to the type icon.
- aria-busy on the root while anything is queued/uploading/paused; data-status
on each row for consumer styling.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground root with border and
rounded-xl, bg-muted tracks and thumbnail box, bg-primary fill (bg-primary/40
while paused), text-muted-foreground for sizes and captions, text-primary for
the success check, text-destructive for failure reasons, hover:bg-accent
hover:text-accent-foreground for icon buttons. No hex/oklch anywhere, and no
chart tokens on text.
- The list is a plain `max-h-80 overflow-y-auto overscroll-contain divide-y`
scroller — 50 rows scroll fine, no virtualization.
- Bars animate with transition-[width] duration-300 plus
motion-reduce:transition-none; percentages use tabular-nums so digits do not
jitter. Buttons get focus-visible:ring-2 ring-ring.
Customization levers
- Density: row padding px-3 py-2.5, thumbnail size-9 and bar h-1.5 are the
three knobs; a compact variant reads fine at py-1.5 / size-7 / h-1.
- Scroll height: max-h-80 on the <ul> is the only cap — raise it, drop it for a
page-level list, or swap in @tanstack/react-virtual past a few hundred rows.
- Header: the summary line, the detail line and the overall bar are three
independent blocks — delete any of them without touching row rendering.
- Actions: drop onPause/onResume to hide pause affordances entirely; add a
"Cancel all" next to "Retry all" using the same button recipe.
- Middle truncation: TAIL_CONTEXT (characters kept before the extension) and
MIN_SPLIT_LENGTH (names shorter than this are never split) tune how much of
the tail survives.
- Announcement window: ANNOUNCE_WINDOW_MS trades latency for calm — lower it
for small queues, raise it for bulk imports; keep it terminal-only.
- Total progress: swap the byte-weighted average for a simple count-based one
(success / total) in a single reduce if your transport reports no sizes.
- Tone: text-destructive is the only failure token and text-primary the only
success token — remap both without touching the state machine.
- React 18 target: ref-callback cleanup landed in React 19. On 18, create the
preview URL in a lazy useState initializer and revoke it in an unmount
effect, accepting one leaked URL per StrictMode double mount.Concepts
- Queue view, not transport — the component renders
itemsand calls back with anid; opening the picker, running the requests, scheduling concurrency and flipping statuses all stay in your upload layer, so the same list works overfetch, XHR progress events, tus or S3 multipart. - Terminal-only, coalesced announcements — progress ticks are silent; only
success/error/canceledtransitions enter a pending bucket that flushes once per ~900 ms window, which is what keeps a 50-file batch from turning the live region into a firehose. - Middle truncation without measuring — the name is split into a shrinkable head and a protected tail (extension plus a few characters); CSS
truncatedecides when the ellipsis appears, so…auditor-notes-final.tar.gzstays legible at any width with zero layout reads. - Byte-weighted batch total — the header averages progress weighted by file size (empty files still get one unit of weight, canceled files leave the batch), so a 4 GB video cannot be hidden behind a dozen finished thumbnails.
- Object-URL lifetime anchored to the DOM node — the preview URL is created in the
<img>ref callback and revoked in its cleanup, pairing everycreateObjectURLwith exactly onerevokeObjectURLacross removal, unmount and StrictMode's double mount. - Identity by id — rows are keyed and de-duplicated by
id, never by array index, so removing the middle of a queue unmounts exactly that row instead of shifting props into a reused instance.
Form Wizard
A multi-step form state machine — per-step validation gates (sync or async), focus moved to the first failing field, and a self-contained progress rail.
Checkbox Tree
A multi-select hierarchical picker with parent-child cascade, derived indeterminate parents, and full WAI-ARIA tree keyboard navigation.