WebberUI

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.

Loading preview…
npx shadcn@latest add https://webberui.com/r/ai-attachment-tray.json

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
<AiAttachmentTray />

Installation

npx shadcn@latest add https://webberui.com/r/ai-attachment-tray.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)}
/>

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[]) => voidIn uncontrolled mode, called with the full list after any change (add / remove / reorder)
onAdd(files: File[]) => voidCalled 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) => voidCalled when the remove button is pressed (or Delete is pressed on a chip)
onRetry(id: string) => voidCalled when retry is pressed on a failed chip
onReorder(ids: string[]) => voidCalled 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) => voidCalled when a file fails the count / size / type check
maxFilesnumberMaximum number of attachments; extra files show a hint chip and are not handed to onAdd
maxSizeMBnumberMaximum size per file (MB)
acceptstringAccepted 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
hintstringHint 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)
classNamestringForwarded to the outermost container

AiAttachmentFile

FieldTypeDefaultDescription
idstringUnique identifier; remove, retry, and reorder all key off it
namestringFile name (including extension)
sizenumberFile size in bytes
typestringMIME type (e.g. image/png); when empty the extension is used instead
status"uploading" | "done" | "error"Upload status
progressnumberUpload progress 0–100; when status is uploading and this is omitted an indeterminate ring is shown
previewUrlstringThumbnail source (data URL or blob URL); only used for image types

AiAttachmentTrayLabels

FieldTypeDefaultDescription
hintstring"拖曳、貼上或點「+」加入附件" (Drag, paste, or press + to add attachments)Hint text shown when the tray has no attachments
dropHintstring"放開以加入附件" (Release to add attachments)Hint text shown while files are dragged over the tray
addstring"加入附件" (Add attachment)Accessible name of the "+" add button
removestring"移除" (Remove)Prefix of the remove button's accessible name, followed by the file name
retrystring"重試" (Retry)Retry button text
uploadingstring"上傳中" (Uploading)Accessible name of the progress ring while uploading
failedstring"上傳失敗" (Upload failed)Text shown on the chip when the upload failed
tooManystring"最多 {max} 個附件" (Up to {max} attachments)Hint when the attachment count limit is exceeded; {max} is replaced with the limit
tooLargestring"單檔上限 {size}" (Per-file limit {size})Hint when a file exceeds the size limit; {size} is replaced with the limit
unsupportedstring"不支援的檔案類型" (Unsupported file type)Hint when the file type is outside accept
liststring"附件" (Attachments)Accessible name of the attachment list (ul)
reorderHintstring"左右方向鍵切換附件;Shift 加方向鍵調整順序;Delete 移除" (Left/Right to move between attachments; Shift + arrow to reorder; Delete to remove)Keyboard instructions for each chip (screen readers only)
movedstring"{name} 已移到第 {index} 位,共 {total} 個" ({name} moved to position {index} of {total})Announcement after a keyboard reorder; {name}, {index}, {total} are replaced
removedstring"已移除 {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-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 onAddonReject 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

On this page