Inputs
Upload list
Rows of files being uploaded, with size, progress, retry and remove. Your code uploads; this shows it.
- Built on
<progress>aria-live- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.8 kB
Uploads0 of 2 done, 1 failed
- site-photos.zip
- floor-plan.dwgConnection lost
Code
"use client";
import { useState } from "react";
import { UploadList, type UploadItem } from "@/components/equip/upload-list";
const start: UploadItem[] = [
{ id: "b", name: "site-photos.zip", size: 38_500_000, state: "uploading", progress: 0.64 },
{ id: "c", name: "floor-plan.dwg", size: 12_100_000, state: "failed", error: "Connection lost" },
];
export default function Demo() {
const [files, setFiles] = useState(start);
return (
<UploadList
style={{ width: 224 }}
files={files}
onRemove={(id) => setFiles((all) => all.filter((f) => f.id !== id))}
onRetry={(id) => setFiles((all) => all.map((f) => (f.id === id ? { ...f, state: "uploading", progress: 0.1, error: undefined } : f)))}
/>
);
}Examples
Driving it from upload code
The list never uploads anything. Here a timer stands in for your upload code: it moves each file from queued to done, and the second one fails until you retry it.
Code
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/equip/button";
import { UploadList, type UploadItem } from "@/components/equip/upload-list";
const batch: UploadItem[] = [
{ id: "1", name: "invoice-2026-10.pdf", size: 182_000, state: "queued" },
{ id: "2", name: "contract-signed-final.pdf", size: 2_400_000, state: "queued" },
{ id: "3", name: "receipts-october.zip", size: 9_800_000, state: "queued" },
];
export default function Example() {
const [files, setFiles] = useState<UploadItem[]>([]);
const attempts = useRef<Record<string, number>>({});
// One tick moves the first unfinished file along; only one uploads at a time.
useEffect(() => {
const current = files.find((f) => f.state === "uploading") ?? files.find((f) => f.state === "queued");
if (!current) return;
const timer = setTimeout(() => {
setFiles((all) =>
all.map((f) => {
if (f.id !== current.id) return f;
if (f.state === "queued") return { ...f, state: "uploading", progress: 0 };
const progress = Math.min(1, (f.progress ?? 0) + 0.2);
const tries = attempts.current[f.id] ?? 0;
if (f.id === "2" && tries === 0 && progress >= 0.6) {
attempts.current[f.id] = 1;
return { ...f, state: "failed", error: "The server closed the connection." };
}
return progress >= 1 ? { ...f, state: "done", progress: 1 } : { ...f, progress };
}),
);
}, 350);
return () => clearTimeout(timer);
}, [files]);
return (
<div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 340 }}>
<UploadList
label="Attachments"
files={files}
onRetry={(id) => setFiles((all) => all.map((f) => (f.id === id ? { ...f, state: "queued", progress: undefined, error: undefined } : f)))}
onRemove={(id) => setFiles((all) => all.filter((f) => f.id !== id))}
/>
<Button
variant="outline"
size="s"
style={{ justifySelf: "start" }}
onClick={() => {
attempts.current = {};
setFiles(batch);
}}
>
{files.length ? "Start again" : "Upload three files"}
</Button>
</div>
);
}Every state, read only
Without onRetry and onRemove the rows have no buttons, which suits a record of what was sent. Leaving out progress gives a bar that only says the upload is under way.
Imported statements1 of 5 done, 1 failed
- statement-2026-08.csv
- statement-2026-09.csv
- statement-2026-10.csv
- statement-2026-11.csv
- statement-2026-07.xlsOnly CSV files can be imported.
Code
import { UploadList } from "@/components/equip/upload-list";
export default function Example() {
return (
<UploadList
label="Imported statements"
style={{ width: "100%", maxWidth: 340 }}
files={[
{ id: "s1", name: "statement-2026-08.csv", size: 48_000, state: "done" },
{ id: "s2", name: "statement-2026-09.csv", size: 51_500, state: "uploading", progress: 0.35 },
{ id: "s3", name: "statement-2026-10.csv", size: 50_200, state: "uploading" },
{ id: "s4", name: "statement-2026-11.csv", size: 12_900, state: "queued" },
{ id: "s5", name: "statement-2026-07.xls", size: 220_000, state: "failed", error: "Only CSV files can be imported." },
]}
/>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
files | UploadItem[] | none | One row each: id, name, size in bytes, state, and optional progress (0 to 1) and error. |
label | ReactNode | "Uploads" | Heading above the list with a done count; null hides the heading. |
onRetry | (id: string) => void | none | Adds a Retry button to failed rows. |
onRemove | (id: string) => void | none | Adds a remove button to every row, named Cancel upload of the file while it is queued or uploading. |
...rest | Omit<ComponentProps<"div">, "children"> | none | Every native div attribute on the wrapper, including ref. |
UploadItem state | "queued" | "uploading" | "done" | "failed" | none | Sets the icon, the status text and whether a bar shows. |
Anatomy
| root | .eq-upload-list | |
|---|---|---|
| header | .eq-upload-list-header | |
| label | .eq-upload-list-label | |
| count | .eq-upload-list-count | How many are done, and how many failed. |
| list | .eq-upload-list-list | Takes focus when the last row is removed. |
| item | .eq-upload-list-item | data-state is queued, uploading, done or failed; data-id is the file's id. |
| icon | .eq-upload-list-icon | File, check or warning, by state. |
| tick | .eq-upload-list-tick | The check's stroke, which draws in when a file finishes. |
| main | .eq-upload-list-main | |
| name | .eq-upload-list-name | A Truncate that keeps the extension visible. |
| meta | .eq-upload-list-meta | |
| size | .eq-upload-list-size | |
| status | .eq-upload-list-status | Queued, a percentage, Uploading, Done or Failed, in text. |
| error | .eq-upload-list-error | |
| retry | .eq-upload-list-retry | An extra small ghost Button. |
| remove | .eq-upload-list-remove | A square extra small quiet Button. |
| progress | .eq-upload-list-progress | Native progress, shown only while uploading; indeterminate without a number. |
| announcer | .eq-upload-list-announce | Visually hidden status that reads out finished and failed files. |
Keyboard
| Tab | Moves through each row's Retry and remove buttons. |
|---|---|
| EnterSpace | Retries or removes the file. |
Notes
- It does not upload anything: your code sends the files and updates each item's state and progress, and the list draws them.
- Rows are keyed by id, so two files with the same name stay apart.
- Only state changes are announced, such as report.pdf uploaded; progress ticks are not, but the percentage is always on screen as text.
- After a remove, focus moves to the next row's remove button, or to the list when it was the last one.
- Pair it with File drop for choosing files, and give File drop no progress so the bars are not shown twice.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/upload-list.tsx198 lines
"use client";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "./button";
import { Truncate } from "./truncate";
import "./upload-list.css";
export type UploadState = "queued" | "uploading" | "done" | "failed";
export type UploadItem = {
/** Stable key for the row. Two files may share a name; they never share an id. */
id: string;
name: string;
/** Size in bytes. */
size: number;
state: UploadState;
/** 0 to 1 while uploading. Leave out for a bar that only says it is under way. */
progress?: number;
/** Why it failed, shown under the name. */
error?: string;
};
export type UploadListProps = Omit<ComponentProps<"div">, "children"> & {
/** Heading above the list, and its accessible name. */
label?: ReactNode;
files: UploadItem[];
/** Adds a Retry button to failed rows. */
onRetry?: (id: string) => void;
/** Adds a remove button to every row; on a queued or uploading row it reads as Cancel. */
onRemove?: (id: string) => void;
};
function formatSize(bytes: number): string {
if (bytes < 1000) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let n = bytes / 1000;
let unit = 0;
while (n >= 1000 && unit < units.length - 1) {
n /= 1000;
unit += 1;
}
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[unit]}`;
}
const percent = (value: number) => `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
function StateIcon({ state }: { state: UploadState }) {
const common = {
className: "eq-upload-list-icon",
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
if (state === "done") {
return (
<svg {...common}>
<circle cx="8" cy="8" r="6" />
<path d="m5.5 8.25 1.75 1.75 3.25-3.75" pathLength="1" className="eq-upload-list-tick" />
</svg>
);
}
if (state === "failed") {
return (
<svg {...common}>
<circle cx="8" cy="8" r="6" />
<path d="M8 5v3.25M8 10.75v.01" />
</svg>
);
}
return (
<svg {...common}>
<path d="M9.25 1.75H4.5c-.69 0-1.25.56-1.25 1.25v10c0 .69.56 1.25 1.25 1.25h7c.69 0 1.25-.56 1.25-1.25V5.25l-3.5-3.5Z" />
<path d="M9.25 1.75v3.5h3.5" />
</svg>
);
}
export function UploadList({ label = "Uploads", files, onRetry, onRemove, className, ...rest }: UploadListProps) {
const [announcement, setAnnouncement] = useState("");
const seen = useRef<Map<string, UploadState> | null>(null);
const list = useRef<HTMLUListElement>(null);
/** After a remove, where focus goes: the neighbouring row's id, or "" for the list itself. */
const refocus = useRef<string | null>(null);
useEffect(() => {
const target = refocus.current;
if (target === null || !list.current) return;
refocus.current = null;
const row = target ? list.current.querySelector<HTMLElement>(`[data-id="${CSS.escape(target)}"] .eq-upload-list-remove`) : null;
(row ?? list.current).focus();
}, [files]);
function remove(id: string) {
const index = files.findIndex((f) => f.id === id);
const neighbour = files[index + 1] ?? files[index - 1];
refocus.current = neighbour ? neighbour.id : "";
onRemove?.(id);
}
// Only finishes and failures are announced; progress ticks would drown everything else out.
useEffect(() => {
const previous = seen.current;
const next = new Map(files.map((f) => [f.id, f.state]));
seen.current = next;
if (!previous) return;
const said: string[] = [];
for (const file of files) {
const was = previous.get(file.id);
if (was === file.state) continue;
if (file.state === "done") said.push(`${file.name} uploaded.`);
else if (file.state === "failed") said.push(`${file.name} failed${file.error ? `: ${file.error}` : ""}.`);
}
if (said.length) setAnnouncement(said.join(" "));
}, [files]);
const done = files.filter((f) => f.state === "done").length;
const failed = files.filter((f) => f.state === "failed").length;
return (
<div {...rest} className={["eq-upload-list", className].filter(Boolean).join(" ")}>
{label != null && files.length > 0 && (
<div className="eq-upload-list-header">
<span className="eq-upload-list-label">{label}</span>
<span className="eq-upload-list-count">
{done} of {files.length} done{failed > 0 && `, ${failed} failed`}
</span>
</div>
)}
<ul ref={list} tabIndex={-1} className="eq-upload-list-list" aria-label={typeof label === "string" ? label : "Uploads"}>
{files.map((file) => {
const status =
file.state === "queued"
? "Queued"
: file.state === "uploading"
? file.progress === undefined
? "Uploading"
: percent(file.progress)
: file.state === "done"
? "Done"
: "Failed";
const active = file.state === "queued" || file.state === "uploading";
return (
<li key={file.id} className="eq-upload-list-item" data-id={file.id} data-state={file.state}>
<StateIcon state={file.state} />
<span className="eq-upload-list-main">
<Truncate className="eq-upload-list-name" keep={8}>
{file.name}
</Truncate>
<span className="eq-upload-list-meta">
<span className="eq-upload-list-size">{formatSize(file.size)}</span>
<span className="eq-upload-list-status">{status}</span>
</span>
{file.state === "failed" && file.error && <span className="eq-upload-list-error">{file.error}</span>}
</span>
{file.state === "failed" && onRetry && (
<Button variant="ghost" size="xs" className="eq-upload-list-retry" onClick={() => onRetry(file.id)} aria-label={`Retry ${file.name}`}>
Retry
</Button>
)}
{onRemove && (
<Button
variant="quiet"
size="xs"
square
className="eq-upload-list-remove"
aria-label={active ? `Cancel upload of ${file.name}` : `Remove ${file.name}`}
onClick={() => remove(file.id)}
lead={
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="m4.5 4.5 7 7m0-7-7 7" />
</svg>
}
/>
)}
{file.state === "uploading" && (
<progress
className="eq-upload-list-progress"
max={1}
value={file.progress === undefined ? undefined : Math.max(0, Math.min(1, file.progress))}
aria-label={`Uploading ${file.name}`}
aria-valuetext={file.progress === undefined ? undefined : percent(file.progress)}
/>
)}
</li>
);
})}
</ul>
<p className="eq-upload-list-announce" role="status">
{announcement}
</p>
</div>
);
}components/equip/upload-list.css203 lines
@layer equip {
.eq-upload-list {
display: grid;
gap: var(--eq-space-2);
min-width: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-upload-list-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--eq-space-3);
}
.eq-upload-list-label {
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-upload-list-count {
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-xs) / 1.3 var(--eq-font);
font-variant-numeric: tabular-nums;
}
.eq-upload-list-list {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
margin: 0;
padding: 0;
list-style: none;
border-radius: var(--eq-radius-s);
}
.eq-upload-list-list:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* The same row as File drop's, with a status line under the name. */
.eq-upload-list-item {
box-sizing: border-box;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
column-gap: var(--eq-space-2);
min-height: var(--eq-h-l);
padding: var(--eq-space-1) 1px var(--eq-space-1) var(--eq-space-2);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-s);
font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-upload-list-item[data-state="failed"] {
border-color: color-mix(in srgb, var(--eq-danger) 45%, var(--eq-line));
}
.eq-upload-list-icon {
width: 16px;
height: 16px;
color: var(--eq-ink-3);
}
.eq-upload-list-item[data-state="done"] .eq-upload-list-icon {
color: var(--eq-ok);
}
.eq-upload-list-item[data-state="failed"] .eq-upload-list-icon {
color: var(--eq-danger);
}
/* The tick draws itself in when a file finishes. */
.eq-upload-list-tick {
stroke-dasharray: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-ease);
}
@starting-style {
.eq-upload-list-tick {
stroke-dashoffset: 1;
}
}
.eq-upload-list-main {
display: grid;
min-width: 0;
}
.eq-upload-list-name {
min-width: 0;
}
.eq-upload-list-meta {
display: flex;
gap: var(--eq-space-2);
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-upload-list-size::after {
content: "·";
margin-inline-start: var(--eq-space-2);
}
.eq-upload-list-status {
color: var(--eq-ink-2);
}
.eq-upload-list-item[data-state="done"] .eq-upload-list-status {
color: var(--eq-ok);
}
.eq-upload-list-item[data-state="failed"] .eq-upload-list-status,
.eq-upload-list-error {
color: var(--eq-danger);
}
.eq-upload-list-error {
font-size: var(--eq-text-xs);
}
/* The remove button keeps the last column whether or not a retry sits before it. */
.eq-upload-list-remove {
grid-column: 4;
}
.eq-upload-list-progress {
grid-column: 2 / -1;
display: block;
width: calc(100% - var(--eq-space-2));
height: 3px;
margin: var(--eq-space-1) 0 2px;
border: 0;
border-radius: var(--eq-radius-full);
overflow: hidden;
appearance: none;
background: var(--eq-tint-strong);
}
.eq-upload-list-progress::-webkit-progress-bar {
background: transparent;
}
.eq-upload-list-progress::-webkit-progress-value {
border-radius: var(--eq-radius-full);
background: var(--eq-solid);
transition: width var(--eq-dur-move) var(--eq-ease);
}
.eq-upload-list-progress::-moz-progress-bar {
border-radius: var(--eq-radius-full);
background: var(--eq-solid);
}
/* Without a number, a short segment slides along the track, as in Progress. */
.eq-upload-list-progress:indeterminate {
background:
linear-gradient(var(--eq-solid), var(--eq-solid)) no-repeat,
var(--eq-tint-strong);
background-size: 40% 100%;
animation: eq-upload-list-slide 1.4s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.eq-upload-list-progress:indeterminate::-webkit-progress-value {
background: transparent;
}
.eq-upload-list-progress:indeterminate::-moz-progress-bar {
background: transparent;
}
@keyframes eq-upload-list-slide {
from {
background-position: -70% 0;
}
to {
background-position: 170% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.eq-upload-list-progress:indeterminate {
animation-duration: 4s;
animation-timing-function: linear;
}
}
.eq-upload-list-announce {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}