AI Interface Components

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.en

New project or installation trouble? Start here →

AI 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.

6
10
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
filesAiAttachmentFile[]—Controlled attachment list (array order is display order); when omitted the component is uncontrolled
defaultFilesAiAttachmentFile[][]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
maxFilesnumber—Maximum number of attachments; extra files show a hint chip and are not handed to onAdd
maxSizeMBnumber—Maximum size per file (MB)
acceptstring—Accepted file types, same as the native accept (e.g. "image/*,.pdf"); also applied to drops and pastes
compactbooleanfalseCompact mode: smaller thumbnails and a single line of text
showSizebooleantrueWhether to show the file size on each chip
showAddButtonbooleantrueWhether to show the "+" add button (opens the native file picker)
listenPastebooleantrueWhether to listen for document paste events; pasted content that carries files is treated like a drop
hintstring—Hint text shown when the tray is empty; a shortcut for labels.hint
labelsPartial<AiAttachmentTrayLabels>—Custom strings (partial overrides are fine)
disabledbooleanfalseDisables every interaction (drop, paste, remove, reorder)
classNamestring—Forwarded to the outermost container

AiAttachmentFile

FieldTypeDefaultDescription
idstring—Unique identifier; remove, retry, and reorder all key off it
namestring—File name (including extension)
sizenumber—File size in bytes
typestring—MIME type (e.g. image/png); when empty the extension is used instead
status"uploading" | "done" | "error"—Upload status
progressnumber—Upload progress 0–100; when status is uploading and this is omitted an indeterminate ring is shown
previewUrlstring—Thumbnail source (data URL or blob URL); only used for image types

AiAttachmentTrayLabels

FieldTypeDefaultDescription
hintstring"Drag, paste, or click "+" to add attachments" (Drag, paste, or press + to add attachments)Hint text shown when the tray has no attachments
dropHintstring"Release to add attachments" (Release to add attachments)Hint text shown while files are dragged over the tray
addstring"Add attachments" (Add attachment)Accessible name of the "+" add button
removestring"Remove" (Remove)Prefix of the remove button's accessible name, followed by the file name
retrystring"Retry" (Retry)Retry button text
uploadingstring"Uploading" (Uploading)Accessible name of the progress ring while uploading
failedstring"Upload Failed" (Upload failed)Text shown on the chip when the upload failed
tooManystring"Maximum {max} attachments" (Up to {max} attachments)Hint when the attachment count limit is exceeded; {max} is replaced with the limit
tooLargestring"Maximum size per file {size}" (Per-file limit {size})Hint when a file exceeds the size limit; {size} is replaced with the limit
unsupportedstring"Unsupported file types" (Unsupported file type)Hint when the file type is outside accept
liststring"Attachments" (Attachments)Accessible name of the attachment list (ul)
reorderHintstring"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)
movedstring"{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
removedstring"{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-dashoffset advances smoothly on a spring; when progress is 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 when onReorder is omitted in controlled mode
  • Drop and paste: the whole tray is a drop zone — while a drag hovers, the dashed border darkens and labels.dropHint is shown; dragging in and out of child elements fires dragenter / dragleave repeatedly, so a depth counter prevents flicker. The paste listener is attached to document (bound only inside an effect, and can be turned off with listenPaste) and only acts when the clipboard carries files (a screenshot, say)
  • Limit handling: accept / maxSizeMB / maxFiles are checked in that order; files that fail are never handed to onAdd — onReject fires 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" (its aria-label comes from labels.list); each chip is a focusable role="listitem" whose aria-label includes the file name, size, and status, and is linked to the keyboard instructions via aria-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" with aria-valuenow / aria-valuetext; the remove and retry buttons both have an aria-label that 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 a focus-visible outline
  • 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.