caliper uidocs

Attachment

Free

A file or image chip for chat inputs and forms, with an upload fill, a failed state with Retry and a card for images.

Installs components/ui/attachment.tsxDepends on lucide-reactMotion none in component source
pnpm dlx shadcn@latest add https://caliperui.site/r/attachment.json
  • close-checklist-q3.pdf2.4 MB
  • bank-export-october.csvUploading 20%
  • ledger-2025.xlsxOver 10 MB ·
  • dashboard-mock.png1.1 MB

When to use #

Use it for

  • Files attached to a message, a ticket or a form.
  • A queue of uploads with their own progress.

Skip it when

  • A page of files with actions per file; use the file manager or items.

Decisions #

Why it is built this way, so you know what to keep when you change your copy.

Behaviour

Progress is text too
The fill grows, and the chip says “Uploading 62%”, so progress does not depend on seeing the colour.
Errors say the limit and offer Retry
“Over 10 MB · Retry” tells people what to change. Remove stays available in every state.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/attachment.json

Adds its 2 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. It is built from Attachment and AttachmentList.

example.tsx
import { Image as ImageIcon, Paperclip } from "lucide-react"
import { useEffect, useState } from "react"
import { Attachment, AttachmentList } from "@/components/ui/attachment"
import { Button } from "@/components/ui/button"

type Upload = { id: number; name: string; size: number; state: "done" | "uploading" | "error"; progress: number };

export function AttachmentDemo() {
  const [files, setFiles] = useState<Upload[]>([
    { id: 1, name: "close-checklist-q3.pdf", size: 2_516_582, state: "done", progress: 100 },
    { id: 2, name: "bank-export-october.csv", size: 880_000, state: "uploading", progress: 20 },
    { id: 3, name: "ledger-2025.xlsx", size: 14_000_000, state: "error", progress: 0 },
  ]);
  // The upload bar moves on a timer here; in your app it follows the request's real progress.
  useEffect(() => {
    const id = setInterval(() => {
      setFiles((all) => all.map((file) => (file.state === "uploading" ? (file.progress >= 100 ? { ...file, state: "done" } : { ...file, progress: Math.min(100, file.progress + 8) }) : file)));
    }, 700);
    return () => clearInterval(id);
  }, []);
  return (
    <div className="grid gap-4">
      <AttachmentList aria-label="Attachments">
        {files.map((file) => (
          <Attachment
            key={file.id}
            name={file.name}
            size={file.size}
            state={file.state}
            progress={file.progress}
            error="Over 10 MB"
            onRemove={() => setFiles((all) => all.filter((item) => item.id !== file.id))}
            onRetry={() => setFiles((all) => all.map((item) => (item.id === file.id ? { ...item, state: "uploading", progress: 10 } : item)))}
          />
        ))}
      </AttachmentList>
      <AttachmentList aria-label="Image">
        <Attachment variant="card" name="dashboard-mock.png" size={1_153_434} preview={<ImageIcon aria-hidden="true" className="size-6" />} onRemove={() => undefined} />
      </AttachmentList>
      <Button size="sm" variant="secondary" className="justify-self-start" onClick={() => setFiles((all) => [...all, { id: Date.now(), name: "receipts-oct.zip", size: 3_400_000, state: "uploading", progress: 0 }])}>
        <Paperclip aria-hidden="true" />Add a file
      </Button>
    </div>
  );
}

API reference #

Attachment

component

Also accepts the native props of its root element and a ref.

PropTypeDefaultDescription
name *string—
sizenumber—Bytes. Shown after the name once the file is done.
state"error" | "done" | "uploading""done"
progressnumber00 to 100 while uploading. The fill grows with it and the text says it too.
errorstring—What went wrong, with the limit if there is one: "Over 10 MB".
variant"pill" | "card""pill"A pill is one line; a card shows `preview` above the name, for images.
previewReactNode—The image or icon for a card.
onRemove(() => void)—
onRetry(() => void)—

AttachmentList

component

No props of its own; it accepts the native props of its root element.

Accessibility #

It is built on native elements and keeps aria-label and aria-hidden in step with state.

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.