AI Attachment Tray
Attachment tray that mounts above any composer: drop or paste files into thumbnail chips with upload progress rings, retry on failure, type icons, limit hints and drag-to-reorder
A standalone attachment strip, decoupled from any chat composer, that you can mount above any textarea. Drop files onto the tray, paste a screenshot, or press "+" to pick — each becomes a chip: images show a thumbnail, everything else shows a type icon derived from the MIME type or extension (PDF, document, spreadsheet, code, audio, video, archive) plus a middle-truncated file name and size. While uploading, an SVG progress ring with a percentage overlays the thumbnail; on failure the chip turns red with a retry button, and the remove × only appears on hover. Chips spring in and shrink out, and can be dragged to reorder; when the count or size limit is exceeded only a hint chip is shown and the files are never handed to onAdd.
npx shadcn@latest add https://webberui.com/r/ai-attachment-tray.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<AiAttachmentTray />
Installation
npx shadcn@latest add https://webberui.com/r/ai-attachment-tray.jsonOr, once registries are configured in components.json, install it as @webberui/ai-attachment-tray.
Usage
Controlled mode: the parent owns the list, performs the uploads, and writes progress back into files.
import * as React from "react";
import {
AiAttachmentTray,
type AiAttachmentFile,
} from "@/components/ui/ai-attachment-tray";
function Composer() {
const [files, setFiles] = React.useState<AiAttachmentFile[]>([]);
const upload = (id: string, file: File) => {
// Call your upload API and update progress from its progress events; set status to "done" on success, "error" on failure
uploadToServer(file, {
onProgress: (p) =>
setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, progress: p * 100 } : f))),
})
.then(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "done" } : f))))
.catch(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "error" } : f))));
};
return (
<div className="rounded-2xl border p-2">
<AiAttachmentTray
files={files}
maxFiles={6}
maxSizeMB={10}
accept="image/*,.pdf,.docx,.xlsx"
onAdd={(incoming) => {
const entries = incoming.map<AiAttachmentFile>((file) => ({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
status: "uploading",
progress: 0,
previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
}));
setFiles((prev) => [...prev, ...entries]);
entries.forEach((entry, i) => upload(entry.id, incoming[i]));
}}
onRemove={(id) => setFiles((prev) => prev.filter((f) => f.id !== id))}
onRetry={(id) =>
setFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, status: "uploading", progress: 0 } : f)),
)
}
onReorder={(ids) =>
setFiles((prev) =>
ids.flatMap((id) => prev.filter((f) => f.id === id)),
)
}
/>
<textarea rows={2} placeholder="Ask anything…" className="w-full bg-transparent p-2" />
</div>
);
}Omit files for uncontrolled mode: the component keeps its own list (newly added files are marked done right away and images get an automatic thumbnail), which suits the case where you only need to collect files and upload them all at once on submit.
<AiAttachmentTray
defaultFiles={[]}
onChange={(files) => console.log(files.map((f) => f.name))}
onAdd={(incoming) => console.log("files that passed validation", incoming)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
files | AiAttachmentFile[] | — | Controlled attachment list (array order is display order); when omitted the component is uncontrolled |
defaultFiles | AiAttachmentFile[] | [] | Initial attachment list in uncontrolled mode |
onChange | (files: AiAttachmentFile[]) => void | — | In uncontrolled mode, called with the full list after any change (add / remove / reorder) |
onAdd | (files: File[]) => void | — | Called when files the user dropped, pasted, or picked pass validation; in controlled mode the parent creates the entries and uploads them from here |
onRemove | (id: string) => void | — | Called when the remove button is pressed (or Delete is pressed on a chip) |
onRetry | (id: string) => void | — | Called when retry is pressed on a failed chip |
onReorder | (ids: string[]) => void | — | Called with the new id order after a drag or keyboard reorder; in controlled mode reordering is disabled when this is omitted |
onReject | (file: File, reason: AiAttachmentRejectReason) => void | — | Called when a file fails the count / size / type check |
maxFiles | number | — | Maximum number of attachments; extra files show a hint chip and are not handed to onAdd |
maxSizeMB | number | — | Maximum size per file (MB) |
accept | string | — | Accepted file types, same as the native accept (e.g. "image/*,.pdf"); also applied to drops and pastes |
compact | boolean | false | Compact mode: smaller thumbnails and a single line of text |
showSize | boolean | true | Whether to show the file size on each chip |
showAddButton | boolean | true | Whether to show the "+" add button (opens the native file picker) |
listenPaste | boolean | true | Whether to listen for document paste events; pasted content that carries files is treated like a drop |
hint | string | — | Hint text shown when the tray is empty; a shortcut for labels.hint |
labels | Partial<AiAttachmentTrayLabels> | — | Custom strings (partial overrides are fine) |
disabled | boolean | false | Disables every interaction (drop, paste, remove, reorder) |
className | string | — | Forwarded to the outermost container |
AiAttachmentFile
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique identifier; remove, retry, and reorder all key off it |
name | string | — | File name (including extension) |
size | number | — | File size in bytes |
type | string | — | MIME type (e.g. image/png); when empty the extension is used instead |
status | "uploading" | "done" | "error" | — | Upload status |
progress | number | — | Upload progress 0–100; when status is uploading and this is omitted an indeterminate ring is shown |
previewUrl | string | — | Thumbnail source (data URL or blob URL); only used for image types |
AiAttachmentTrayLabels
| Field | Type | Default | Description |
|---|---|---|---|
hint | string | "拖曳、貼上或點「+」加入附件" (Drag, paste, or press + to add attachments) | Hint text shown when the tray has no attachments |
dropHint | string | "放開以加入附件" (Release to add attachments) | Hint text shown while files are dragged over the tray |
add | string | "加入附件" (Add attachment) | Accessible name of the "+" add button |
remove | string | "移除" (Remove) | Prefix of the remove button's accessible name, followed by the file name |
retry | string | "重試" (Retry) | Retry button text |
uploading | string | "上傳中" (Uploading) | Accessible name of the progress ring while uploading |
failed | string | "上傳失敗" (Upload failed) | Text shown on the chip when the upload failed |
tooMany | string | "最多 {max} 個附件" (Up to {max} attachments) | Hint when the attachment count limit is exceeded; {max} is replaced with the limit |
tooLarge | string | "單檔上限 {size}" (Per-file limit {size}) | Hint when a file exceeds the size limit; {size} is replaced with the limit |
unsupported | string | "不支援的檔案類型" (Unsupported file type) | Hint when the file type is outside accept |
list | string | "附件" (Attachments) | Accessible name of the attachment list (ul) |
reorderHint | string | "左右方向鍵切換附件;Shift 加方向鍵調整順序;Delete 移除" (Left/Right to move between attachments; Shift + arrow to reorder; Delete to remove) | Keyboard instructions for each chip (screen readers only) |
moved | string | "{name} 已移到第 {index} 位,共 {total} 個" ({name} moved to position {index} of {total}) | Announcement after a keyboard reorder; {name}, {index}, {total} are replaced |
removed | string | "已移除 {name}" (Removed {name}) | Announcement after removal; {name} is replaced |
getAttachmentKind(type, name) (derives the kind from MIME type and extension), truncateMiddle(name, max) (middle-truncates a file name), formatFileSize(bytes) (formats as B / KB / MB / GB), and the AiAttachmentStatus, AiAttachmentKind, and AiAttachmentRejectReason types are also named exports, so the parent can reuse the same logic when rendering its own lists or messages.
How it works
- Kind detection: the MIME type is checked first (
image/*,application/pdf,audio/*,video/*, spreadsheets, archives, documents, code), then it falls back to an extension table — browsers often report an empty MIME type for things like.md/.ts, and the fallback keeps them from all landing in "other" - Progress ring: SVG
stroke-dashoffsetadvances smoothly on a spring; whenprogressis omitted a short spinning arc signals "processing"; the ring center shows a whole-number percentage (omitted in compact mode) - Drag to reorder: built on motion
Reorder.Group/Reorder.Item(axis="x"); the retry and remove buttons inside a chip stop pointerdown so they never start a drag; dragging is disabled with a single attachment or whenonReorderis omitted in controlled mode - Drop and paste: the whole tray is a drop zone — while a drag hovers, the dashed border darkens and
labels.dropHintis shown; dragging in and out of child elements fires dragenter / dragleave repeatedly, so a depth counter prevents flicker. The paste listener is attached todocument(bound only inside an effect, and can be turned off withlistenPaste) and only acts when the clipboard carries files (a screenshot, say) - Limit handling:
accept/maxSizeMB/maxFilesare checked in that order; files that fail are never handed toonAdd—onRejectfires instead and an amber hint chip appears (it fades out after about 3 seconds); only the first reason in a batch is shown - Thumbnails in uncontrolled mode: images get a thumbnail via
URL.createObjectURL, which is revoked on removal or unmount so nothing leaks - Focus after removal: if focus was on the removed chip, it moves to a neighboring chip instead of falling back to body
Accessibility
- The attachment list is
role="list"(itsaria-labelcomes fromlabels.list); each chip is a focusablerole="listitem"whosearia-labelincludes the file name, size, and status, and is linked to the keyboard instructions viaaria-describedby - Keyboard: Left / Right move between chips, Home / End jump to the first / last, Shift + arrow reorders, Delete / Backspace removes; reorders and removals are announced through
aria-live="polite" - The progress ring carries
role="progressbar"witharia-valuenow/aria-valuetext; the remove and retry buttons both have anaria-labelthat includes the file name - The remove button only appears on hover / focus on fine-pointer devices and is always visible on touch devices; every button is
type="button"with afocus-visibleoutline - When the user has "reduce motion" enabled at the system level, chip enter / exit becomes a plain fade, chips do not scale up while dragging, the progress ring jumps straight to the current value, and the indeterminate ring stops spinning
AI Model Arena (React)
An arena-style side-by-side blind test React component: two anonymous responses stream in sync, you vote A / B / tie / both bad, then the cards flip to reveal the model names and provider color blocks, crown the winner, and roll the scoreboard numbers.
AI Branch Switcher (React)
A React version switcher for a single AI reply: a ‹ 2 / 3 › pager slides the content directionally, regenerate opens a new branch with a typing skeleton, an optional mini branch tree sits above the bubble, ←/→ switch versions while the bubble has focus, plus copy and model/time captions.