Inputs

File Upload List

An upload queue — per-file progress, pause/resume, retry, cancel and remove, with a byte-weighted batch summary and throttled announcements.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  File as FileIcon,
  FileArchive,
  FileImage,
  FileMusic,
  FilePlay,
  FileText,
  FileUp,
  Pause,
  Play,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/file-upload-list.json

Prompt

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 items and calls back with an id; opening the picker, running the requests, scheduling concurrency and flipping statuses all stay in your upload layer, so the same list works over fetch, XHR progress events, tus or S3 multipart.
  • Terminal-only, coalesced announcements — progress ticks are silent; only success / error / canceled transitions 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 truncate decides when the ellipsis appears, so …auditor-notes-final.tar.gz stays 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 every createObjectURL with exactly one revokeObjectURL across 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.

On This Page