Attachment
A file chip with a thumbnail or icon, a name, a line of detail and actions.
Vibra draws the chip as the panel recipe — card plane and hairline ring — instead of a bordered box; an idle slot is a dashed outline at --input contrast, and a failed one rings in the danger tone. The actions are Vibra's ghost icon buttons. A working title shimmers only for a reader who has not asked for less motion, the stretched AttachmentTrigger wears the kit's focus ring on the chip's corners, and the media sizes only its own icons, so a ProductArt picture inside it keeps its drawing's size.
Install
npx shadcn@latest add @vibra/attachmentNeeds the @vibra registry in your components.json — set it up once.
Examples
import { FileSpreadsheetIcon, FileTextIcon, RotateCwIcon, XIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
import { Spinner } from "@/components/ui/spinner"
export default function AttachmentDemo() {
return (
// A wrapping list here; AttachmentGroup is the one-row strip that scrolls sideways.
<div role="list" aria-label="Attachments" className="flex w-full max-w-xl flex-wrap justify-center gap-2">
<Attachment role="listitem">
<AttachmentMedia>
<FileTextIcon aria-hidden="true" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>q3-board-deck.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 1.2 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove q3-board-deck.pdf">
<XIcon aria-hidden="true" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment role="listitem" state="uploading">
<AttachmentMedia>
<Spinner aria-hidden="true" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>revenue-by-region.csv</AttachmentTitle>
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment role="listitem" state="error">
<AttachmentMedia>
<FileSpreadsheetIcon aria-hidden="true" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>churn-model.xlsx</AttachmentTitle>
<AttachmentDescription>Larger than 10 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Retry churn-model.xlsx">
<RotateCwIcon aria-hidden="true" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
)
}Every state
Idle, uploading, processing, failed and done, each said in words as well as drawn.
import { FileSpreadsheetIcon, FileTextIcon, RotateCwIcon, XIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
import { Spinner } from "@/components/ui/spinner"
const FILES = [
{ state: "idle", name: "board-minutes-q3.docx", detail: "Ready to upload", icon: FileTextIcon, actions: ["Remove"] },
{ state: "uploading", name: "revenue-by-region.csv", detail: "Uploading · 38%", icon: null, actions: ["Cancel"] },
{ state: "processing", name: "ledger-september.xlsx", detail: "Checking 12,480 rows", icon: FileSpreadsheetIcon, actions: [] },
{ state: "error", name: "signed-contract.pdf", detail: "14.2 MB — the limit is 10 MB", icon: FileTextIcon, actions: ["Retry", "Remove"] },
{ state: "done", name: "q3-board-deck.pdf", detail: "PDF · 1.2 MB", icon: FileTextIcon, actions: ["Remove"] },
] as const
// Every state a file passes through, each one said in words — a reader who
// can't see the dashed edge, the shimmer or the red still hears where the
// file is. The upload shows a spinner and the check a shimmer; both stop for
// a reader who asked for less motion.
export default function AttachmentStates() {
return (
<ul aria-label="Files for the Q3 close" className="grid w-full max-w-xl gap-2 sm:grid-cols-2">
{FILES.map((file) => (
<li key={file.name} className="flex">
<Attachment state={file.state} size="sm" className="w-full">
<AttachmentMedia>{file.icon ? <file.icon aria-hidden="true" /> : <Spinner aria-hidden="true" />}</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{file.name}</AttachmentTitle>
<AttachmentDescription>{file.detail}</AttachmentDescription>
</AttachmentContent>
{file.actions.length > 0 ? (
<AttachmentActions>
{file.actions.map((action) => (
<AttachmentAction key={action} aria-label={`${action} ${file.name}`}>
{action === "Retry" ? <RotateCwIcon aria-hidden="true" /> : <XIcon aria-hidden="true" />}
</AttachmentAction>
))}
</AttachmentActions>
) : null}
</Attachment>
</li>
))}
</ul>
)
}Image tiles
Pictures as tiles on a strip that scrolls and snaps; the whole tile opens the file.
import { XIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment"
import { ProductArt } from "@/components/ui/product-art"
const PHOTOS = [
{ product: "Walnut Desk Organizer", file: "desk-organizer-front.png", size: "820 KB" },
{ product: "Brass Desk Lamp", file: "desk-lamp-lit.png", size: "1.1 MB" },
{ product: "Ceramic Mug", file: "mug-pair.png", size: "640 KB" },
{ product: "Linen Notebook", file: "notebook-open.png", size: "930 KB" },
]
// Pictures as tiles, on a strip that scrolls sideways and snaps to each one.
// The whole tile opens the file — AttachmentTrigger stretches over it — while
// the remove button sits above the trigger and names what it removes.
export default function AttachmentImages() {
return (
<AttachmentGroup role="list" aria-label="Photos for the store listing" className="w-full max-w-xl">
{PHOTOS.map((photo) => (
<Attachment key={photo.file} role="listitem" orientation="vertical">
<AttachmentTrigger render={<a href={`/files/${photo.file}`} />} aria-label={`Open ${photo.file}`} />
<AttachmentMedia variant="image">
<ProductArt name={photo.product} className="size-full rounded-none" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{photo.file}</AttachmentTitle>
<AttachmentDescription>PNG · {photo.size}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction variant="secondary" aria-label={`Remove ${photo.file}`}>
<XIcon aria-hidden="true" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
)
}Sizes
One file at default, sm and xs, each where it is used.
import { FileTextIcon } from "lucide-react"
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
const SIZES = [
{ size: "default", where: "A file panel" },
{ size: "sm", where: "A list of uploads" },
{ size: "xs", where: "A message composer" },
] as const
// One file at each size, each where it is used. xs drops the second line: at
// that height the name alone has to carry the chip.
export default function AttachmentSizes() {
return (
<dl className="grid w-full max-w-md grid-cols-[auto_1fr] items-center gap-x-4 gap-y-3">
{SIZES.map(({ size, where }) => (
<div key={size} className="contents">
<dt className="flex flex-col text-xs text-muted-foreground">
<span className="font-mono text-foreground">{size}</span>
{where}
</dt>
<dd>
<Attachment size={size}>
<AttachmentMedia>
<FileTextIcon aria-hidden="true" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>q3-board-deck.pdf</AttachmentTitle>
{size === "xs" ? null : <AttachmentDescription>PDF · 1.2 MB</AttachmentDescription>}
</AttachmentContent>
</Attachment>
</dd>
</div>
))}
</dl>
)
}In a message composer
Files waiting to go with a message. Removing one hands the focus to the next, then to the message.
"use client"
import * as React from "react"
import { FileTextIcon, PaperclipIcon, SendIcon, XIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
import { Button } from "@/components/ui/button"
import { ProductArt } from "@/components/ui/product-art"
import { Textarea } from "@/components/ui/textarea"
const FILES = [
{ id: "deck", name: "q3-board-deck.pdf" },
{ id: "lamp", name: "desk-lamp-lit.png", picture: "Brass Desk Lamp" },
{ id: "notes", name: "renewal-notes.pdf" },
]
// Files waiting to go with a message, as xs chips above the box; a picture
// shows as its own thumbnail with no text beside it. Removing one hands the
// focus to the next chip's remove button, or to the message once none is left.
export default function AttachmentComposer() {
const [files, setFiles] = React.useState(FILES)
const [removed, setRemoved] = React.useState("")
const message = React.useRef<HTMLTextAreaElement>(null)
const chips = React.useRef(new Map<string, HTMLButtonElement>())
function remove(index: number) {
const file = files[index]
const next = files.filter((item) => item.id !== file.id)
setFiles(next)
setRemoved(`Removed ${file.name}.`)
const neighbour = next[index] ?? next[index - 1]
requestAnimationFrame(() => (neighbour ? chips.current.get(neighbour.id) : message.current)?.focus())
}
return (
<div className="flex w-full max-w-md flex-col gap-2 rounded-xl border border-input bg-card p-2 has-[textarea:focus-visible]:focus-outline">
{files.length > 0 ? (
<ul aria-label="Attached files" className="flex flex-wrap gap-1.5">
{files.map((file, index) => (
<li key={file.id}>
<Attachment size="xs" className={file.picture ? "min-w-0" : undefined}>
<AttachmentMedia variant={file.picture ? "image" : "icon"}>
{file.picture ? <ProductArt name={file.picture} className="size-full rounded-none" /> : <FileTextIcon aria-hidden="true" />}
</AttachmentMedia>
{file.picture ? <span className="sr-only">{file.name}</span> : (
<AttachmentContent>
<AttachmentTitle className="max-w-32">{file.name}</AttachmentTitle>
</AttachmentContent>
)}
<AttachmentActions>
<AttachmentAction
ref={(node) => void (node ? chips.current.set(file.id, node) : chips.current.delete(file.id))}
aria-label={`Remove ${file.name}`}
onClick={() => remove(index)}
>
<XIcon aria-hidden="true" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</li>
))}
</ul>
) : null}
<Textarea ref={message} aria-label="Message to Hiro Tanaka" rows={2} defaultValue="Here's the deck and the lamp shot for Friday." className="min-h-0 resize-none border-0 bg-transparent p-1 shadow-none focus-visible:outline-none" />
<div className="flex items-center justify-between">
<Button variant="ghost" size="icon-sm" aria-label="Attach a file">
<PaperclipIcon aria-hidden="true" />
</Button>
<span role="status" aria-live="polite" className="sr-only">
{removed}
</span>
<Button size="sm">
<SendIcon data-icon="inline-start" aria-hidden="true" />
Send
</Button>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| state | "idle" | "uploading" | "processing" | "error" | "done" | "done" | uploading and processing shimmer the title; error turns the media and detail to the danger tone. |
| size | "default" | "sm" | "xs" | "default" | The chip's padding and the media tile's size. |
| orientation | "horizontal" | "vertical" | "horizontal" | A row with the media at the start, or a tile with the media on top. |
| AttachmentMedia.variant | "icon" | "image" | "icon" | An icon on the muted tile, or an image cropped to fill it. |
Dependencies
Source
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap panel text-card-foreground transition-colors has-[:focus-visible]:focus-outline has-[>a,>button]:hover:bg-muted/50 data-[state=error]:ring-danger/40 data-[state=idle]:ring-0 data-[state=idle]:outline-1 data-[state=idle]:outline-dashed data-[state=idle]:-outline-offset-1 data-[state=idle]:outline-input",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
// The icon sizes reach the media's own children only: a picture drawn with
// svg inside it — ProductArt, the kit's product images — keeps its drawing's
// size instead of shrinking to an icon's 24px.
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&>svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&>svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
// The shimmer loops, so no duration token can stop it: it is drawn
// only for readers who have not asked for less motion, by the OS
// setting (motion-safe) or by the kit's switch (outside
// [data-motion="reduced"]). A stopped shimmer would still paint its
// gradient over the words, so it is not stopped but never drawn.
// (The stylesheet's own reduced-motion rule names .shimmer alone, and
// a variant-prefixed class never matched it.)
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:motion-safe:not-in-data-[motion=reduced]:shimmer group-data-[state=uploading]/attachment:motion-safe:not-in-data-[motion=reduced]:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:end-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
render,
type,
...props
}: useRender.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? type : (type ?? "button"),
// Stretched over the chip, it draws the kit's focus ring on the chip's
// own corners; the chip's half-strength focus-within ring alone was
// all a keyboard reader had to go on.
className: cn("absolute inset-0 z-10 rounded-[inherit] focus-ring", className),
},
props
),
render,
state: {
slot: "attachment-trigger",
},
})
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}