caliper uidocs

Upload queue

Free

Files on their way up, each with its own progress ring; failures say why with a retry, and rows open and close without the list jumping.

Installs components/ui/upload-queue.tsxDepends on @radix-ui/react-slot, lucide-reactMotion none in component source
pnpm dlx shadcn@latest add https://caliperui.site/r/upload-queue.json

Uploading 2 of 4 · 4.5 MB of 22.6 MB

  • board-pack-q3.pdf4.0 MB
  • bank-statements-sept.csv336 KB of 840 KB
  • receipts-october.zipWaiting · 17.5 MB
  • contract-ostra.docxThe connection dropped. Try again.

When to use #

Use it for

  • Attachments, imports and media libraries.
  • Any upload of more than one file.

Skip it when

  • A single small file; show progress on its button.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/upload-queue.json

Adds its 7 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { UploadQueue, type QueuedFile } from "@/components/ui/upload-queue"

const startFiles: QueuedFile[] = [
  { id: "f1", name: "board-pack-q3.pdf", size: 4_200_000, progress: 100, status: "done" },
  { id: "f2", name: "bank-statements-sept.csv", size: 860_000, progress: 40, status: "uploading" },
  { id: "f3", name: "receipts-october.zip", size: 18_400_000, progress: 0, status: "queued" },
  { id: "f4", name: "contract-ostra.docx", size: 220_000, progress: 62, status: "error", error: "The connection dropped. Try again." },
];

export function UploadQueueDemo() {
  const [files, setFiles] = useState(startFiles);
  useEffect(() => {
    const id = setInterval(() => {
      setFiles((current) => {
        let started = current.some((file) => file.status === "uploading");
        return current.map((file) => {
          if (file.status === "uploading") {
            const progress = Math.min(100, file.progress + 6 + Math.random() * 10);
            return progress >= 100 ? { ...file, progress: 100, status: "done" as const } : { ...file, progress };
          }
          if (file.status === "queued" && !started) {
            started = true;
            return { ...file, status: "uploading" as const };
          }
          return file;
        });
      });
    }, 400);
    return () => clearInterval(id);
  }, []);
  return (
    <div className="grid w-full max-w-md gap-3">
      <UploadQueue
        files={files}
        onRetry={(id) => setFiles((current) => current.map((file) => (file.id === id ? { ...file, status: "queued", progress: 0, error: undefined } : file)))}
        onCancel={(id) => setFiles((current) => current.filter((file) => file.id !== id))}
        onClearDone={() => setFiles((current) => current.filter((file) => file.status !== "done"))}
      />
      <Button size="sm" variant="secondary" className="w-fit" onClick={() => setFiles((current) => [...current, { id: `f${Date.now()}`, name: `scan-${current.length + 1}.pdf`, size: 1_300_000, progress: 0, status: "queued" }])}>Add a file</Button>
    </div>
  );
}

API reference #

UploadQueue

component
PropTypeDefaultDescription
files *readonly QueuedFile[]—
onRetry((id: string) => void)—
onCancel((id: string) => void)—Cancels a queued or uploading file, or removes a failed one.
onClearDone(() => void)—Removes finished files from the list.
classNamestring—

Accessibility #

It exposes status semantics, with aria-label and aria-hidden kept in step with state. Changes that matter are announced through a live region.

No custom key handling; Tab and the native activation keys do the work.

Motion #

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.