14 / 122 · Messages
Attachments in a message
Files as received rather than staged: an image to open, a document with its page count.
Installation
npx shadcn@latest add "@assistant-ui/elements-message-attachment"
Usage
import { MessageAttachments } from "@/components/elements/message-attachment";
<MessageAttachments
attachments={[
{ id: "spec", name: "migration.pdf", size: "1.2 MB", kind: "document", pages: 14 },
]}
onOpen={(id) => preview(id)}
/>Props
MessageAttachments
attachments*MessageAttachmentItem[]Files carried by the message. Images render as a thumbnail row, everything else as a chip.
onOpen(id: string) => voidCalled when an attachment is opened for preview.
classNamestringExtra classes merged onto the root.
MessageAttachmentItem
id*stringStable identity for the list key and for the callbacks that report a selection.
name*stringFile name as shown.
size*stringPre-formatted size. The element never converts bytes.
kind*"image" | "document" | "file"Chooses the shape: a thumbnail button, a paged document chip, or a plain file chip.
swatchstringCSS background-image for the thumbnail, used when kind is image. A gradient stands in for a real preview.
pagesnumberPage count appended to the size, for documents.
Source
message-attachment.tsx"use client";
import { FileTextIcon, ImageIcon, PaperclipIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, mono, paper } from "./surfaces";
export interface MessageAttachmentItem {
id: string;
name: string;
size: string;
kind: "image" | "document" | "file";
pages?: number;
swatch?: string;
}
export function MessageAttachments({
attachments,
onOpen,
className,
}: {
attachments: readonly MessageAttachmentItem[];
onOpen?: (id: string) => void;
className?: string;
}) {
return (
<div className={cn("flex w-full max-w-sm flex-col gap-1.5", className)}>
{attachments.map((item) =>
item.kind === "image" ? (
<button
key={item.id}
type="button"
onClick={() => onOpen?.(item.id)}
className={cn(
paper,
"fade-in animate-in fill-mode-both group flex w-full items-center gap-3 overflow-hidden rounded-2xl p-2 text-start duration-300",
)}
>
<span
aria-hidden
className="size-12 shrink-0 rounded-xl bg-cover bg-center transition-transform duration-300 group-hover:scale-[1.04] motion-reduce:transition-none"
style={{
backgroundImage: item.swatch,
backgroundColor: "var(--color-foreground)",
}}
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5 pe-2">
<span className="text-foreground/90 truncate text-[13.5px]">
{item.name}
</span>
<span className={cn(mono, "text-foreground/35")}>
{item.size}
</span>
</span>
<ImageIcon className="text-foreground/25 me-2 size-3.5 shrink-0" />
</button>
) : (
<button
key={item.id}
type="button"
onClick={() => onOpen?.(item.id)}
className={cn(
field,
"fade-in animate-in fill-mode-both hover:bg-foreground/[0.07] flex w-full items-center gap-2.5 rounded-2xl px-3 py-2.5 text-start transition-colors duration-300",
)}
>
<span className="bg-background/70 text-foreground/45 flex size-8 shrink-0 items-center justify-center rounded-lg">
{item.kind === "document" ? (
<FileTextIcon className="size-3.5" />
) : (
<PaperclipIcon className="size-3.5" />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-foreground/90 truncate text-[13.5px]">
{item.name}
</span>
<span className={cn(mono, "text-foreground/35")}>
{item.size}
{item.pages !== undefined && ` · ${item.pages} pages`}
</span>
</span>
</button>
),
)}
</div>
);
}