Kits
ChatComposerAttachment
Composer attachment rail with file previews, upload progress, and removal.
Composition
Attachment rail
- <ChatComposerAttachments>
- <ChatComposerAttachment>
- <ChatComposerAttachmentPreview />
- <ChatComposerAttachmentContent>
- <ChatComposerAttachmentTitle />
- <ChatComposerAttachmentDescription />
- <ChatComposerAttachmentRemove />
- <ChatComposerAttachmentProgress />
- <ChatComposerAttachment>
File intake
The area carries drop and paste, so a screenshot pasted into the composer and a file dropped on it reach the same policy as the file dialog. Open that dialog from the toolbar with the dropzone's open(). Inside the shell, the overlay takes the shell's radius, and each fileRejections entry renders as an error attachment dismissed with removeRejection().
- <Dropzone>
- <DropzoneInput />
- <DropzoneArea>
- <ChatComposer>
- <ChatComposerShell>
- composer anatomy
- <DropzoneOverlay />
- <ChatComposerShell>
- <ChatComposer>
Installation
Use this command to install the component.
npx shadcn@latest add https://control-ui.dev/r/chat-composer-attachment.jsonUsage
"use client";import { useChat } from "@mastra/react";import { ArrowUp, Paperclip } from "lucide-react";import { ChatComposer, ChatComposerShell, ChatComposerSubmit, ChatComposerTextarea, ChatComposerToolbar, ChatComposerTools,} from "@/components/control-ui/chat-composer";import { ChatComposerAttachment, ChatComposerAttachments } from "@/components/control-ui/chat-composer-attachment";import { Button } from "@/components/control-ui/ui/button";import { Dropzone, DropzoneArea, DropzoneInput, DropzoneOverlay, type DropzonePolicy, useDropzoneContext,} from "@/components/control-ui/ui/dropzone";const policy: DropzonePolicy = { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], "application/pdf": [".pdf"] }, maxSize: 10 * 1024 * 1024,};function readBase64(file: File) { return new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const dataUrl = String(reader.result); resolve(dataUrl.slice(dataUrl.indexOf(",") + 1)); }; reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); });}async function toFilePart(file: File) { return { type: "file" as const, data: await readBase64(file), mimeType: file.type, filename: file.name };}export function Example({ agentId }: { agentId: string }) { return ( <Dropzone policy={policy}> <DropzoneInput /> <AttachmentComposer agentId={agentId} /> </Dropzone> );}function AttachmentComposer({ agentId }: { agentId: string }) { const { sendMessage, isRunning } = useChat({ agentId }); const dropzone = useDropzoneContext(); async function sendWithFiles(text: string) { const content = await Promise.all(dropzone.value.map(toFilePart)); await sendMessage({ message: text, coreUserMessages: content.length > 0 ? [{ role: "user", content }] : undefined }); } return ( <DropzoneArea> <ChatComposer state={isRunning ? "submitting" : "idle"} allowEmptySubmit={dropzone.value.length > 0} onSubmit={async ({ value, clear }) => { await sendWithFiles(value); clear(); dropzone.reset(); }} > <ChatComposerShell> <ChatComposerAttachments> {dropzone.value.map((file) => ( <ChatComposerAttachment key={`${file.name}-${file.lastModified}-${file.size}`} file={file} onRemove={() => dropzone.removeFile(file)} /> ))} {dropzone.fileRejections.map(({ file, errors }) => ( <ChatComposerAttachment key={`rejected-${file.name}-${file.lastModified}-${file.size}`} file={file} status="error" description={errors[0]?.message} onRemove={() => dropzone.removeRejection(file)} /> ))} </ChatComposerAttachments> <ChatComposerTextarea placeholder="Ask anything — paste a screenshot or drop a file" /> <ChatComposerToolbar> <ChatComposerTools> <Button variant="ghost" size="xs" iconOnly aria-label="Add files" onClick={dropzone.open}> <Paperclip className="size-4" /> </Button> </ChatComposerTools> <ChatComposerSubmit size="sm" iconOnly aria-label="Send message"> <ArrowUp className="size-4" /> </ChatComposerSubmit> </ChatComposerToolbar> <DropzoneOverlay>Drop to attach</DropzoneOverlay> </ChatComposerShell> </ChatComposer> </DropzoneArea> );}Dependencies
Raw code
Installed component source and support files
"use client";import { XIcon } from "lucide-react";import type { ComponentProps, CSSProperties, MouseEvent, ReactNode } from "react";import { createContext, useContext, useLayoutEffect, useState } from "react";import type { ButtonKnobStyle } from "@/components/control-ui/knob-contracts/button-knobs";import type { ChatComposerAttachmentKnobStyle } from "@/components/control-ui/knob-contracts/chat-composer-attachment-knobs";import { cn } from "@/components/control-ui/lib/cn";import { Button } from "@/components/control-ui/ui/button";import { LiveStatus } from "@/components/control-ui/ui/live-status";import { ScrollArea } from "@/components/control-ui/ui/scroll-area";import { Spinner } from "@/components/control-ui/ui/spinner";export type ChatComposerAttachmentKind = "image" | "pdf" | "spreadsheet" | "document" | "archive" | "audio" | "video" | "file";export type ChatComposerAttachmentStatus = "idle" | "uploading" | "uploaded" | "error";export type ChatComposerAttachmentVariant = "auto" | "preview" | "file";type ChatComposerAttachmentContextValue = { name: string; type?: string; description?: ReactNode; status: ChatComposerAttachmentStatus; progress?: number; previewUrl?: string; kind: ChatComposerAttachmentKind; variant: Exclude<ChatComposerAttachmentVariant, "auto">; extension: string; onRemove?: () => void; removeLabel?: string; errorLabel: string;};const ChatComposerAttachmentContext = createContext<ChatComposerAttachmentContextValue | null>(null);function useChatComposerAttachmentContext() { const context = useContext(ChatComposerAttachmentContext); if (!context) throw new Error("ChatComposerAttachment compound components must be rendered inside <ChatComposerAttachment>."); return context;}function extensionFromName(name: string) { const match = name.match(/\.([a-z0-9]+)$/i); return match?.[1]?.toLowerCase() ?? "";}function kindFromAttachment(type: string | undefined, name: string): ChatComposerAttachmentKind { const mime = type?.toLowerCase() ?? ""; const extension = extensionFromName(name); if (mime.startsWith("image/") || ["avif", "gif", "heic", "jpeg", "jpg", "png", "webp"].includes(extension)) return "image"; if (mime === "application/pdf" || extension === "pdf") return "pdf"; if ( mime.includes("spreadsheet") || mime.includes("excel") || mime.includes("csv") || ["csv", "numbers", "ods", "tsv", "xls", "xlsx"].includes(extension) ) { return "spreadsheet"; } if (mime.startsWith("audio/") || ["aac", "flac", "m4a", "mp3", "ogg", "wav"].includes(extension)) return "audio"; if (mime.startsWith("video/") || ["avi", "mov", "mp4", "webm"].includes(extension)) return "video"; if (mime.includes("zip") || ["7z", "gz", "rar", "tar", "zip"].includes(extension)) return "archive"; if (mime.includes("document") || mime.includes("text") || ["doc", "docx", "md", "rtf", "txt"].includes(extension)) return "document"; return "file";}function resolveVariant( variant: ChatComposerAttachmentVariant, kind: ChatComposerAttachmentKind, status: ChatComposerAttachmentStatus, previewUrl: string | undefined,): Exclude<ChatComposerAttachmentVariant, "auto"> { if (variant !== "auto") return variant; if (status === "error") return "file"; return previewUrl || kind === "image" ? "preview" : "file";}function useObjectUrl(blob: Blob | undefined) { const [objectUrl, setObjectUrl] = useState<{ blob: Blob; url: string }>(); useLayoutEffect(() => { if (!blob) return; const url = URL.createObjectURL(blob); setObjectUrl({ blob, url }); return () => URL.revokeObjectURL(url); }, [blob]); return blob && objectUrl?.blob === blob ? objectUrl.url : undefined;}function progressValue(progress: number | undefined) { if (typeof progress !== "number" || Number.isNaN(progress)) return undefined; return Math.min(100, Math.max(0, progress));}function fallbackLabel(kind: ChatComposerAttachmentKind, extension: string) { if (extension && extension.length <= 4) return extension.toUpperCase(); const labels: Record<ChatComposerAttachmentKind, string> = { image: "IMG", pdf: "PDF", spreadsheet: "XLS", document: "DOC", archive: "ZIP", audio: "AUD", video: "VID", file: "FILE", }; return labels[kind];}function defaultDescription(context: ChatComposerAttachmentContextValue) { const { progress } = context; if (context.description) return context.description; if (context.status === "uploading") return progress === undefined ? "Uploading…" : `Uploading ${Math.round(progress)}%`; if (context.status === "error") return context.errorLabel; return fallbackLabel(context.kind, context.extension);}function defaultUploadedLabel(name: string) { return `${name} uploaded`;}function defaultUploadFailedLabel(name: string) { return `${name} couldn't upload`;}function useUploadAnnouncement( status: ChatComposerAttachmentStatus, name: string, uploadedLabel: (name: string) => string, uploadFailedLabel: (name: string) => string,) { const [previousStatus, setPreviousStatus] = useState(status); const [announcement, setAnnouncement] = useState(""); if (status !== previousStatus) { setPreviousStatus(status); const labelFor: Partial<Record<ChatComposerAttachmentStatus, (name: string) => string>> = { uploaded: uploadedLabel, error: uploadFailedLabel, }; setAnnouncement(labelFor[status]?.(name) ?? ""); } return announcement;}export type ChatComposerAttachmentsProps = ComponentProps<"div"> & { label?: string; viewportClassName?: string;} & { style?: CSSProperties & ChatComposerAttachmentKnobStyle };export function ChatComposerAttachments({ label = "Attachments", className, viewportClassName, children, ...props}: ChatComposerAttachmentsProps) { return ( <div data-control-ui="chat-composer-attachments" data-control-family="chat-composer-attachment" data-slot="root" className={cn("min-w-0", className)} {...props} > <ScrollArea data-control-ui="chat-composer-attachments" data-slot="scroll" lockAxis="y" mask className="w-full" viewportClassName={viewportClassName} > <ul aria-label={label} data-control-ui="chat-composer-attachments" data-control-family="chat-composer-attachment" data-slot="list" className="flex w-max min-w-full list-none" > {children} </ul> </ScrollArea> </div> );}type ChatComposerAttachmentSource = { file: File; name?: string } | { file?: undefined; name: string };export type ChatComposerAttachmentProps = Omit<ComponentProps<"li">, "style"> & ChatComposerAttachmentSource & { type?: string; description?: ReactNode; status?: ChatComposerAttachmentStatus; progress?: number; previewUrl?: string; kind?: ChatComposerAttachmentKind; variant?: ChatComposerAttachmentVariant; onRemove?: () => void; removeLabel?: string; errorLabel?: string; uploadedLabel?: (name: string) => string; uploadFailedLabel?: (name: string) => string; style?: CSSProperties & ChatComposerAttachmentKnobStyle; };export function ChatComposerAttachment({ file, name: nameInput, type: typeInput, description, status = "idle", progress: progressInput, previewUrl: previewUrlInput, kind, variant = "auto", onRemove, removeLabel, errorLabel = "Couldn't upload. Remove and try again.", uploadedLabel = defaultUploadedLabel, uploadFailedLabel = defaultUploadFailedLabel, className, children, "aria-label": ariaLabel, ...props}: ChatComposerAttachmentProps) { const name = nameInput ?? file?.name ?? ""; const type = typeInput ?? file?.type; const resolvedKind = kind ?? kindFromAttachment(type, name); const resolvedVariant = resolveVariant(variant, resolvedKind, status, previewUrlInput); const filePreviewUrl = useObjectUrl(resolvedVariant === "preview" && !previewUrlInput ? file : undefined); const previewUrl = previewUrlInput ?? filePreviewUrl; const extension = extensionFromName(name); const progress = progressValue(progressInput); const announcement = useUploadAnnouncement(status, name, uploadedLabel, uploadFailedLabel); return ( <ChatComposerAttachmentContext.Provider value={{ name, type, description, status, progress, previewUrl, kind: resolvedKind, variant: resolvedVariant, extension, onRemove, removeLabel, errorLabel, }} > <li aria-label={ariaLabel ?? name} data-control-ui="chat-composer-attachment" data-control-family="chat-composer-attachment" data-slot="root" data-surface="panel" data-kind={resolvedKind} data-state={status} data-variant={resolvedVariant} className={cn("relative isolate flex shrink-0 items-center overflow-hidden", className)} {...props} > {children ?? ( <> <ChatComposerAttachmentPreview /> {resolvedVariant === "file" && ( <ChatComposerAttachmentContent> <ChatComposerAttachmentTitle /> <ChatComposerAttachmentDescription /> </ChatComposerAttachmentContent> )} <ChatComposerAttachmentRemove /> <ChatComposerAttachmentProgress /> </> )} <LiveStatus message={announcement} /> </li> </ChatComposerAttachmentContext.Provider> );}export type ChatComposerAttachmentPreviewProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatComposerAttachmentKnobStyle;};export function ChatComposerAttachmentPreview({ className, children, ...props }: ChatComposerAttachmentPreviewProps) { const context = useChatComposerAttachmentContext(); const label = fallbackLabel(context.kind, context.extension); return ( <div data-control-ui="chat-composer-attachment" data-control-family="chat-composer-attachment" data-slot="preview" className={cn("relative flex shrink-0 items-center justify-center overflow-hidden", className)} {...props} > {children ?? (context.previewUrl ? ( <img src={context.previewUrl} alt={context.name} loading="lazy" decoding="async" className="h-full w-auto max-w-full object-contain" /> ) : ( <span data-control-ui="chat-composer-attachment" data-control-family="chat-composer-attachment" data-slot="preview-label" className="uppercase" > {label} </span> ))} </div> );}export type ChatComposerAttachmentContentProps = ComponentProps<"div"> & { style?: CSSProperties & ChatComposerAttachmentKnobStyle };export function ChatComposerAttachmentContent({ className, ...props }: ChatComposerAttachmentContentProps) { return ( <div data-control-ui="chat-composer-attachment" data-control-family="chat-composer-attachment" data-slot="content" className={cn("min-w-0 flex-1", className)} {...props} /> );}export type ChatComposerAttachmentTitleProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatComposerAttachmentKnobStyle;};export function ChatComposerAttachmentTitle({ className, children, ...props }: ChatComposerAttachmentTitleProps) { const { name } = useChatComposerAttachmentContext(); const content = children ?? name; return ( <div data-control-ui="chat-composer-attachment" data-control-family="chat-composer-attachment" data-slot="title" title={typeof content === "string" ? content : undefined} className={cn("truncate", className)} {...props} > {content} </div> );}export type ChatComposerAttachmentDescriptionProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatComposerAttachmentKnobStyle;};export function ChatComposerAttachmentDescription({ className, children, ...props }: ChatComposerAttachmentDescriptionProps) { const context = useChatComposerAttachmentContext(); const content = children ?? defaultDescription(context); return ( <div data-control-ui="chat-composer-attachment" data-control-family="chat-composer-attachment" data-slot="description" title={typeof content === "string" ? content : undefined} className={cn("truncate", className)} data-state={context.status} {...props} > {content} </div> );}export type ChatComposerAttachmentRemoveProps = Omit<ComponentProps<typeof Button>, "style"> & { style?: CSSProperties & ButtonKnobStyle & ChatComposerAttachmentKnobStyle;};export function ChatComposerAttachmentRemove({ className, onClick, children, "aria-label": ariaLabel, ...props}: ChatComposerAttachmentRemoveProps) { const { name, onRemove, removeLabel } = useChatComposerAttachmentContext(); if (!onRemove) return null; function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (!event.defaultPrevented) onRemove?.(); } return ( <Button data-control-ui="chat-composer-attachment" data-chat-composer-attachment-remove="true" iconOnly data-slot="remove" aria-label={ariaLabel ?? removeLabel ?? `Remove ${name}`} size="xs" variant="solid" className={cn("absolute end-1 top-1 z-10", className)} onClick={handleClick} {...props} > {children ?? <XIcon aria-hidden="true" className="size-3" />} </Button> );}export type ChatComposerAttachmentProgressProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatComposerAttachmentKnobStyle;};export function ChatComposerAttachmentProgress({ className, children, ...props }: ChatComposerAttachmentProgressProps) { const { name, status, progress } = useChatComposerAttachmentContext(); if (status !== "uploading") return null; return ( <div role="progressbar" aria-label={`Uploading ${name}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress} data-control-ui="chat-composer-attachment" data-control-family="chat-composer-attachment" data-slot="progress" className={cn("pointer-events-none absolute inset-0 z-1 grid place-items-center", className)} {...props} > {children ?? ( <> <div data-control-ui="chat-composer-attachment" data-control-family="chat-composer-attachment" data-slot="progress-indicator" className="absolute inset-y-0 end-0" style={{ inlineSize: `${100 - (progress ?? 0)}%` }} /> {progress === undefined && <Spinner aria-hidden="true" className="relative" size="xs" />} </> )} </div> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-chat-composer-attachment-* · 10 knobsHow the cascade resolves--cui-chat-composer-attachment-radius<length-percentage>var(--radius-popover)--cui-chat-composer-attachment-height<length-percentage>calc(var(--spacing) * 14)--cui-chat-composer-attachment-list-padding<length-percentage>calc(var(--spacing) * 3)--cui-chat-composer-attachment-background<color>var(--popover)--cui-chat-composer-attachment-border-color<color>var(--control-rim)--cui-chat-composer-attachment-shadow*inset 0 0 0 var(--control-rim-width) var(--cui-chat-composer-attachment-border-color), var(--shadow-sm)--cui-chat-composer-attachment-backdrop-filter*blur(var(--backdrop-blur-popover))--cui-chat-composer-attachment-progress-background<color>transparent--cui-chat-composer-attachment-progress-foreground<color>oklch(from var(--background) l c h / var(--overlay-opacity))--cui-chat-composer-attachment-image-outline-color<color>var(--image-outline)