Skip to content
Component installs need the registry setup
VegaStack Design

Attachment

Displays a file or image attachment with media, metadata, upload state, and actions.

Status
stable
Since
0.1.0
Accessibility pattern
labelled card with an independent trigger

Last updated

A scenic landscape
landscape.svgSVG · 820 KB
An abstract portrait mark
portrait-ada.svgSVG · 12 KB
A second abstract portrait mark
portrait-linus.svgSVG · 12 KB
sales-dashboard.pdfUploading · 64%
message-renderer.tsxTypeScript · 12 KB

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 attachment

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/attachment

Run 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.

A scenic landscape
landscape.svgSVG · 820 KB
An abstract portrait mark
portrait-ada.svgSVG · 12 KB
A second abstract portrait mark
portrait-linus.svgSVG · 12 KB
sales-dashboard.pdfUploading · 64%
message-renderer.tsxTypeScript · 12 KB

Anatomy

Attachment
AttachmentAction — data-slot="attachment-action"
AttachmentActions — data-slot="attachment-actions"
AttachmentContent — data-slot="attachment-content"
AttachmentDescription — data-slot="attachment-description"
AttachmentGroup — data-slot="attachment-group"
AttachmentPreview
AttachmentMedia — data-slot="attachment-media"
AttachmentProgress — data-slot="attachment-progress"
AttachmentTitle — data-slot="attachment-title"
AttachmentTrigger
AttachmentSkeletons — data-slot="attachment-skeletons"

Examples

Composition

Attachment
├── AttachmentMedia
├── AttachmentContent
│   ├── AttachmentTitle
│   └── AttachmentDescription
├── AttachmentActions
│   └── AttachmentAction
└── AttachmentTrigger

Use AttachmentGroup to lay out multiple attachments in a scrollable row:

AttachmentGroup
├── Attachment
└── Attachment
sales-dashboard.pdfPDF · 2.4 MB

Features

  • Icon and image media through AttachmentMedia
  • Upload states: idle, uploading, processing, error and done, with built-in styling and a shimmer while the upload is in progress
  • Three sizes, and horizontal or vertical orientation
  • A full-card AttachmentTrigger that opens a link or a dialog while the actions stay independently clickable above it
  • A scrollable, snapping AttachmentGroup with an edge fade
  • Customizable styling through the className prop on every part
briefing-notes.pdfPDF · 1.4 MB
market-research.pdfProcessing document
A scenic landscape
landscape.svgSVG · 820 KB
renderer.tsx

Image

Set variant="image" on AttachmentMedia and render an <img> inside it. Use orientation="vertical" to stack the media above the content.

A scenic landscape
landscape.svgSVG · 820 KB
An abstract portrait mark
portrait-ada.svgSVG · 12 KB
A second abstract portrait mark
portrait-linus.svgSVG · 12 KB

States

Set state to reflect the upload lifecycle. uploading and processing shimmer the title, and error switches to a destructive treatment.

selected-file.pdfReady to upload
design-system.zipUploading · 64%
market-research.pdfProcessing document
financial-model.xlsxUpload failed. Try again.
uploaded-report.pdfUploaded · 1.8 MB

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.

Default attachmentPDF · 2.4 MB
Small attachmentPDF · 2.4 MB
Extra small attachment
Large attachmentPDF · 2.4 MB
A scenic landscape
landscape.svgLarge tile · 10rem

Group

Wrap attachments in AttachmentGroup to lay them out in a horizontally scrollable, snapping row with an edge fade.

briefing-notes.pdfPDF · 1.4 MB
A scenic landscape
landscape.svgSVG · 820 KB
customers.csvCSV · 18 KB
renderer.tsxTSX · 12 KB

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>
research-summary.pdfOpen preview 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.

A scenic landscape
landscape.svgSVG · 820 KB
An abstract portrait mark
portrait-ada.svgSVG · 12 KB
ip-rating-cert.pdf1.1 MB of 2.4 MB
x
2024-datasheet.pdfReplaced by the 2026 sheet

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.

landscape.svg
landscape.svgSVG · 820 KB
portrait-ada.svg
portrait-ada.svgSVG · 12 KB
spec-sheet.pdfPDF · 64 KB
beam-30deg-with-a-long-photometry-file-name.iesIES · 4 KB
ip-rating-cert.pdf62%
x

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>
landscape.svg
landscape.svgSVG · 820 KB
spec-sheet.pdfPDF · 64 KB
portrait-ada.svg
portrait-ada.svgSVG · 12 KB

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.

landscape.svg
landscape.svgSVG · 820 KB
portrait-ada.svg
portrait-ada.svgSVG · 12 KB
spec-sheet.pdfPDF · 64 KB
beam-30deg-with-a-long-photometry-file-name.iesIES · 4 KB
landscape.svg
landscape.svgSVG · 820 KB

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.

landscape.svg
landscape.svgSVG · 820 KB
portrait-ada.svg
portrait-ada.svgSVG · 12 KB
spec-sheet.pdfPDF · 64 KB
beam-30deg-with-a-long-photometry-file-name.iesIES · 4 KB
landscape.svg
landscape.svgSVG · 820 KB
portrait-ada.svg
portrait-ada.svgSVG · 12 KB
spec-sheet.pdfPDF · 64 KB
beam-30deg-with-a-long-photometry-file-name.iesIES · 4 KB

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.

