Chat
Displays a file or image attachment with media, metadata, upload state, and actions.
Playground
bg-card row that sizes itself to its content, so give it w-full when it should fill the composer instead.import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@workspace/ui/components/attachment"
<Attachment>
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>AttachmentContent switches the padding on, and dropping it leaves a bare media tile. Wrap several attachments in AttachmentGroup to lay them out as a scrollable row.Attachment ├── AttachmentMedia ├── AttachmentContent │ ├── AttachmentTitle │ └── AttachmentDescription ├── AttachmentActions │ └── AttachmentAction └── AttachmentTrigger AttachmentGroup ├── Attachment └── Attachment
variant="image" on AttachmentMedia and render an img inside it — the slot crops it square with object-cover and dims it until the state is done, so an upload in flight reads as unfinished without a second indicator.<AttachmentGroup>
{images.map((image) => (
<Attachment key={image.name} orientation="vertical">
<AttachmentMedia variant="image">
<img src={image.src} alt={image.alt} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{image.name}</AttachmentTitle>
<AttachmentDescription>{image.meta}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label={`Remove ${image.name}`}>
<XIcon />
</AttachmentAction>
</AttachmentActions>
<AttachmentTrigger asChild>
<a
href={image.src}
target="_blank"
rel="noreferrer"
aria-label={`Open ${image.name}`}
/>
</AttachmentTrigger>
</Attachment>
))}
</AttachmentGroup>state mirrors to data-state on the root, and every part styles itself from there. uploading and processing shimmer the title, idle dashes the border, and error turns the media tile and the description destructive.<Attachment state="idle">…</Attachment>
<Attachment state="uploading">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>design-system.zip</AttachmentTitle>
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment state="processing">…</Attachment>
<Attachment state="error">…</Attachment>
<Attachment state="done">…</Attachment>| State | Description |
|---|---|
| idle | Picked but not sent yet. The dashed border says the file is still a draft. |
| uploading | Bytes are moving. Pair the shimmer with a Spinner in the media slot and a percentage in the description. |
| processing | Uploaded, but the server is still working on it — parsing, transcoding, indexing. |
| error | The upload failed. Keep the reason in the description and offer a retry action. |
| done | The default. The file is settled, so the description carries its type and size. |
size scales the media tile, the padding, and the text together: default for a composer, sm for an attachment hanging off a message, xs for a dense list. At xs the description is usually one line too many — drop it.<Attachment size="default">…</Attachment>
<Attachment size="sm">…</Attachment>
<Attachment size="xs">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Extra small attachment</AttachmentTitle>
</AttachmentContent>
</Attachment>vertical stacks the media above the content and fixes the card width, which is what turns an attachment into a thumbnail. The actions move into the top corner as an overlay, and a vertical attachment with no AttachmentContent collapses to the tile alone.<Attachment orientation="vertical">
<AttachmentMedia variant="image">
<img src={image.src} alt={image.alt} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>workspace.png</AttachmentTitle>
<AttachmentDescription>PNG · 820 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment orientation="vertical">
<AttachmentMedia>
<FileCodeIcon />
</AttachmentMedia>
</Attachment>AttachmentGroup is a horizontal scroller with snap points, hidden scrollbars, and a mask that fades the content at whichever edge still has more to show. Children are pinned to flex-none, so give each attachment its own width.<AttachmentGroup>
{items.map((item) => (
<Attachment key={item.name} className="w-64">
<AttachmentMedia>
<item.icon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{item.name}</AttachmentTitle>
<AttachmentDescription>{item.meta}</AttachmentDescription>
</AttachmentContent>
</Attachment>
))}
</AttachmentGroup>AttachmentTrigger is an absolute overlay that makes the whole card activate something — a preview dialog, a download, a link. It sits at z-10 and AttachmentActions at z-20, so the buttons stay clickable on top of it rather than fighting it for the click.<Dialog>
<Attachment>
{/* media, content, actions */}
<DialogTrigger asChild>
<AttachmentTrigger aria-label="Preview research-summary.pdf" />
</DialogTrigger>
</Attachment>
<DialogContent>{/* … */}</DialogContent>
</Dialog>MessageContent stacks both and aligns them to the same edge. Use size="sm" there so the card reads as an annotation on the message rather than as a second message.<Message align="end">
<MessageContent>
<Bubble align="end">
<BubbleContent>Can you pull the numbers out of this deck?</BubbleContent>
</Bubble>
<Attachment size="sm">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>q3-review.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 3.1 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
</MessageContent>
</Message>AttachmentAction renders a Button and is almost always icon-only, so every one needs an aria-label naming both the action and its target. AttachmentTrigger covers the card with no text of its own and needs a label for what activating it does; because it sits behind the actions in the stacking order, the two never trap each other and both stay separately focusable. A group of presentational attachments scrolls horizontally with nothing to tab to — give it tabIndex={0}, role="group", and an aria-label so a keyboard can reach the off-screen items. Finally, the error state is a color: keep the reason for the failure in AttachmentDescription so it is never carried by the color alone.<AttachmentAction aria-label="Remove sales-dashboard.pdf">
<XIcon />
</AttachmentAction>
<AttachmentTrigger asChild>
<a
href={url}
target="_blank"
rel="noreferrer"
aria-label="Open workspace.png"
/>
</AttachmentTrigger>
<AttachmentGroup tabIndex={0} role="group" aria-label="Attachments">
{/* presentational attachments */}
</AttachmentGroup>div elements except AttachmentTitle and AttachmentDescription (span), AttachmentAction (a Button), and AttachmentTrigger (a button). All native props pass through.| Part | Prop | Default |
|---|---|---|
| Attachment | state?: "idle" | "uploading" | "processing" | "error" | "done" | "done" |
| Attachment | size?: "default" | "sm" | "xs" | "default" |
| Attachment | orientation?: "horizontal" | "vertical" | "horizontal" |
| AttachmentMedia | variant?: "icon" | "image" | "icon" |
| AttachmentContent | className?: string | — |
| AttachmentTitle | className?: string | — |
| AttachmentDescription | className?: string | — |
| AttachmentActions | className?: string | — |
| AttachmentAction | ...props: React.ComponentProps<typeof Button> | size="icon-xs" |
| AttachmentTrigger | asChild?: boolean | false |
| AttachmentGroup | className?: string | — |
Tokens used
15 design tokens consumed by the Attachment component.
--card
Card
--card-foreground
Card Foreground
--muted
Muted
--foreground
Foreground
--muted-foreground
Muted Foreground
--destructive
Destructive
--border
Border
--ring
Ring
--radius-xl
Radius Extra Large
--radius-lg
Radius Large
--radius-md
Radius Medium
--text-sm
Text Small (14px)
--text-xs
Text Extra Small (12px)
--font-weight-medium
Font Weight Medium (500)
--spacing
Spacing unit (4px)