Attachments
File and image attachments with progress, failure recovery, metadata, and removal actions.
Default
A settled attachment with its file type, size, and a remove action.
Loading component preview…
import { Attachments } from "@/components/ai/attachments"
export function AttachmentDefault() {
return (
<Attachments
variant="default"
items={[
{ id: "spec", kind: "pdf", name: "agent-spec.pdf", size: "1.8 MB" },
]}
/>
)
}
Uploading
An attachment in progress with a live upload bar.
Loading component preview…
import { Attachments } from "@/components/ai/attachments"
export function AttachmentUploading() {
return (
<Attachments
variant="uploading"
items={[
{ id: "spec", kind: "pdf", name: "agent-spec.pdf", size: "1.8 MB", progress: 45 },
]}
/>
)
}
Failed
An attachment that did not upload, with a retry action.
Loading component preview…
import { Attachments } from "@/components/ai/attachments"
export function AttachmentFailed() {
return (
<Attachments
variant="failed"
items={[
{ id: "spec", kind: "pdf", name: "agent-spec.pdf", size: "1.8 MB" },
]}
/>
)
}
Usage
import { useState } from "react"
import {
Attachments,
type AttachmentItem,
} from "@/components/ai/attachments"
const initialFiles: AttachmentItem[] = [
{ id: "pdf", name: "Project brief.pdf", kind: "pdf", size: "2.4 MB" },
{ id: "document", name: "Notes.docx", kind: "document", size: "128 KB" },
{ id: "image", name: "Cover.png", kind: "image", size: "840 KB" },
{ id: "file", name: "Assets.zip", kind: "file", size: "5.1 MB" },
]
export function AttachmentsExample() {
const [files, setFiles] = useState(initialFiles)
return (
<Attachments
items={files}
onRemove={(id) => setFiles((items) => items.filter((item) => item.id !== id))}
/>
)
}
// Use a variant to set the state for every item in a list.
export function AttachmentVariants() {
return (
<div className="space-y-6">
<Attachments variant="default" items={[initialFiles[0]]} />
<Attachments
variant="uploading"
items={[{ ...initialFiles[0], progress: 64 }]}
/>
<Attachments variant="failed" items={[initialFiles[0]]} />
</div>
)
}
// Item status overrides the variant for mixed states.
// Supply your upload implementation; report progress from your uploader.
export function UploadExample({
upload,
}: {
upload: (
item: AttachmentItem,
onProgress: (progress: number) => void,
) => Promise<void>
}) {
const [files, setFiles] = useState<AttachmentItem[]>([
{ ...initialFiles[0], status: "error" },
])
function updateFile(id: string, changes: Partial<AttachmentItem>) {
setFiles((items) => items.map((item) =>
item.id === id ? { ...item, ...changes } : item
))
}
async function retryUpload(id: string) {
const file = files.find((item) => item.id === id)
if (!file) return
updateFile(id, { status: "uploading", progress: 0 })
try {
await upload(file, (progress) => updateFile(id, { progress }))
updateFile(id, { status: "ready", progress: 100 })
} catch {
updateFile(id, { status: "error" })
}
}
return (
<Attachments
items={files}
onRetry={retryUpload}
onRemove={(id) => setFiles((items) => items.filter((item) => item.id !== id))}
/>
)
}
Install
pnpm dlx shadcn@latest add @pastaui/attachmentsnpx shadcn@latest add @pastaui/attachmentsyarn dlx shadcn@latest add @pastaui/attachmentsbunx --bun shadcn@latest add @pastaui/attachmentsSource
import { FileIcon } from '@solar-icons/react/linear/file'
import { FileTextIcon } from '@solar-icons/react/linear/file-text'
import { GalleryIcon as ImageIcon } from '@solar-icons/react/linear/gallery'
import { RestartIcon as RotateCcwIcon } from '@solar-icons/react/linear/restart'
import { CloseIcon as XIcon } from '@solar-icons/react/linear/close'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from 'cn'
interface AttachmentItem {
id: string
kind?: 'document' | 'file' | 'image' | 'pdf'
name: string
progress?: number
size?: string
status?: 'error' | 'ready' | 'uploading'
url?: string
}
interface AttachmentsProps extends React.ComponentProps<'ul'> {
variant?: 'default' | 'uploading' | 'failed'
items: AttachmentItem[]
onRemove?: (id: string) => void
onRetry?: (id: string) => void
}
function Attachments({
className,
items,
onRemove,
onRetry,
variant = 'default',
...props
}: AttachmentsProps) {
return (
<ul
aria-label="Attachments"
data-slot="attachments"
className={cn(
'grid gap-3 font-sans text-sm font-[450] tracking-[-0.05px] text-muted-foreground sm:grid-cols-2',
className,
)}
{...props}
>
{items.map((attachment) => {
const item = {
...attachment,
status:
attachment.status ??
(variant === 'failed' ? 'error' : variant === 'uploading' ? 'uploading' : 'ready'),
}
return (
<li
className="relative flex min-w-0 items-center gap-3 overflow-hidden rounded-[16px] bg-card p-3 shadow-card transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:scale-[0.97] starting:opacity-0 motion-reduce:starting:scale-100"
key={item.id}
>
<AttachmentPreview item={item} />
<div className="min-w-0 flex-1">
<p className="truncate card-heading leading-5 text-muted-foreground">{item.name}</p>
<p
className={cn(
'mt-0.5 text-xs leading-5 font-[450] text-muted-foreground',
item.status === 'error' && 'text-destructive',
)}
>
{item.status === 'uploading'
? `${item.progress ?? 0}% uploaded`
: item.status === 'error'
? 'Upload failed'
: item.size}
</p>
</div>
{item.status === 'error' && (
<Button
aria-label={`Retry upload: ${item.name}`}
type="button"
onClick={() => onRetry?.(item.id)}
size="icon-xs"
variant="ghost"
>
<RotateCcwIcon />
</Button>
)}
<Button
aria-label={`Remove ${item.name}`}
type="button"
onClick={() => onRemove?.(item.id)}
size="icon-xs"
variant="ghost"
>
<XIcon />
</Button>
{item.status === 'uploading' && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-muted">
<span
className="block h-full origin-left bg-foreground transition-[scale] duration-300 ease-linear"
style={{ scale: `${(item.progress ?? 0) / 100} 1` }}
/>
</span>
)}
</li>
)
})}
</ul>
)
}
function AttachmentPreview({ item }: { item: AttachmentItem }) {
if (item.kind === 'image' && item.url) {
return (
<img
alt=""
className="size-10 shrink-0 rounded-md object-cover"
height={40}
src={item.url}
width={40}
/>
)
}
const kind = item.kind === 'pdf' || /\.pdf$/i.test(item.name) ? 'pdf' : (item.kind ?? 'file')
const Icon =
kind === 'image' ? ImageIcon : kind === 'document' || kind === 'pdf' ? FileTextIcon : FileIcon
const colors = {
image: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
document: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
pdf: 'bg-red-500/10 text-red-600 dark:text-red-400',
file: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
}
return (
<span
aria-hidden="true"
className={cn(
'flex size-10 shrink-0 flex-col items-center justify-center gap-0.5 rounded-md',
colors[kind],
)}
>
<Icon className="size-4" />
{kind === 'pdf' && (
<span className="text-[8px] leading-none font-semibold tracking-wide">PDF</span>
)}
</span>
)
}
export { Attachments }
export type { AttachmentItem, AttachmentsProps }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "uploading" | "failed" | = default | Sets the attachment state. Individual item status values override this variant. |
| items | AttachmentItem[] | = — | Provides files and their transfer state. |
| onRemove | (id: string) => void | = — | Removes an attachment. |
| onRetry | (id: string) => void | = — | Retries a failed upload. |