Upload Queue
A list of uploading files, each with its own progress bar, cancel, retry on failure and a tick when done.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/upload-queue.json'use client'
import './upload-queue.css'
export interface UploadFile { id: string; name: string; size: number; progress: number; status: 'uploading' | 'done' | 'error' }
export interface UploadQueueProps {
files: UploadFile[]
onCancel?: (id: string) => void
onRetry?: (id: string) => void
removing?: string[]
fill?: 'dark' | 'light'
className?: string
}
const fmt = (b: number) => (b > 1e6 ? `${(b / 1e6).toFixed(1)} MB` : `${Math.round(b / 1e3)} KB`)
/** Per-file upload rows with progress, retry and cancel. */
export function UploadQueue({ files, onCancel, onRetry, removing = [], fill = 'dark', className }: UploadQueueProps) {
const live = files.filter((f) => !removing.includes(f.id))
const total = live.reduce((a, f) => a + f.size, 0) || 1
const sent = live.reduce((a, f) => a + f.size * (f.status === 'done' ? 1 : f.progress), 0)
const done = live.filter((f) => f.status === 'done').length
return (
<div className={['sd-uq', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-uq__head"><span><b>{done}</b> of {live.length} uploaded</span><span>{Math.round((sent / total) * 100)}%</span></div>
{files.map((f) => (
<div key={f.id} className="sd-uq__row" data-status={f.status} data-gone={removing.includes(f.id) || undefined}>
<div className="sd-uq__in">
<div className="sd-uq__card">
<span className="sd-uq__ext" aria-hidden>{f.name.split('.').pop()}</span>
<div style={{ minWidth: 0 }}>
<div className="sd-uq__name">{f.name}</div>
<div className="sd-uq__sub">{f.status === 'error' ? 'Upload failed' : f.status === 'done' ? fmt(f.size) : `${fmt(f.size * f.progress)} of ${fmt(f.size)}`}</div>
{f.status !== 'done' && (
<div className="sd-uq__bar" role="progressbar" aria-label={f.name} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(f.progress * 100)}>
<i style={{ transform: `scaleX(${f.progress})` }} />
</div>
)}
</div>
{f.status === 'done' ? (
<span className="sd-uq__act" aria-label="Uploaded" role="img"><svg viewBox="0 0 16 16" className="sd-uq__ok"><path d="m3.5 8.5 3 3 6-7" /></svg></span>
) : f.status === 'error' ? (
<button type="button" className="sd-uq__act" aria-label={`Retry ${f.name}`} onClick={() => onRetry?.(f.id)}><svg viewBox="0 0 16 16" aria-hidden><path d="M13 8a5 5 0 1 1-1.5-3.6M13 2.5v2.5h-2.5" /></svg></button>
) : (
<button type="button" className="sd-uq__act" aria-label={`Cancel ${f.name}`} onClick={() => onCancel?.(f.id)}><svg viewBox="0 0 16 16" aria-hidden><path d="m4.5 4.5 7 7m0-7-7 7" /></svg></button>
)}
</div>
</div>
</div>
))}
<span className="sr" aria-live="polite">{files.filter((f) => f.status === 'error').map((f) => `${f.name} failed`).join(', ')}</span>
</div>
)
}.sd-uq { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-uq[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-uq button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-uq :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-uq .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-uq { width: min(100%, 320px); padding: 6px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-uq__head { display: flex; justify-content: space-between; padding: 8px 10px 10px; font-size: 12.5px; color: var(--mute); }
.sd-uq__head b { color: var(--fg); font-weight: 600; }
.sd-uq__row { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 260ms ease, opacity 200ms; }
.sd-uq__row[data-gone] { grid-template-rows: 0fr; opacity: 0; }
.sd-uq__in { overflow: hidden; }
.sd-uq__card { display: grid; grid-template-columns: 34px 1fr auto; gap: 10px; align-items: center; padding: 10px; border-radius: 12px; }
.sd-uq__card:hover { background: var(--soft); }
.sd-uq__ext { display: grid; place-items: center; width: 34px; height: 40px; border-radius: 7px; box-shadow: inset 0 0 0 1px var(--line); font: 600 9px/1 ui-monospace, monospace; text-transform: uppercase; color: var(--mute); }
.sd-uq__name { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sd-uq__sub { font-size: 11.5px; color: var(--mute); font-variant-numeric: tabular-nums; margin-top: 2px; }
.sd-uq__row[data-status='error'] .sd-uq__sub { color: var(--fg); }
.sd-uq__bar { height: 3px; border-radius: 9px; background: var(--soft); margin-top: 7px; overflow: hidden; }
.sd-uq__bar i { display: block; height: 100%; background: var(--fg); transform-origin: left; transition: transform 200ms linear; }
.sd-uq__row[data-status='error'] .sd-uq__bar i { background: var(--mute); }
.sd-uq__act { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: var(--mute); }
.sd-uq__act:hover { color: var(--fg); background: var(--soft); }
.sd-uq__act svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.sd-uq__ok path { stroke-dasharray: 14; animation: sd-uq-draw 400ms ease forwards; }
@keyframes sd-uq-draw { from { stroke-dashoffset: 14; } to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) { .sd-uq *, .sd-uq *::before, .sd-uq *::after { animation: none !important; transition: none !important; } }Overview
Every file row tracks its own state: uploading with a thin bar and percentage, failed with a retry button, or done with a tick that draws itself. A header sums progress across the queue.
Cancelled rows collapse out of the list. The demo simulates the network; wire upload state from your own uploader.
When to use
- Attachments in composers, media libraries, bulk import.
When not to use
- Single small files: inline button state is enough.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/upload-queue.json$
npx shadcn@latest add https://sundaeui.dev/r/upload-queue.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/upload-queue.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/upload-queue.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy upload-queue.tsx and upload-queue.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { UploadQueue } from '@/components/sundae/upload-queue'
<UploadQueue />Props
| Prop | Type | Default |
|---|---|---|
files*Rows. | { id: string; name: string; size: number; progress: number; status: "uploading" | "done" | "error" }[] | — |
onCancel / onRetryRow actions. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each row has a progressbar with aria-valuenow and the file name as its label.
- Status changes (failed, done) are announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-uq; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Upload Queue free?
- Yes. Upload Queue is free for personal and commercial projects, with full source on this page.
- How do I install Upload Queue?
- Run npx shadcn@latest add https://sundaeui.dev/r/upload-queue.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Upload Queue support keyboard and screen readers?
- Each row has a progressbar with aria-valuenow and the file name as its label. Status changes (failed, done) are announced politely.
- Does Upload Queue respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Drop Zone
Forms · Refined Avatar Upload
Forms · Refined- Pro
Photo Uploader
Forms · Refined OTP Field
Forms · Refined- Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined