Attachment
A file chip with media, name, size and actions, alone or in a group.
- Status
- Experimental
- Level
- Molecule
- Category
- Content
- Adoption
- Not used yet
import { Attachment } from "@oration/canon/components/attachment";packages/canon/src/components/attachment.tsximport { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentMedia, AttachmentTitle,} from "@oration/canon/components/attachment";import { Button } from "@oration/canon/components/button";import { Spinner } from "@oration/canon/components/spinner";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { CircleAlertIcon, FileSpreadsheetIcon, FileTextIcon, PaperclipIcon, RotateCwIcon, XIcon,} from "lucide-react";import * as React from "react";export function Hero() { const replyId = React.useId(); const [files, setFiles] = React.useState([ { name: "bank-letter.pdf", detail: "212 KB", state: "done" as "done" | "uploading" | "error", kind: "pdf", }, { name: "w9-northwind.pdf", detail: "Upload failed", state: "error" as "done" | "uploading" | "error", kind: "pdf", }, { name: "remittance-sep-25.csv", detail: "Uploading, 62%", state: "uploading" as "done" | "uploading" | "error", kind: "csv", }, ]); const remove = (name: string) => setFiles((current) => current.filter((file) => file.name !== name)); const retry = (name: string) => { setFiles((current) => current.map((file) => file.name === name ? { ...file, state: "done" as const, detail: "96 KB" } : file, ), ); toast.add({ type: "success", title: `${name} uploaded` }); }; return ( <div className="flex w-full max-w-2xl flex-col rounded-xl bg-card shadow-border"> <label htmlFor={replyId} className="sr-only"> Reply to Northwind Freight </label> <textarea id={replyId} rows={3} defaultValue="Hi Aisha, the new bank letter is attached. The $1,200 credit posts on Friday's run." className="min-h-20 resize-none rounded-t-xl bg-transparent px-4 pt-3 text-sm text-foreground outline-none placeholder:text-muted-foreground" /> {files.length ? ( <AttachmentGroup className="px-3"> {files.map((file) => ( <Attachment key={file.name} state={file.state} size="sm" > <AttachmentMedia> {file.state === "uploading" ? ( <Spinner /> ) : file.state === "error" ? ( <CircleAlertIcon aria-hidden="true" /> ) : file.kind === "csv" ? ( <FileSpreadsheetIcon aria-hidden="true" /> ) : ( <FileTextIcon aria-hidden="true" /> )} </AttachmentMedia> <AttachmentContent className="max-w-36"> <AttachmentTitle>{file.name}</AttachmentTitle> <AttachmentDescription> {file.detail} </AttachmentDescription> </AttachmentContent> <AttachmentActions> {file.state === "error" ? ( <Tooltip> <TooltipTrigger render={ <AttachmentAction aria-label={`Retry ${file.name}`} onClick={() => retry(file.name) } /> } > <RotateCwIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Retry</TooltipContent> </Tooltip> ) : null} <Tooltip> <TooltipTrigger render={ <AttachmentAction aria-label={`Remove ${file.name}`} onClick={() => remove(file.name) } /> } > <XIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Remove</TooltipContent> </Tooltip> </AttachmentActions> </Attachment> ))} </AttachmentGroup> ) : null} <div className="flex items-center justify-between gap-2 px-3 pt-1 pb-3"> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label="Attach a file" className="text-muted-foreground" onClick={() => toast.add({ title: "Attach a file", description: "Opens the file picker.", }) } /> } > <PaperclipIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Attach a file</TooltipContent> </Tooltip> <Button type="button" size="sm" onClick={() => toast.add({ type: "success", title: "Reply sent", description: `${files.filter((f) => f.state === "done").length} attachments to Northwind Freight.`, }) } > Send reply </Button> </div> </div> );}Usage#
Attachment is a file chip: a media tile, the file name, one line of detail and optional actions, with states for idle, uploading, processing, error and done. It's meant for files on a reply, a ticket or a message, laid out in a scrolling AttachmentGroup. It is experimental: the Contact Center composer and threads still hand-roll smaller chips. The mistake is a vague error; the description line is where the chip says what went wrong and what to do.
When to use
- For files attached to a reply before it's sent, with upload progress and a remove action.
- For files on a sent message, ticket or conversation that people open, through
AttachmentTrigger. - For a document a flow is checking, such as a W-9 being matched, in the
processingstate. - For a row of document thumbnails that scrolls sideways, with
orientation="vertical".
When not to use
- For the drop target people drag files onto. Use Dropzone
- For a value on a record, such as a tier or stage. Use Tag
- For an active filter. Use Filter chip
- For retrieved knowledge sources in an AI answer. Use Source cards
- For a row in a list of documents with several columns. Use Table
The Label-Beside-Color Rule
The Machine Mono Rule
Anatomy#
- Container.
Attachment: a Card White chip with 12px corners and a hairline border (dashed when idle, red at 30% on error). - Media.
AttachmentMedia: a 40px Well Gray tile (32px atsm, 28px atxs) holding a file icon, a spinner or an image. - Title.
AttachmentTitle: the file name in medium weight, truncated. It shimmers while uploading or processing. - Description.
AttachmentDescription: size, progress or the error, in 12px Slate Meta (red at 80% on error). - Actions.
AttachmentActionswithAttachmentActionghost 24px icon buttons, such as Retry and Remove.
Examples#
Sizes
Default for a single file, sm in composers and threads, xs for tight rows. Media steps from 40 to 32 to 28px.
import { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle,} from "@oration/canon/components/attachment";import { FileSpreadsheetIcon } from "lucide-react";export function Sizes() { const sizes = ["default", "sm", "xs"] as const; return ( <div className="flex flex-col items-start gap-3"> {sizes.map((size) => ( <Attachment key={size} size={size}> <AttachmentMedia> <FileSpreadsheetIcon aria-hidden="true" /> </AttachmentMedia> <AttachmentContent> <AttachmentTitle>payment-run-oct-2.csv</AttachmentTitle> <AttachmentDescription> {size === "default" ? "Default" : size === "sm" ? "Small" : "Extra small"} , 48 KB </AttachmentDescription> </AttachmentContent> </Attachment> ))} </div> );}Vertical
orientation="vertical" stacks media over text in a 120px tile. In a group, tiles scroll sideways with snap points and an edge fade.
import { Attachment, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentMedia, AttachmentTitle,} from "@oration/canon/components/attachment";import { FileTextIcon } from "lucide-react";export function Vertical() { const files = [ { name: "invoice-20931.pdf", detail: "1 page" }, { name: "credit-memo-118.pdf", detail: "2 pages" }, { name: "delivery-receipt.pdf", detail: "1 page" }, ]; return ( <AttachmentGroup className="w-full max-w-md"> {files.map((file) => ( <Attachment key={file.name} orientation="vertical"> <AttachmentMedia> <FileTextIcon aria-hidden="true" className="text-muted-foreground" /> </AttachmentMedia> <AttachmentContent> <AttachmentTitle>{file.name}</AttachmentTitle> <AttachmentDescription> {file.detail} </AttachmentDescription> </AttachmentContent> </Attachment> ))} </AttachmentGroup> );}Opening a file
AttachmentTrigger covers the chip with one named button, so the whole chip opens the file and fills Well Gray on hover.
import { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle, AttachmentTrigger,} from "@oration/canon/components/attachment";import { toast } from "@oration/canon/components/toast";import { FileTextIcon } from "lucide-react";export function WithTrigger() { const files = [ { name: "bank-letter-northwind.pdf", detail: "212 KB, from Aisha Bello", }, { name: "price-list-aug-2026.pdf", detail: "88 KB, from Wen Zhou" }, ]; return ( <div className="flex flex-wrap gap-3"> {files.map((file) => ( <Attachment key={file.name} size="sm"> <AttachmentTrigger aria-label={`Open ${file.name}`} onClick={() => toast.add({ title: file.name, description: "Opens the preview.", }) } /> <AttachmentMedia> <FileTextIcon aria-hidden="true" /> </AttachmentMedia> <AttachmentContent> <AttachmentTitle>{file.name}</AttachmentTitle> <AttachmentDescription> {file.detail} </AttachmentDescription> </AttachmentContent> </Attachment> ))} </div> );}States#
import { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle,} from "@oration/canon/components/attachment";import { Spinner } from "@oration/canon/components/spinner";import { CircleAlertIcon, FileTextIcon, UploadIcon } from "lucide-react";export function States() { const states = [ { state: "idle" as const, title: "Add a W-9", detail: "PDF, up to 10 MB", icon: <UploadIcon aria-hidden="true" />, }, { state: "uploading" as const, title: "w9-halcyon.pdf", detail: "Uploading, 40%", icon: <Spinner />, }, { state: "processing" as const, title: "w9-halcyon.pdf", detail: "Checking the TIN", icon: <Spinner />, }, { state: "error" as const, title: "w9-halcyon.pdf", detail: "File is over 10 MB", icon: <CircleAlertIcon aria-hidden="true" />, }, { state: "done" as const, title: "w9-halcyon.pdf", detail: "84 KB", icon: <FileTextIcon aria-hidden="true" />, }, ]; return ( <div className="flex w-full flex-wrap gap-3"> {states.map((entry) => ( <div key={entry.state} className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground capitalize"> {entry.state} </span> <Attachment state={entry.state} size="sm"> <AttachmentMedia>{entry.icon}</AttachmentMedia> <AttachmentContent> <AttachmentTitle>{entry.title}</AttachmentTitle> <AttachmentDescription> {entry.detail} </AttachmentDescription> </AttachmentContent> </Attachment> </div> ))} </div> );}| State | Treatment |
|---|---|
| Idle | A dashed border: a slot waiting for a file, such as Add a W-9. |
| Uploading | The title shimmers, image media dims to 60%, and the description carries progress. |
| Processing | Same treatment as uploading, for work after the upload such as a virus scan or TIN check. |
| Error | Border at 30% red, a red-tinted media tile and a red description that says what failed. |
| Done | The resting chip at full opacity. |
| Hover | With an AttachmentTrigger, the chip fills Well Gray at 50%. |
| Focus visible | Whenever anything inside has focus, the chip draws a 1px Focus Indigo ring at 50%. |
Behavior#
stateonly changes the look and setsdata-state; you drive it from your upload code.AttachmentTriggeris an invisible button covering the chip (absolute inset-0 z-10). Actions sit above it atz-20, so remove and retry stay clickable.AttachmentGrouplays chips in one row that scrolls sideways with snap points and fades its clipped edges withscroll-fade-x.- Vertical chips are 96px wide (120px with text) and float their actions over the top-right of the media.
- Image media dims to 60% until the attachment is
doneoridle. - The title's shimmer runs for as long as the state is uploading or processing.
Do and don't#
Content#
- Titles are the file name as uploaded, including the extension: bank-letter-northwind.pdf.
- Descriptions are one short fact: size (212 KB), progress (Uploading, 62%), the work in progress (Checking the TIN) or the error (File is over 10 MB).
- Error text says what to do when it isn't obvious: Upload failed. Try again.
- Name actions with the file: Remove w9-halcyon.pdf, Retry w9-halcyon.pdf.
- Idle slots say what belongs there: Add a W-9, with the accepted type and size as the description.
Accessibility#
- Icon-only actions need an
aria-labelthat names the file and a tooltip.AttachmentActionis a Button and takes both. AttachmentTriggerhas no text of its own; give it anaria-labelsuch as Open bank-letter.pdf.- State is visual only. Announce upload progress and failures in a polite live region, and include the state in the description text.
- The spinner inside media has
role="status"and the name Loading; the description should still say what's loading. - The shimmer isn't stopped under reduced motion.
- The group scrolls sideways, and chips are focusable only through their triggers and actions. Keep the most important files first.
| Keys | Action |
|---|---|
| Tab | Moves to each chip's trigger, then its actions. |
| Enter | Opens the file or runs the focused action. |
Design tokens#
| Token | Used for |
|---|---|
--card | Chip surface |
--border | Chip border, dashed when idle |
--muted | Media tile; the 50% hover fill |
--destructive | Error border at 30%, media tint at 10%, description at 80% |
--ring | The 1px focus ring at 50% |
shimmer | Title shimmer while uploading or processing |
scroll-fade-x | Edge fade on the group |
--radius-xl | 12px chip corners (10px at xs) |
API reference#
Attachment
The chip. Sets data-slot="attachment", data-state, data-size and data-orientation.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
state | "idle" | "uploading" | "processing" | "error" | "done" | "done" | Visual state of the file. |
size | "default" | "sm" | "xs" | "default" | Padding, gap, text size and media size. |
orientation | "horizontal" | "vertical" | "horizontal" | A row chip, or a 96px tile with media on top. |
AttachmentMedia
The leading tile.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "icon" | "image" | "icon" | image crops an <img> to a square and dims it until done. |
AttachmentContent
The text column.
Other props spread onto <div>.
No props of its own.
AttachmentTitle
The file name, truncated.
Other props spread onto <span>.
No props of its own.
AttachmentDescription
One line of detail, truncated.
Other props spread onto <span>.
No props of its own.
AttachmentActions
The trailing actions, above the trigger.
Other props spread onto <div>.
No props of its own.
AttachmentAction
An action button.
Other props spread onto Button.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | Button variant | "ghost" | Any Button variant. |
size | Button size | "icon-xs" | Any Button size. |
AttachmentTrigger
A transparent button over the whole chip. Defaults to type="button".
Other props spread onto Base UI useRender props for <button>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render as another element, such as a link to the file. |
AttachmentGroup
A single row of chips that scrolls sideways with snap points.
Other props spread onto <div>.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Attachment isn't imported anywhere in apps/web. The Contact Center composer hand-rolls 24px bg-muted chips and the threads 28px bg-muted/70 chips, both with a paperclip and no states.
The chip is a Card White surface with a CSS border. By The Hairline-and-Lift Rule a raised chip takes its edge from shadow-border, and inside a message or card it would be a tint well instead.
The focus indicator is focus-within:ring-1 ring-ring/50, a 1px ring where Canon uses 3px.
The title's shimmer isn't stopped under reduced motion; globals.css only stops text-shimmer and skeleton-shimmer.
AttachmentGroup uses scrollbar-none, which isn't a utility in this Tailwind setup, so the group shows the thin global scrollbar.
The registry's export list omits AttachmentContent, AttachmentDescription, AttachmentAction and AttachmentTrigger.