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
Built from
ButtonTruncate
Uploads0 of 2 done, 1 failed
  • site-photos.zip39 MB64%
  • floor-plan.dwg12 MBFailedConnection 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.csv48 KBDone
    • statement-2026-09.csv52 KB35%
    • statement-2026-10.csv50 KBUploading
    • statement-2026-11.csv13 KBQueued
    • statement-2026-07.xls220 KBFailedOnly 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

    PropTypeDefaultWhat it does
    filesUploadItem[]noneOne row each: id, name, size in bytes, state, and optional progress (0 to 1) and error.
    labelReactNode"Uploads"Heading above the list with a done count; null hides the heading.
    onRetry(id: string) => voidnoneAdds a Retry button to failed rows.
    onRemove(id: string) => voidnoneAdds a remove button to every row, named Cancel upload of the file while it is queued or uploading.
    ...restOmit<ComponentProps<"div">, "children">noneEvery native div attribute on the wrapper, including ref.
    UploadItem state"queued" | "uploading" | "done" | "failed"noneSets 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-countHow many are done, and how many failed.
    list.eq-upload-list-listTakes focus when the last row is removed.
    item.eq-upload-list-itemdata-state is queued, uploading, done or failed; data-id is the file's id.
    icon.eq-upload-list-iconFile, check or warning, by state.
    tick.eq-upload-list-tickThe check's stroke, which draws in when a file finishes.
    main.eq-upload-list-main
    name.eq-upload-list-nameA Truncate that keeps the extension visible.
    meta.eq-upload-list-meta
    size.eq-upload-list-size
    status.eq-upload-list-statusQueued, a percentage, Uploading, Done or Failed, in text.
    error.eq-upload-list-error
    retry.eq-upload-list-retryAn extra small ghost Button.
    remove.eq-upload-list-removeA square extra small quiet Button.
    progress.eq-upload-list-progressNative progress, shown only while uploading; indeterminate without a number.
    announcer.eq-upload-list-announceVisually hidden status that reads out finished and failed files.

    Keyboard

    TabMoves through each row's Retry and remove buttons.
    EnterSpaceRetries 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;
      }
    }

    EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

    Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

    Loading the index.

    Loading the index