f-ui
Components

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 (idleuploadingprocessingdone / error); percent and errors live in AttachmentDescription text.
  • Use File Upload for form fields, dropzones, and picture-card / avatar list UI — not as the chat card chrome.

Features

AreaBehavior
Statesidle, uploading, processing, error, done on the root (data-state)
Title shimmerAttachmentTitle pulses while uploading or processing
MediaIcon or image via AttachmentMedia variant
OrientationHorizontal file row or vertical image card
GroupAttachmentGroup — horizontal scroll, snap, edge fade
TriggerFull-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/attachment
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/attachment
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/attachment
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/attachment

registryDependencies: 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).

quarterly-report.pdfPDF · 2.4 MB
"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.

notes.mdReady to upload
brief.pdfUploading · 42%
scan.pngProcessing…
corrupt.zipUpload failed
spec.docxDOCX · 180 KB
"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.

roadmap.pdfPDF · 1.1 MB
moodboard.pngPNG · 640 KB
transcript.txtUploading · 18%
"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.

design-brief.pdfPDF · 890 KB · click to preview
"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 feasible

API Reference

Props

PropTypeDefaultDescription
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.
classNamestringMerged onto the root.
childrenReactNodeMedia, 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.

On this page