landscape.svgSVG · 820 KB · Asha Rao · 2 days ago
portrait-ada.svgSVG · 12 KB · Asha Rao · 2 days ago
spec-sheet.pdfPDF · 64 KB · Asha Rao · 2 days ago
beam-30deg-with-a-long-photometry-file-name.iesIES · 4 KB · Asha Rao · 2 days ago
install-guide.pdf
x
warranty-scan.tiffUpload failed. Check your connection and retry.

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.

landscape.svgSVG · 820 KB · Asha Rao · 2 days ago
portrait-ada.svgSVG · 12 KB · Asha Rao · 2 days ago
spec-sheet.pdfPDF · 64 KB · Asha Rao · 2 days ago
beam-30deg-with-a-long-photometry-file-name.iesIES · 4 KB · Asha Rao · 2 days ago

Playground

Step through the lifecycle states, sizes, orientations and media treatments, then copy the generated JSX.

release-notes.pdf248 KB
<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

PropTypeDefaultDescription
fileFileViewerItem—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.
mutedboolean—
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—
previewbooleantrueOpen 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

PropTypeDefaultDescription
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

PropTypeDefaultDescription
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.

PropTypeDefaultDescription
loadingboolean—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.

PropTypeDefaultDescription
value*number | null—
maxnumber—

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

PropTypeDefaultDescription
columns2 | 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.
previewbooleantrueWhether 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.

PropTypeDefaultDescription
childrenReact.ReactNode—
disabledbooleanfalseLeave the tiles inside as plain cards: no whole-tile open, no viewer.

Accessibility

  • Label every icon-only action. AttachmentAction is usually a bare icon, so give each one an aria-label naming the action and its target — "Remove sales-dashboard.pdf", not "Remove".
  • Label the trigger. AttachmentTrigger covers the card and has no text of its own, so give it an aria-label for what activating it does. A tile given a file labels its own open button Open {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 AttachmentAction and the AttachmentTrigger never trap each other — both stay separately focusable and separately clickable.
  • Focus is the global 2px :focus-visible outline from base.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 AttachmentGroup scrolls 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 with tabIndex={0}, role="group" and an aria-label.
  • Meaning beyond colour. The error state uses a destructive colour; keep the failure reason in AttachmentDescription so the state is never carried by colour alone.
  • AttachmentAction is size="icon-xs" — size-6, 24px, exactly on the WCAG 2.2 SC 2.5.8 floor. attachment.test.tsx measures it rather than assuming it.
sales-dashboard.pdfPDF · 2.4 MB
financial-model.xlsxUpload failed — the file is larger than 25 MB.
briefing-notes.pdfPDF · 1.4 MB
customers.csvCSV · 18 KB
renderer.tsxTSX · 12 KB
ContractStates tested
Behaviourdefault, complete, disabled, error, muted, open, progress, uploading
Accessibilitydisabled, focus-visible, labeled, live, status-announcement, semantic-html
Visualdefault, focus, error

Do / Don't

Do
Put the failure reason in AttachmentDescription and a target-naming aria-label on every icon-only action.
Don't
Use Attachment as a generic upload drop zone — it renders upload state, it does not acquire files.

Deviations

Upstream's file plus packages/ui/upstream/patches/attachment.patch. Every hunk:

  • FOC-1, FOC-6 — attachmentVariants drops focus-within:ring-1 focus-within:ring-ring/50, and AttachmentTrigger drops outline-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 is absolute inset-0 inside a rounded-xl (or rounded-lg at size="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: AttachmentAction defaults to size="icon-xs", which is size-6 — 24px, exactly on the SC 2.5.8 floor — and AttachmentTrigger is the full card. attachment.test.tsx measures both, so the day upstream shrinks the action tier below 24px it fails here rather than quietly shipping.
  • A11Y-13 — AttachmentDescription's error ink becomes text-destructive-text in place of upstream's text-destructive/80. Rasterised on card with 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 -text ink 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 /10 tint 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 calls useRender, which calls React.useRef internally; under the react-server condition that hook is undefined, 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. AttachmentGroup takes layout="scroll" | "grid" | "tiles" | "list" | "cards" (upstream's row stays the default), a new AttachmentProgress part composes Progress's track and indicator and announces aria-valuetext="{n}%", Attachment takes muted (data-muted, media at opacity-50), and image media styles a nested <img> with [&_img] in place of *:[img], so the system Image and its fallback fill the slot. The tile overhaul (28-09-2026) adds columns (at most N tiles a row, container-responsive, and a sized scroll row), the side="start" | "end" overlay slots with the blurred scrim on a vertical tile, file / preview / onOpen and AttachmentPreview for the whole-tile open into FileViewer, and the full name as AttachmentTitle's title. layout="list" (29-09-2026) stacks dense full-width rows, and layout="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 owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header the three-copy model needs; no decision of its own.

On this page