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.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add ai-attachment-tray.enAI Attachment Tray is a React file attachment chips component built with Tailwind CSS and Motion, typically placed above an AI chat input. It accepts drag and drop, pasted screenshots and the file picker, shows upload progress with an SVG ring, and supports drag reordering through Motion's Reorder.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<AiAttachmentTray/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- AI chat or support composers where users attach images, PDFs or spreadsheets before asking.
- Bug report or feedback forms that need screenshots, which users can simply paste.
- Email or message composers that list attachments and let users reorder or remove them.
- Upload flows that report per-file progress and offer a retry on failure.
- Not a good fit: upload pages built around a large drop area; use File Dropzone instead.
Installation
npx webberui@latest add https://webberui.com/r/ai-attachment-tray.en.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)}
/>Examples
Report upload progress with XMLHttpRequest
fetch cannot report upload progress, so the uploadToServer used in the Usage example above can be written with XMLHttpRequest. onProgress reports 0 to 1; multiply by 100 when writing progress.
export function uploadToServer(
file: File,
{ onProgress }: { onProgress: (ratio: number) => void },
) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
const body = new FormData();
body.append("file", file);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total);
};
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(String(xhr.status))));
xhr.onerror = () => reject(new Error("network"));
xhr.open("POST", "/api/attachments");
xhr.send(body);
});
}Show why a file was rejected
The tray already shows a notice chip when a file fails a check. To log or display a message elsewhere, branch on the reason passed to onReject ("count", "size" or "type").
import {
AiAttachmentTray,
type AiAttachmentRejectReason,
} from "@/components/ui/ai-attachment-tray";
const messages: Record<AiAttachmentRejectReason, string> = {
count: "Attachment limit reached",
size: "File is larger than 10 MB",
type: "Only images and PDFs are accepted",
};
<AiAttachmentTray
maxFiles={4}
maxSizeMB={10}
accept="image/*,.pdf"
onReject={(file, reason) => console.warn(file.name, messages[reason])}
/>Compact mode and custom labels
compact shrinks the thumbnails and uses a single line of text, which suits mobile or a sidebar; labels only needs the fields you want to change. With several trays on one page, set listenPaste={false} on the extra ones so a single paste is not picked up by all of them.
<AiAttachmentTray
compact
showSize={false}
listenPaste={false}
hint="Drop files here"
labels={{ tooMany: "You can attach up to {max} files", failed: "Send failed" }}
maxFiles={3}
/>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 click "+" to add attachments" (Drag, paste, or press + to add attachments) | Hint text shown when the tray has no attachments |
dropHint | string | "Release to add attachments" (Release to add attachments) | Hint text shown while files are dragged over the tray |
add | string | "Add attachments" (Add attachment) | Accessible name of the "+" add button |
remove | string | "Remove" (Remove) | Prefix of the remove button's accessible name, followed by the file name |
retry | string | "Retry" (Retry) | Retry button text |
uploading | string | "Uploading" (Uploading) | Accessible name of the progress ring while uploading |
failed | string | "Upload Failed" (Upload failed) | Text shown on the chip when the upload failed |
tooMany | string | "Maximum {max} attachments" (Up to {max} attachments) | Hint when the attachment count limit is exceeded; {max} is replaced with the limit |
tooLarge | string | "Maximum size per file {size}" (Per-file limit {size}) | Hint when a file exceeds the size limit; {size} is replaced with the limit |
unsupported | string | "Unsupported file types" (Unsupported file type) | Hint when the file type is outside accept |
list | string | "Attachments" (Attachments) | Accessible name of the attachment list (ul) |
reorderHint | string | "Use left and right arrow keys to switch attachments; Shift + arrow keys to adjust the order; Delete to remove" (Left/Right to move between attachments; Shift + arrow to reorder; Delete to remove) | Keyboard instructions for each chip (screen readers only) |
moved | string | "{name} has been moved to the {index} position, total {total} files" ({name} moved to position {index} of {total}) | Announcement after a keyboard reorder; {name}, {index}, {total} are replaced |
removed | string | "{name} removed" (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
FAQ
Does it work with the Next.js App Router?
Yes. The source starts with "use client", so import it from a Server Component page as usual. The document paste listener is only attached inside an effect, so server rendering never touches browser APIs.
Does the component upload files for me?
No. It collects, validates and displays files; files that pass the checks go to onAdd, and you handle the upload. In controlled mode, write status and progress back into files and the progress ring updates.
Do I need to release thumbnail blob URLs?
In uncontrolled mode the component creates them and revokes them on remove or unmount. In controlled mode you create previewUrl, so call URL.revokeObjectURL yourself when removing an item.
Can attachments be reordered or removed from the keyboard?
Yes. With a chip focused, Left and Right move between chips, Home and End jump to the ends, Shift plus an arrow moves the chip, and Delete or Backspace removes it; changes are announced through aria-live. In controlled mode, reordering is disabled when onReorder is not provided.
Which helpers can I reuse?
getAttachmentKind, truncateMiddle and formatFileSize are named exports, so your message list or sent attachments can reuse the same file type detection and name and size formatting.
AI Model Arena
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
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.