import { FileCodeIcon, XIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Spinner } from "@/components/ui/spinner";
const images = [
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
alt: "Workspace",
},
{
name: "desk-reference.jpg",
meta: "JPG · 1.1 MB",
src: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80",
alt: "Desk",
},
{
name: "office-reference.jpg",
meta: "JPG · 940 KB",
src: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
alt: "Office",
},
];
export function AttachmentDemo() {
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-3 py-12">
<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>
</Attachment>
))}
</AttachmentGroup>
<Attachment state="uploading" className="w-full">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Cancel upload">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment className="w-full">
<AttachmentMedia>
<FileCodeIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>message-renderer.tsx</AttachmentTitle>
<AttachmentDescription>TypeScript · 12 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove message-renderer.tsx">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
);
}
The Attachment component displays a file or image attachment, its media, name, and metadata, with optional actions and upload state. Use it for files and images in chat composers, message threads, and upload lists.
pnpm dlx shadcn@latest add https://herocn.dev/r/attachment.jsonimport {
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>Use the following composition to build an attachment:
Attachment
├── AttachmentMedia
├── AttachmentContent
│ ├── AttachmentTitle
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction
└── AttachmentTriggerUse AttachmentGroup to lay out multiple attachments in a scrollable row:
AttachmentGroup
├── Attachment
└── AttachmentSet variant="image" on AttachmentMedia and render an <img> inside it. Use orientation="vertical" to stack the media above the content.
import { XIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment";
const images = [
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
alt: "Workspace",
},
{
name: "desk-reference.jpg",
meta: "JPG · 1.1 MB",
src: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80",
alt: "Desk",
},
{
name: "office-reference.jpg",
meta: "JPG · 940 KB",
src: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
alt: "Office",
},
];
export function AttachmentImage() {
return (
<div className="mx-auto w-full max-w-sm py-12">
<AttachmentGroup className="w-full">
{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
render={
<a
href={image.src}
target="_blank"
rel="noreferrer"
aria-label={`Open ${image.name}`}
/>
}
/>
</Attachment>
))}
</AttachmentGroup>
</div>
);
}
Set state to one of "idle", "uploading", "processing", "error", or "done" to reflect the upload lifecycle. "uploading" and "processing" shimmer the title, and "error" applies a destructive treatment.
import {
CheckIcon,
ClockIcon,
FileTextIcon,
FileWarningIcon,
RefreshCwIcon,
XIcon,
} from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Spinner } from "@/components/ui/spinner";
export function AttachmentStates() {
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-2 py-12">
<Attachment state="idle" className="w-full">
<AttachmentMedia>
<ClockIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>selected-file.pdf</AttachmentTitle>
<AttachmentDescription>Ready to upload</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove selected-file.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="uploading" className="w-full">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>design-system.zip</AttachmentTitle>
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Cancel upload">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="processing" className="w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>market-research.pdf</AttachmentTitle>
<AttachmentDescription>Processing document</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove market-research.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="error" className="w-full">
<AttachmentMedia>
<FileWarningIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>financial-model.xlsx</AttachmentTitle>
<AttachmentDescription>
Upload failed. Try again.
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Retry upload">
<RefreshCwIcon />
</AttachmentAction>
<AttachmentAction aria-label="Remove financial-model.xlsx">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="done" className="w-full">
<AttachmentMedia>
<CheckIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>uploaded-report.pdf</AttachmentTitle>
<AttachmentDescription>Uploaded · 1.8 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove uploaded-report.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
);
}
Use size to switch between "default", "sm", and "xs".
import { FileTextIcon } from "lucide-react";
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
export function AttachmentSizes() {
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-3 py-12">
<Attachment size="default" className="w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Default attachment</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment size="sm" className="w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Small attachment</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment size="xs" className="w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Extra small attachment</AttachmentTitle>
</AttachmentContent>
</Attachment>
</div>
);
}
Wrap attachments in AttachmentGroup to lay them out in a horizontally scrollable, snapping row with an edge fade.
import {
FileCodeIcon,
FileTextIcon,
type LucideIcon,
TableIcon,
XIcon,
} from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
type Item = {
name: string;
meta: string;
icon?: LucideIcon;
src?: string;
};
const items: Item[] = [
{ name: "briefing-notes.pdf", meta: "PDF · 1.4 MB", icon: FileTextIcon },
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
},
{ name: "customers.csv", meta: "CSV · 18 KB", icon: TableIcon },
{ name: "renderer.tsx", meta: "TSX · 12 KB", icon: FileCodeIcon },
];
export function AttachmentGroupDemo() {
return (
<div className="mx-auto w-full max-w-sm py-12">
<AttachmentGroup className="w-full">
{items.map((item) => {
const Icon = item.icon;
return (
<Attachment key={item.name} className="w-64">
{item.src ? (
<AttachmentMedia variant="image">
<img src={item.src} alt={item.name} />
</AttachmentMedia>
) : Icon ? (
<AttachmentMedia>
<Icon />
</AttachmentMedia>
) : null}
<AttachmentContent>
<AttachmentTitle>{item.name}</AttachmentTitle>
<AttachmentDescription>{item.meta}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label={`Remove ${item.name}`}>
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
);
})}
</AttachmentGroup>
</div>
);
}
Add an AttachmentTrigger to make the whole card open a link or dialog. It fills the card behind the actions, so the actions stay independently clickable.
import { CopyIcon, FileSearchIcon, XIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment";
export function AttachmentTriggerDemo() {
return (
<div className="mx-auto w-full max-w-sm py-12">
<Attachment className="w-full">
<AttachmentMedia>
<FileSearchIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>research-summary.pdf</AttachmentTitle>
<AttachmentDescription>Open in new tab</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Copy link">
<CopyIcon />
</AttachmentAction>
<AttachmentAction aria-label="Remove research-summary.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
<AttachmentTrigger
render={
<a
href="#"
target="_blank"
rel="noreferrer"
aria-label="Open research-summary.pdf"
/>
}
/>
</Attachment>
</div>
);
}
AttachmentAction is usually icon-only, so give each one an aria-label describing the action and its target.
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
<XIcon />
</AttachmentAction>AttachmentTrigger covers the card with no text of its own, so give it an aria-label for what activating it does.
<AttachmentTrigger
render={
<a
href={url}
target="_blank"
rel="noreferrer"
aria-label="Open workspace.png"
/>
}
/>The trigger sits behind the actions in the stacking order, so an AttachmentAction and the AttachmentTrigger never trap each other — both remain separately focusable and clickable.
An AttachmentGroup scrolls horizontally. When its attachments are interactive, keyboard users reach off-screen items by tabbing to them. For a row of presentational attachments, make the group itself focusable and scrollable by adding tabIndex={0}, role="group", and an aria-label.
The error state uses a destructive color. Keep the failure reason in AttachmentDescription so the state is not conveyed by color alone.
The root attachment container.
| Prop | Type | Default |
|---|---|---|
| state | "idle" | "uploading" | "processing" | "error" | "done" | "done" |
| size | "default" | "sm" | "xs" | "default" |
| orientation | "horizontal" | "vertical" | "horizontal" |
| className | string | — |
The media slot for an icon or image preview.
| Prop | Type | Default |
|---|---|---|
| variant | "icon" | "image" | "icon" |
| className | string | — |
Wraps the title and description.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
The attachment name. Shimmers while the attachment is uploading or processing.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
Secondary metadata such as the file type, size, or upload status.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
A container for one or more actions, aligned to the end of the attachment.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
An action button. Renders a Button and accepts all of its props.
| Prop | Type | Default |
|---|---|---|
| size | "default" | "lg" | "sm" | "xs" | "icon" | "icon-lg" | "icon-sm" | "icon-xs" | "icon-xs" |
| variant | "default" | "destructive" | "destructive-soft" | "ghost" | "link" | "outline" | "secondary" | "tertiary" | "ghost" |
| ...props | React.ComponentProps<typeof Button> | — |
A full-card overlay that activates the attachment. Renders a <button> by default.
| Prop | Type | Default |
|---|---|---|
| render | ReactElement | function | — |
| ...props | React.ComponentProps<'button'> | — |
Lays out attachments in a horizontally scrollable, snapping row.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
On This Page
InstallationUsageCompositionExamplesImageStatesSizesGroupTriggerAccessibilityLabel icon-only actionsLabel the triggerKeyboard scrollingMeaning beyond colorAPI ReferenceAttachmentAttachmentMediaAttachmentContentAttachmentTitleAttachmentDescriptionAttachmentActionsAttachmentActionAttachmentTriggerAttachmentGroup