Attachment
A file chip / thumbnail card for chat and message-compose surfaces — media slot, name + meta, uploading/error/complete states, and remove/download actions.
- Status
- Since
0.1.0- Accessibility pattern
- labelled media tile
Last updated
Install
Add Attachment from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/attachmentThe same command installs the registry items it composes: @vegastack/spinner, @vegastack/progress.
Usage
import { X } from "lucide-react";
import {
Attachment,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { IconButton } from "@/components/ui/icon-button";
import { FileText } from "lucide-react";
<Attachment state="complete">
<AttachmentMedia>
<FileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>release-notes.pdf</AttachmentTitle>
<AttachmentDescription>248 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<IconButton aria-label="Remove release-notes.pdf" variant="ghost" size="xs">
<X />
</IconButton>
</AttachmentActions>
</Attachment>;Anatomy
There is no headless @shadcn/react Attachment primitive published (0.2.1
exports only ./message-scroller), so — like the rest of the chat family
(Message, Bubble,
Marker) — Attachment is fully self-owned: plain
CVA + data-slot/data-* styling, no new npm dependency.
Attachment— the root chip/card (data-slot="attachment"). Ownsstate,size,orientation.AttachmentMedia— the leading file-type icon or image thumbnail (data-slot="attachment-media", decorativearia-hidden). Shows an inlineSpinneroverlay automatically whilestate="uploading".AttachmentContent— the vertical column of title / description / progress.AttachmentTitle— the file name. Always truncates.AttachmentDescription— the meta line (size, upload %, error message). Passliveto make it anaria-live="polite"region.AttachmentProgress— a thin determinate bar (wrapsProgress) for the uploading state.AttachmentActions— the trailing action row. ComposeIconButtondirectly — its type-levelaria-labelrequirement covers the "remove button needs an accessible name" contract, so there is no separateAttachmentActionwrapper.AttachmentTrigger— an optional invisible overlay that turns the whole card into one activation target (open/download). Built on Base UIuseRender.AttachmentGroup— wraps multiple attachments in a message-compose row.
Examples
File chip
The default anatomy: a file-type icon, name + size, and a remove action.
Image thumbnail
orientation="vertical" stacks the media on top; AttachmentMedia variant="image" expects a filled thumbnail — compose
Image (aspectRatio="square" className="size-full") for a real, resolved URL. AttachmentActions floats
over the corner and an AttachmentTrigger makes the whole card open the file.
Uploading
state="uploading" shimmers the title and reveals the AttachmentMedia
spinner overlay automatically. Pair AttachmentProgress (the visual bar) with
a live AttachmentDescription (the text screen readers actually hear as the
percentage changes).
Error
state="error" tints the border, media, title, and description with
destructive tokens. Give the AttachmentDescription live so the failure
reason is announced.
Removable compose list
AttachmentGroup wraps several size="sm" attachments under a
message-compose input; each IconButton remove action drives real client
state.
Playground
Step through the lifecycle states, densities, 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>API Reference
Attachment
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | AttachmentOrientation | 'horizontal' | Layout. |
size | AttachmentSize | 'md' | Density. |
state | AttachmentState | 'idle' | Lifecycle state.
- idle: attached, not yet acted on.
- uploading: in flight — pair with AttachmentProgress and a live AttachmentDescription.
- error: failed — tints the border/media/description with destructive tokens.
- complete: finished/delivered.
- disabled: visually dimmed and inert (pointer-events-none); also pass disabled to any
interactive descendant (e.g. IconButton) so it is unreachable by keyboard, not just the mouse. |
Data attributes and CSS variables on Attachment
| Attribute | Values |
|---|---|
data-orientation | mirrors a prop or state value |
data-size | mirrors a prop or state value |
data-slot | "attachment" |
data-state | mirrors a prop or state value |
AttachmentMedia
| Prop | Type | Default | Description |
|---|---|---|---|
variant | AttachmentMediaVariant | 'icon' | icon centers a bare lucide-react glyph; image expects a filled thumbnail (compose
"@/components/ui/image" Image with aspectRatio="square" and className="size-full"). |
Data attributes and CSS variables on AttachmentMedia
| Attribute | Values |
|---|---|
data-slot | "attachment-media" | "attachment-media-overlay" |
data-variant | mirrors a prop or state value |
AttachmentDescription
| Prop | Type | Default | Description |
|---|---|---|---|
live | boolean | false | Mark this line as a polite live region — announce its text as it changes. Set this whenever
the description shows transient status copy (an upload percentage, an error message) so screen
reader users hear the update without having to re-focus the attachment. Leave false for a
static meta line (e.g. a plain file size) that never changes on its own. |
Data attributes and CSS variables on AttachmentDescription
| Attribute | Values |
|---|---|
data-slot | "attachment-description" |
AttachmentProgress
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label* | string | — | Accessible name for the underlying progress bar — required (there is no visible label). Include
the file name so assistive tech can tell multiple in-flight attachments apart, e.g.
"quarterly-report.xlsx upload progress". |
indicatorClassName | string | — | Classes for the fill indicator. Use this to change the fill token or motion treatment without replacing the accessible progress root. |
max | number | 100 | Upper bound of the scale — value is reported as value / max. |
render | ComponentRenderFn<HTMLProps, ProgressRootState> | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | — | Replace the rendered root element via Base UI render composition. Pass a
ReactElement or a render function — Base UI merges this
wrapper's data-slot and state data-* onto your element, forwards the ref,
and keeps the <Progress.Track> / <Progress.Indicator> children. The
element must support role="progressbar" semantics. |
trackClassName | string | — | Classes for the inner track rail. Use this for track width/height/color
overrides; className belongs to the root progressbar element. |
value | number | null | Current completion value, between 0 and max. Pass null for an
indeterminate bar (Base UI sets data-indeterminate and drops
aria-valuenow). |
Data attributes and CSS variables on AttachmentProgress
| Attribute | Values |
|---|---|
data-slot | "attachment-progress" |
AttachmentTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
render | useRender.RenderProp<Record<string, unknown>> | — | Render the trigger as a different element (e.g. an a to open/download the file) via Base UI
render composition. Pass a ReactElement or a render function. |
AttachmentGroup / AttachmentContent / AttachmentTitle / AttachmentActions
Plain layout wrappers with no own props — they accept standard <div> /
<span> props (className, children, …).
Accessibility
AttachmentMediais decorative (aria-hidden) —AttachmentTitle(the file name) carries the accessible identity, never the icon or thumbnail alone.- The remove/download action is a real
IconButton, which requiresaria-labelat the type level — always include the file name in it (e.g."Remove release-notes.pdf") so multiple attachments stay distinguishable to screen reader users. - Transient state changes (an upload percentage, an error message) are not
announced by color or a bare
role="progressbar"alone — passlivetoAttachmentDescriptionso the text is read out viarole="status" aria-live="polite", the same pattern asAutoSaveInputandCopyButton. state="disabled"visually dims the card and setspointer-events-none+aria-disabled, but it cannot reach into children — also passdisabledto any nestedIconButtonso it is unreachable by keyboard, not just the mouse.AttachmentTriggeris a real button covering the card, and it shows the system's standard:focus-visibleoutline, inset so the card's rounded corners do not clip it. It does not rely on a border tint alone — a tint is the text-entry treatment, not a button's.AttachmentTriggerandAttachmentActionsshare the samez-(--z-raised)stacking band; placeAttachmentTriggerfirst in JSX so the actions stay on top and clickable.
| Contract | States tested |
|---|---|
| Behaviour | default, complete, disabled, error, open, uploading |
| Accessibility | disabled, focus-visible, labeled, live, status-announcement, semantic-html |
| Visual | default, focus, error |