Skip to content

Attachment

A file chip with media, name, size and actions, alone or in a group.

Category
Content
Adoption
Not used yet
import { Attachment } from "@oration/canon/components/attachment";
packages/canon/src/components/attachment.tsx
bank-letter.pdf212 KB
w9-northwind.pdfUpload failed
remittance-sep-25.csvUploading, 62%
import {  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 processing state.
  • 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

An error chip turns its border and media red, but the description always says what failed: File is over 10 MB, not just a red tint or Error.

The Machine Mono Rule

File names stay in Geist Sans. They are names people read, not strings a machine will parse.

Anatomy#

bank-letter.pdf212 KB
  1. Container. Attachment: a Card White chip with 12px corners and a hairline border (dashed when idle, red at 30% on error).
  2. Media. AttachmentMedia: a 40px Well Gray tile (32px at sm, 28px at xs) holding a file icon, a spinner or an image.
  3. Title. AttachmentTitle: the file name in medium weight, truncated. It shimmers while uploading or processing.
  4. Description. AttachmentDescription: size, progress or the error, in 12px Slate Meta (red at 80% on error).
  5. Actions. AttachmentActions with AttachmentAction ghost 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.

payment-run-oct-2.csvDefault, 48 KB
payment-run-oct-2.csvSmall, 48 KB
payment-run-oct-2.csvExtra small, 48 KB
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.

invoice-20931.pdf1 page
credit-memo-118.pdf2 pages
delivery-receipt.pdf1 page
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.

bank-letter-northwind.pdf212 KB, from Aisha Bello
price-list-aug-2026.pdf88 KB, from Wen Zhou
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#

idle
Add a W-9PDF, up to 10 MB
uploading
w9-halcyon.pdfUploading, 40%
processing
w9-halcyon.pdfChecking the TIN
error
w9-halcyon.pdfFile is over 10 MB
done
w9-halcyon.pdf84 KB
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>    );}
States
StateTreatment
IdleA dashed border: a slot waiting for a file, such as Add a W-9.
UploadingThe title shimmers, image media dims to 60%, and the description carries progress.
ProcessingSame treatment as uploading, for work after the upload such as a virus scan or TIN check.
ErrorBorder at 30% red, a red-tinted media tile and a red description that says what failed.
DoneThe resting chip at full opacity.
HoverWith an AttachmentTrigger, the chip fills Well Gray at 50%.
Focus visibleWhenever anything inside has focus, the chip draws a 1px Focus Indigo ring at 50%.

Behavior#

  • state only changes the look and sets data-state; you drive it from your upload code.
  • AttachmentTrigger is an invisible button covering the chip (absolute inset-0 z-10). Actions sit above it at z-20, so remove and retry stay clickable.
  • AttachmentGroup lays chips in one row that scrolls sideways with snap points and fades its clipped edges with scroll-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 done or idle.
  • The title's shimmer runs for as long as the state is uploading or processing.

Do and don't#

w9-halcyon.pdfFile is over 10 MB
Do. Say what failed and how to fix it in the description, with an alert icon in the media.
w9-halcyon.pdfError
Don't. Show a red chip that only says Error. People can't tell whether to retry, shrink the file or pick another.

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-label that names the file and a tooltip. AttachmentAction is a Button and takes both.
  • AttachmentTrigger has no text of its own; give it an aria-label such 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.
Keyboard interactions
KeysAction
TabMoves to each chip's trigger, then its actions.
EnterOpens the file or runs the focused action.

Design tokens#

Design tokens
TokenUsed for
--cardChip surface
--borderChip border, dashed when idle
--mutedMedia tile; the 50% hover fill
--destructiveError border at 30%, media tint at 10%, description at 80%
--ringThe 1px focus ring at 50%
shimmerTitle shimmer while uploading or processing
scroll-fade-xEdge fade on the group
--radius-xl12px 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>.

Props of Attachment
PropTypeDefaultDescription
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>.

Props of AttachmentMedia
PropTypeDefaultDescription
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.

Props of AttachmentAction
PropTypeDefaultDescription
variantButton variant"ghost"Any Button variant.
sizeButton 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>.

Props of AttachmentTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender 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.