Attachment
FreeA file or image chip for chat inputs and forms, with an upload fill, a failed state with Retry and a card for images.
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 #
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.
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.
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.