- Accordion
- Alert
- Alert Dialog
- Attachment
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Checkbox
- Collapsible
- Command
- Context Menu
- Data Table
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input
- Input Group
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Pagination
- Popover
- Progress
- Questionnaire
- Radio Group
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
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>
)
}Attachment shows a file or image with its name, metadata, upload state and actions. Use it for files in chat composers, message threads and upload lists.
Installation#
pnpm dlx shadcn@latest add https://ui.ikramhasan.com/r/attachment.json
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>Composition#
Attachment
├── AttachmentMedia
├── AttachmentContent
│ ├── AttachmentTitle
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction
└── AttachmentTriggerUse AttachmentGroup to lay out several attachments in a scrollable row:
AttachmentGroup
├── Attachment
└── AttachmentExamples#
Image#
Set variant="image" on AttachmentMedia and put 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>
)
}States#
state follows the upload: idle draws a dashed outline, uploading and processing shimmer the title (and dim an image), and error turns the media and description destructive.
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>
)
}Sizes#
default, sm and xs are 56, 44 and 36px tall, with or without media.
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>
)
}Group#
AttachmentGroup lays attachments out in a horizontally scrolling, snapping row with faded edges.
import {
FileCodeIcon,
FileTextIcon,
TableIcon,
XIcon,
type LucideIcon,
} 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>
)
}Trigger#
AttachmentTrigger makes the whole card open a link or dialog. It sits behind the actions, so they stay clickable.
import { CopyIcon, FileSearchIcon, XIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export function AttachmentTriggerDemo() {
return (
<div className="mx-auto w-full max-w-sm py-12">
<Dialog>
<Attachment className="w-full">
<AttachmentMedia>
<FileSearchIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>research-summary.pdf</AttachmentTitle>
<AttachmentDescription>Open preview dialog</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Copy link">
<CopyIcon />
</AttachmentAction>
<AttachmentAction aria-label="Remove research-summary.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
<DialogTrigger
render={
<AttachmentTrigger aria-label="Preview research-summary.pdf" />
}
/>
</Attachment>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>research-summary.pdf</DialogTitle>
<DialogDescription>
The attachment trigger fills the card and opens the dialog, while
the actions stay independently clickable above it.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</div>
)
}<Dialog>
<Attachment>
{/* media, content, actions */}
<DialogTrigger
render={<AttachmentTrigger aria-label="Preview research-summary.pdf" />}
/>
</Attachment>
<DialogContent>{/* ... */}</DialogContent>
</Dialog>In a Message#
"use client"
import { DownloadIcon, FileTextIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageContent } from "@/components/ui/message"
export function MessageAttachmentDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Message align="end">
<MessageContent>
<Attachment orientation="vertical">
<AttachmentMedia variant="image">
<img
src="https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80"
alt="Workspace"
/>
</AttachmentMedia>
</Attachment>
<Bubble>
<BubbleContent>
Here's the image. Can you add it to the PDF? Use it for the
cover page.
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent>
<Bubble variant="muted">
<BubbleContent>
Done. Here's the PDF with the image added as the cover page.
</BubbleContent>
</Bubble>
<Attachment>
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
type="button"
title="Download"
aria-label="Download"
size="icon-sm"
variant="secondary"
>
<DownloadIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</MessageContent>
</Message>
<Message align="end">
<MessageContent>
<Bubble>
<BubbleContent>Thanks. Looks good.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</div>
)
}Accessibility#
AttachmentActionis usually icon-only, so give it anaria-labelnaming the action and the file ("Remove sales-dashboard.pdf").AttachmentTriggerhas no text of its own: give it anaria-labelfor what it opens. It is a real<button>(or your element viarender), and focus draws the ring on the edge of the whole card.- In an
AttachmentGroupof non-interactive attachments, make the group itself focusable (tabIndex={0},role="group",aria-label) so keyboard users can scroll it. - Keep the reason for an
errorinAttachmentDescription; the color alone doesn't say what failed.
API Reference#
| Part | Element | Props / notes |
|---|---|---|
Attachment | div | state: idle · uploading · processing · error · done (default done). size: default · sm · xs (default default). orientation: horizontal · vertical. |
AttachmentMedia | div | variant: icon (a raised tile) · image (default icon). |
AttachmentContent | div | Wraps the title and description. |
AttachmentTitle | span | The file name, truncated. Shimmers while uploading or processing. |
AttachmentDescription | span | Metadata such as type, size or upload status. |
AttachmentActions | div | Holds the actions at the end (over the image when vertical). |
AttachmentAction | button | A Button, ghost icon-xs by default, with all Button props. |
AttachmentTrigger | button | A full-card overlay; render to use a link. |
AttachmentGroup | div | A horizontally scrolling, snapping row. |