Attachment
File and image attachment cards for chat composers and message threads.
Plus Registry
This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/attachment.
Tracks Shadcn
Layout primitives track shadcn ui/attachment. The Plus path @f-ui-plus/attachment re-exports those parts for installers.
Attachment is the presentational card for files and images in chat — composer previews and message-thread chips. Pair it with Prompt Composer for the input strip and Message for turns. Own pick/upload with File Upload (useFileUpload / host state) and map items into Attachment children — do not mount the File Upload shell inside chat.
When To Use
- Composer attachment previews above the textarea (often inside
AttachmentGroup). - File / image chips in a message thread next to Bubble content.
- Mirror host upload lifecycle via
state(idle→uploading→processing→done/error); percent and errors live inAttachmentDescriptiontext. - Use File Upload for form fields, dropzones, and picture-card / avatar list UI — not as the chat card chrome.
Features
| Area | Behavior |
|---|---|
| States | idle, uploading, processing, error, done on the root (data-state) |
| Title shimmer | AttachmentTitle pulses while uploading or processing |
| Media | Icon or image via AttachmentMedia variant |
| Orientation | Horizontal file row or vertical image card |
| Group | AttachmentGroup — horizontal scroll, snap, edge fade |
| Trigger | Full-card hit target under actions (asChild or native button) |
Installing
Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/attachmentFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/attachmentFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/attachmentFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/attachmentregistryDependencies: bare shadcn attachment, plus button (for AttachmentAction). No runtime npm packages beyond the app’s existing stack. File Upload is not a registry dependency — install it separately when the host owns upload lifecycle.
Usage
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/f-ui/attachment/attachment";
<Attachment state="done" size="sm">
<AttachmentMedia variant="icon">{/* icon */}</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>brief.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove brief.pdf" onClick={onRemove} />
</AttachmentActions>
</Attachment>Examples
Default Card
A single done file card with a remove action. Icon-only actions need an aria-label (and a Tooltip in production chrome).
"use client";
import { XIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/f-ui/attachment/attachment";
import { FileTypeIcon } from "@/components/f-ui/file-type-icon/file-type-icon";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function AttachmentDemo() {
return (
<TooltipProvider>
<Attachment className="max-w-xs" state="done">
<AttachmentMedia variant="icon">
<FileTypeIcon fileName="quarterly-report.pdf" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>quarterly-report.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<Tooltip>
<TooltipTrigger asChild>
<AttachmentAction aria-label="Remove quarterly-report.pdf">
<XIcon />
</AttachmentAction>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</AttachmentActions>
</Attachment>
</TooltipProvider>
);
}Upload States
Stack of lifecycle states. Title shimmers while uploading or processing; error tints the card border.
"use client";
import { XIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
type AttachmentState,
} from "@/components/f-ui/attachment/attachment";
import { FileTypeIcon } from "@/components/f-ui/file-type-icon/file-type-icon";
const STATES: {
state: AttachmentState;
name: string;
description: string;
}[] = [
{
state: "idle",
name: "notes.md",
description: "Ready to upload",
},
{
state: "uploading",
name: "brief.pdf",
description: "Uploading · 42%",
},
{
state: "processing",
name: "scan.png",
description: "Processing…",
},
{
state: "error",
name: "corrupt.zip",
description: "Upload failed",
},
{
state: "done",
name: "spec.docx",
description: "DOCX · 180 KB",
},
];
export function AttachmentStatesDemo() {
return (
<div className="flex max-w-sm flex-col gap-2">
{STATES.map((item) => (
<Attachment key={item.state} state={item.state} size="sm">
<AttachmentMedia variant="icon">
<FileTypeIcon fileName={item.name} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{item.name}</AttachmentTitle>
<AttachmentDescription>{item.description}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label={`Remove ${item.name}`}>
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</div>
);
}Group And Images
Horizontal AttachmentGroup with icon cards and a vertical image card. Scroll sideways when the strip overflows.
"use client";
import { XIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/f-ui/attachment/attachment";
import { FileTypeIcon } from "@/components/f-ui/file-type-icon/file-type-icon";
const PLACEHOLDER_IMG =
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<rect width="96" height="96" fill="#d4d4d8"/>
<circle cx="34" cy="34" r="10" fill="#a1a1aa"/>
<path d="M12 78 L40 48 L58 64 L72 52 L84 78 Z" fill="#a1a1aa"/>
</svg>`,
);
export function AttachmentGroupDemo() {
return (
<AttachmentGroup aria-label="Attachments" className="max-w-md pb-1">
<Attachment state="done" size="sm" className="w-56">
<AttachmentMedia variant="icon">
<FileTypeIcon fileName="roadmap.pdf" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>roadmap.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 1.1 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove roadmap.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment
state="done"
size="sm"
orientation="vertical"
className="w-36"
>
<AttachmentMedia variant="image">
<img src={PLACEHOLDER_IMG} alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>moodboard.png</AttachmentTitle>
<AttachmentDescription>PNG · 640 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions className="absolute top-1 right-1 ml-0">
<AttachmentAction aria-label="Remove moodboard.png">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="uploading" size="sm" className="w-56">
<AttachmentMedia variant="icon">
<FileTypeIcon fileName="transcript.txt" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>transcript.txt</AttachmentTitle>
<AttachmentDescription>Uploading · 18%</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove transcript.txt">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</AttachmentGroup>
);
}Trigger Preview
Full-card AttachmentTrigger opens a dialog via DialogTrigger (asChild). Remove stays on AttachmentActions above the trigger hit target.
"use client";
import { XIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/f-ui/attachment/attachment";
import { FileTypeIcon } from "@/components/f-ui/file-type-icon/file-type-icon";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function AttachmentTriggerDemo() {
return (
<TooltipProvider>
<Dialog>
<Attachment className="max-w-xs" state="done">
<AttachmentMedia variant="icon">
<FileTypeIcon fileName="design-brief.pdf" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>design-brief.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 890 KB · click to preview</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<Tooltip>
<TooltipTrigger asChild>
<AttachmentAction aria-label="Remove design-brief.pdf">
<XIcon />
</AttachmentAction>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</AttachmentActions>
<AttachmentTrigger asChild>
<DialogTrigger aria-label="Preview design-brief.pdf" />
</AttachmentTrigger>
</Attachment>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>design-brief.pdf</DialogTitle>
<DialogDescription>
Host preview surface — Attachment only provides the card chrome and
full-card hit target. Actions stay separately clickable above the
trigger.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}With File Upload State
Reuse File Upload for list + transport (useFileUpload / <FileUpload> as the host state machine). Map each FileUploadItem into Attachment parts — the File Upload shell (dropzone, list UI, picture-card) is not mounted in chat. Attachment only mirrors state and description copy.
// Illustrative — pure mapping, no React required
type AttachmentState = "idle" | "uploading" | "processing" | "error" | "done"
function toAttachmentState(
status: FileUploadItem["status"],
): AttachmentState | null {
if (status === "removed") return null
// processing: host may set via response flag / custom field until FU-2 lands
return status
}
function formatAttachmentDescription(item: FileUploadItem): string {
if (item.status === "uploading" && item.percent != null) {
return `Uploading · ${Math.round(item.percent)}%`
}
if (item.status === "error") {
return item.error?.message ?? "Upload failed"
}
// size / mime formatting left to host or shared util
return /* e.g. "PDF · 2.4 MB" */ ""
}Host render:
<AttachmentGroup>
{items.map((item) => {
const state = toAttachmentState(item.status)
if (!state) return null
return (
<Attachment key={item.uid} state={state} size="sm">
<AttachmentMedia variant={item.thumbUrl ? "image" : "icon"}>
{item.thumbUrl ? (
<img src={item.thumbUrl} alt="" />
) : (
<FileTypeIcon fileName={item.name} />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{item.name}</AttachmentTitle>
<AttachmentDescription>
{formatAttachmentDescription(item)}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${item.name}`}
onClick={() => remove(item.uid)}
/>
</AttachmentActions>
</Attachment>
)
})}
</AttachmentGroup>Composition
Attachment
├── AttachmentMedia variant: icon | image
├── AttachmentContent
│ ├── AttachmentTitle shimmer when state is uploading | processing
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction Button; icon-only ⇒ aria-label (+ Tooltip in demos)
└── AttachmentTrigger full-card hit target under actions
AttachmentGroup horizontal scroll + snap; edge fade when feasibleAPI Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
state | "idle" | "uploading" | "processing" | "error" | "done" | "done" | Lifecycle mirror from the host. |
size | "default" | "sm" | "xs" | "default" | Card density. |
orientation | "horizontal" | "vertical" | "horizontal" | Row layout vs stacked image card. |
className | string | — | Merged onto the root. |
children | ReactNode | — | Media, content, actions, optional trigger. |
AttachmentGroup: standard div props plus optional aria-label (sets role="group" when present).
AttachmentMedia: variant "icon" | "image" (default "icon").
AttachmentTrigger: asChild (default false) merges onto a child; otherwise a native button.
AttachmentAction: same props as shadcn Button (size="icon-xs" / variant="ghost" by default).
Slots
Presentational parts: Attachment, AttachmentMedia, AttachmentContent, AttachmentTitle, AttachmentDescription, AttachmentActions, AttachmentAction, AttachmentTrigger, AttachmentGroup. No classNames map in v1 — style via className on each part.