Attachment
Displays a file or image attachment with media, metadata, upload state, and actions.
- Status
- Since
0.1.0- Accessibility pattern
- labelled card with an independent trigger
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Attachment before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify attachmentInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/attachmentRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/file-viewer, @vegastack/media-card, @vegastack/progress, @vegastack/skeleton, @vegastack/tile-overlay.
Usage
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/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>;Attachment shows a file or image attachment — its media, its name, its metadata — with optional
actions and an upload state. Use it for files and images in chat composers, message threads and
upload lists. It renders that state; it owns no upload logic.
Anatomy
Examples
Composition
Attachment
├── AttachmentMedia
├── AttachmentContent
│ ├── AttachmentTitle
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction
└── AttachmentTriggerUse AttachmentGroup to lay out multiple attachments in a scrollable row:
AttachmentGroup
├── Attachment
└── AttachmentFeatures
- Icon and image media through
AttachmentMedia - Upload states:
idle,uploading,processing,erroranddone, with built-in styling and a shimmer while the upload is in progress - Three sizes, and horizontal or vertical orientation
- A full-card
AttachmentTriggerthat opens a link or a dialog while the actions stay independently clickable above it - A scrollable, snapping
AttachmentGroupwith an edge fade - Customizable styling through the
classNameprop on every part
Image
Set variant="image" on AttachmentMedia and render an <img> inside it. Use
orientation="vertical" to stack the media above the content.
States
Set state to reflect the upload lifecycle. uploading and processing shimmer the title, and
error switches to a destructive treatment.
Sizes
Use size to switch between default, sm and xs — or lg, whose vertical tile is 10rem wide (the listing tile a record's files strip uses) and whose horizontal tile has a larger media box.
Group
Wrap attachments in AttachmentGroup to lay them out in a horizontally scrollable, snapping row
with an edge fade.
Trigger
Add an AttachmentTrigger to make the whole card open a link or a dialog. It fills the card behind
the actions, so the actions stay clickable.
<Dialog>
<Attachment>
{/* media, content, actions */}
<DialogTrigger
render={<AttachmentTrigger aria-label="Preview research-summary.pdf" />}
/>
</Attachment>
<DialogContent>{/* ... */}</DialogContent>
</Dialog>Record files grid
A record's files — a product's datasheets and photos, say — read better as a grid than as a
scrolling row. Set layout="grid" on AttachmentGroup to wrap the tiles into equal columns that
reflow down to one or two at a phone width. AttachmentProgress gives an upload a determinate bar
that announces its percentage, and muted dims the media of a file the record no longer uses —
say why in AttachmentDescription, so the state is never carried by opacity alone.
For the full upload flow — acquiring files, per-file progress, cancel, retry and discard — see the Upload queue example on Dropzone.
Form tiles
In a form or a settings page, files sit beside fields. Set layout="tiles" on AttachmentGroup
for square tiles — at most columns a row (four by default), three and then two as the container
narrows, never below 8.5rem a tile. The count follows the group's own width, not the viewport's, so
the same grid fits a full page and a narrow form column. Pair it with
UploadDialog for adding files.
Tile overlay
On a vertical tile, AttachmentActions is an overlay slot instead of a row: the default
side="end" is the top-right corner — at most two icon actions, such as × remove, a ⋯ menu or
download — and side="start" the top-left one, where a drag handle goes (SortableList's grid puts
its ⠿ there). Each slot is inset from the corner so its buttons sit on the image, shows on hover or
focus within the tile and always on a touch screen, and paints an AttachmentAction in
scrim-foreground on a blurred scrim wash (bg-scrim/40, /60 on hover) — the same scrim
AvatarPicker lays over a photo, from the shared tile-overlay recipe. --scrim and
--scrim-foreground are theme-invariant tokens (black and white in both themes); retune them in
your theme to change every tile overlay at once. Leave a slot out and it is not there. A horizontal chip keeps its actions inline.
<Attachment orientation="vertical" file={file}>
{/* media, content */}
<AttachmentActions side="start">{/* handle */}</AttachmentActions>
<AttachmentActions>
<DropdownMenu>
<DropdownMenuTrigger
render={
<AttachmentAction aria-label="Actions for spec-sheet.pdf">
<EllipsisVerticalIcon />
</AttachmentAction>
}
/>
<DropdownMenuContent align="end">
{/* Download, Replace, Remove */}
</DropdownMenuContent>
</DropdownMenu>
</AttachmentActions>
</Attachment>Open on click
Give a tile its file (a FileViewerItem) and the whole tile becomes one Open {name} button: a
click or tap, or Enter/Space on it, opens the
FileViewer — an image to zoom and pan, a PDF page by page, a
download card for anything else. The viewer is its own chunk, loaded the first time a tile opens.
It pages through every tile in the same AttachmentGroup; wrap several groups (a record's file
sections) or a SortableList of tiles in AttachmentPreview to page across all of them. The
overlay actions sit above the open button, so a click on × never opens the viewer.
Preview is on by default. preview={false} on a tile or a group keeps plain cards, and onOpen
replaces the built-in viewer with your own handler.
Sized scroll row
layout="scroll" with columns sizes each tile so that many fit the row and the rest scroll —
four large tiles on a record page, two and a peek on a phone. Without columns the tiles keep their
own width, as upstream's row does.
List rows
layout="list" stacks dense, full-width rows — a 32px icon or thumbnail, the name, a description
line (size · who · when) and trailing actions — for a folder's files or an upload queue. A row with
a file opens the viewer like a tile. While a file uploads, put AttachmentProgress inside
AttachmentContent so the bar sits under the title; a failed row keeps its tinted border and
offers Retry as an action. columns does not apply.
Cards
layout="cards" is the list row in a responsive grid: three columns on a wide container, two on a
tablet, one on a phone — each column at least 16rem, so the count follows the group's own width, not the
viewport's. Each card wears the same surface as MediaCard (RECORD_CARD_SURFACE_CLASS: card
background, border, rounded-lg, p-2 pe-3 padding, a subtle hover fill), the whole card the open
button, with a 48px thumbnail in place of the list's 32px, the name, description lines and trailing
actions. It is the layout FileCard is made for. columns does not apply.
Playground
Step through the lifecycle states, sizes, orientations and media treatments, then copy the generated JSX.
<Attachment>
<AttachmentMedia>
<FileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>release-notes.pdf</AttachmentTitle>
<AttachmentDescription>248 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>Loading a list of files
AttachmentSkeletons sketches file rows in their real shape — a 32px thumbnail, the name and the
size line — while the list loads. AttachmentDescription sets its numbers in tabular figures, so
sizes line up down a list.
<div role="status" aria-label="Loading files">
<AttachmentSkeletons count={3} />
</div>API Reference
Attachment
| Prop | Type | Default | Description |
|---|---|---|---|
file | FileViewerItem | — | The file this tile stands for. With it, the whole tile is one button ("Open {name}") that
opens the FileViewer — an image to zoom, a PDF to read, a download card for anything
else — paging through every tile in the same AttachmentGroup or AttachmentPreview. |
muted | boolean | — | |
onOpen | ((file: FileViewerItem) => void) | — | Called instead of the built-in viewer when the tile is opened — for a host that shows the
file its own way. Needs file, which names the tile's open button. |
orientation | "horizontal" | "vertical" | null | — | |
preview | boolean | true | Open the viewer when the tile is clicked, or Enter or Space is pressed on it. false keeps
the tile a plain card (its actions still work). |
size | "default" | "lg" | "sm" | "xs" | null | — | |
state | "done" | "error" | "idle" | "processing" | "uploading" | — |
AttachmentMedia
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "icon" | "image" | null | — |
AttachmentContent
AttachmentContent adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
AttachmentTitle
AttachmentTitle adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
AttachmentDescription
AttachmentDescription adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
AttachmentActions
| Prop | Type | Default | Description |
|---|---|---|---|
side | "end" | "start" | "end" | A vertical tile's overlay slot: end is the top-right corner (remove, a ⋯ menu, download —
two icon actions at most), start the top-left (a drag handle). Both show on hover or focus
within the tile and always on a touch screen. A horizontal chip keeps its actions inline. |
AttachmentAction
An action button. Renders a Button at size="icon-xs" and
variant="ghost", and accepts every prop that Button accepts.
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | — | Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its
box at opacity: 0, so the button's width does not move and its accessible name survives
(API-5, A11Y-12). |
size | "default" | "icon" | "icon-lg" | "icon-sm" | "icon-xs" | "lg" | "sm" | "xs" | null | — | |
variant | "default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | null | — |
AttachmentProgress
A determinate or indeterminate upload bar composed from Progress's
track and indicator. It takes its own row in the tile and is named "Upload progress" unless you
pass an aria-label.
| Prop | Type | Default | Description |
|---|---|---|---|
value* | number | null | — | |
max | number | — |
AttachmentTrigger
AttachmentTrigger adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
AttachmentGroup
| Prop | Type | Default | Description |
|---|---|---|---|
columns | 2 | 3 | 4 | 5 | 6 | — | The most tiles a row shows. tiles: the grid's column count on a wide container (default
4). scroll: sizes each tile so this many fit the row and the rest scroll (without it, tiles
keep their own width). Never below 8.5rem a tile, so a phone shows two. |
layout | "cards" | "grid" | "list" | "scroll" | "tiles" | "scroll" | scroll — one row that scrolls sideways; grid — listing-size tiles, up to three per row
with 16:9 media; tiles — square tiles, columns per row at most (four by default), fewer
as the container narrows: three on a tablet, two on a phone; list — dense full-width rows
(a 32px icon or thumbnail, the name, a description line, trailing actions) for a folder's
files or an upload queue. In a row, put AttachmentProgress inside AttachmentContent, under
the title; cards — the same rows in a responsive grid (three columns on a wide container,
two on a tablet, one on a phone) with a 48px thumbnail, for a record's files. |
preview | boolean | true | Whether tiles given a file open the FileViewer — one viewer paging through this group's
tiles, or through the enclosing AttachmentPreview's. false keeps them plain cards. |
AttachmentPreview
A preview scope: every tile with a file inside it opens one FileViewer that pages through all of
them in document order.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | |
disabled | boolean | false | Leave the tiles inside as plain cards: no whole-tile open, no viewer. |
Accessibility
- Label every icon-only action.
AttachmentActionis usually a bare icon, so give each one anaria-labelnaming the action and its target — "Remove sales-dashboard.pdf", not "Remove". - Label the trigger.
AttachmentTriggercovers the card and has no text of its own, so give it anaria-labelfor what activating it does. A tile given afilelabels its own open buttonOpen {name}, and Enter or Space on it opens the viewer. - Overlay slots reveal on focus too. A tile's overlay actions show while anything in the tile has focus, so a keyboard user tabbing from the open button to × sees every control it reaches.
- The trigger sits behind the actions in the stacking order, so an
AttachmentActionand theAttachmentTriggernever trap each other — both stay separately focusable and separately clickable. - Focus is the global 2px
:focus-visibleoutline frombase.css, painted on the trigger itself and following the card's own corner. The card wears no ring on the trigger's behalf — see Deviations. - Keyboard scrolling. An
AttachmentGroupscrolls horizontally. When its attachments are interactive, keyboard users reach the off-screen ones by tabbing. For a row of presentational attachments, make the group itself the scroller withtabIndex={0},role="group"and anaria-label. - Meaning beyond colour. The
errorstate uses a destructive colour; keep the failure reason inAttachmentDescriptionso the state is never carried by colour alone. AttachmentActionissize="icon-xs"—size-6, 24px, exactly on the WCAG 2.2 SC 2.5.8 floor.attachment.test.tsxmeasures it rather than assuming it.
| Contract | States tested |
|---|---|
| Behaviour | default, complete, disabled, error, muted, open, progress, uploading |
| Accessibility | disabled, focus-visible, labeled, live, status-announcement, semantic-html |
| Visual | default, focus, error |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/attachment.patch. Every hunk:
- FOC-1, FOC-6 —
attachmentVariantsdropsfocus-within:ring-1 focus-within:ring-ring/50, andAttachmentTriggerdropsoutline-none. Upstream's arrangement is the card wearing a ring on behalf of the invisible full-bleed trigger inside it, with the trigger's own affordance silenced; this system has one focus affordance and it belongs on the element that has focus.rounded-[inherit]on the trigger is part of the same hunk: the trigger isabsolute inset-0inside arounded-xl(orrounded-lgatsize="xs") card, so without it the outline would cut a rectangle across the card's corners. - A11Y-2 — audited, no hunk. Every control in this file is a
Button:AttachmentActiondefaults tosize="icon-xs", which issize-6— 24px, exactly on the SC 2.5.8 floor — andAttachmentTriggeris the full card.attachment.test.tsxmeasures both, so the day upstream shrinks the action tier below 24px it fails here rather than quietly shipping. - A11Y-13 —
AttachmentDescription's error ink becomestext-destructive-textin place of upstream'stext-destructive/80. Rasterised oncardwith this system's tokens, upstream's pair measures 4.113:1 at 12px — a WCAG 1.4.3 failure, and the same pair A11Y-13 already moved onto the-textink for Alert, Badge, Toast and the soft Button. It went unnoticed until Batch 6 because attachment's test lane ran unstyled and the cross-cutting contrast lane had no attachment subject; both gaps are closed.AttachmentMedia's error icon ink is deliberately left upstream-verbatim: measured against its own composited/10tint it is 3.973:1, over the 3:1 floor for non-text UI. - API-16 —
"use client"is added. Upstream ships none, and this file callsuseRender, which callsReact.useRefinternally; under thereact-servercondition that hook isundefined, so a server component rendering it throws on import. The boundary goes at the lowest interactive leaf, and this file is it. - API-28 — Attachment's scope widens from chat to file and media tiles on any surface.
AttachmentGrouptakeslayout="scroll" | "grid" | "tiles" | "list" | "cards"(upstream's row stays the default), a newAttachmentProgresspart composesProgress's track and indicator and announcesaria-valuetext="{n}%",Attachmenttakesmuted(data-muted, media atopacity-50), and image media styles a nested<img>with[&_img]in place of*:[img], so the systemImageand its fallback fill the slot. The tile overhaul (28-09-2026) addscolumns(at most N tiles a row, container-responsive, and a sizedscrollrow), theside="start" | "end"overlay slots with the blurred scrim on a vertical tile,file/preview/onOpenandAttachmentPreviewfor the whole-tile open intoFileViewer, and the full name asAttachmentTitle'stitle.layout="list"(29-09-2026) stacks dense full-width rows, andlayout="cards"(12-10-2026) lays the same rows out in a responsive grid with a 48px media box. - DOC-2 —
import { cn } from "cn"becomes@vegastack/design; our npm layer ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header the three-copy model needs; no decision of its own.