{"slug":"attachment-chip","category":"uploads","job":"ask","added":"2026-09-12T06:37Z","title":"Attachment Chip","description":"A composer's file/image attachment tray with drag-drop, upload progress, and inline preview.","url":"https://ai-patterns.app/patterns/uploads/attachment-chip","markdown":"https://ai-patterns.app/patterns/uploads/attachment-chip.md","json":"https://ai-patterns.app/api/patterns/uploads/attachment-chip","seenIn":[{"team":"Claude","source":"https://support.claude.com/en/articles/8241126-upload-files-to-claude"},{"team":"Figma","source":"https://help.figma.com/hc/en-us/articles/31304529835671-Attach-files-to-a-prompt-in-the-Figma-agent-and-Figma-Make"},{"team":"lovable","source":"https://docs.lovable.dev/tips-tricks/using-images"},{"team":"Perplexity","source":"https://www.perplexity.ai/help-center/en/articles/10354807-file-uploads"}],"spec":"# Attachment Chip\n\n## Summary\nA compact chip representing one file or image attached to a composer — thumbnail or file-type icon, a circular progress ring while it uploads, and a remove control — laid out in a tray that also shows a drop-zone overlay while a file is dragged over it. It lets the user see exactly what's about to be sent, and back out of it, before the message goes.\n\n## When to use\n- Any composer that accepts file or image attachments and needs to show what's queued before the user sends.\n- When uploads can take real time (large files, slow networks) and the user benefits from per-file progress instead of a single blocking spinner.\n- Any time an attachment might fail and the user needs a way to retry or remove it without retyping their message.\n\n## When not to use\n- For attachments on a message that has already been sent — show those inline in the message itself, not as an in-progress chip.\n- As a general file-manager UI. This is for the handful of items about to go out with one message, not for browsing or organizing a file library.\n- When the host truly cannot support removing an in-flight upload — don't show a remove control that doesn't actually cancel anything.\n\n## Anatomy\n- Drop-zone overlay: a dashed-border highlight with a \"Drop to attach\" label that appears over the whole tray while a file is dragged over it.\n- Chip: a thumbnail (for images) or a file-type icon, the filename, the file size, and a remove (×) button.\n- Progress ring: a circular indicator over the thumbnail/icon while uploading; replaced by the plain thumbnail/icon once the upload completes.\n- Error state: an alert tint and icon with an inline \"Retry\" affordance if the upload fails.\n\n## Behavior\n- Dragging a file over the composer shows the drop-zone overlay immediately; dropping it (or picking via an attach button) adds a chip right away in an uploading state, before the network call resolves.\n- The progress ring fills as the upload progresses; if no real progress fraction is available, it spins indeterminately instead of freezing at 0.\n- The remove button works at any stage — including mid-upload, where it also cancels the in-flight request.\n- Multiple attachments lay out in a horizontal, scrollable row above the text input rather than stacking vertically and pushing the input down.\n- On failure, the chip switches to its error state with a visible \"Retry\" rather than silently disappearing or failing the whole send.\n\n## Content guidelines\n- Truncate long filenames in the middle, keeping the extension visible, so `quarterly-report-final-v2.pdf` reads as `quarterly-…-v2.pdf`, not an unreadable prefix.\n- Show file size once the upload starts, not before there's anything to report.\n\n## Accessibility\n- Always provide a file-picker button as an equivalent to drag-and-drop — drag-and-drop must never be the only way to attach a file.\n- Expose upload progress as `role=\"progressbar\"` with `aria-valuenow`, not through animation alone.\n- Give each remove button a descriptive accessible name (`Remove quarterly-report.pdf`), not a bare \"×\".\n\n## Related patterns\n- **Prompt Bar** — Use it as well, since it's the composer this tray usually lives inside.\n- **Prompt Bar Pro** — Use it as well on a first-run composer that accepts files.\n- **Image Peek** — Use it instead for images the agent has already used or produced rather than images going in.\n- **Diff Summary Card** — Use it instead when the batch of items with per-item status is file edits rather than uploads.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\nimport { AlertCircle, File, RotateCcw, Upload, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface Attachment {\n  id: string;\n  name: string;\n  size: number;\n  /** 0-100. Ignored once status is \"done\" or \"error\". */\n  progress: number;\n  status: \"uploading\" | \"done\" | \"error\";\n  previewUrl?: string;\n}\n\nexport interface AttachmentTrayProps {\n  attachments: Attachment[];\n  onRemove?: (id: string) => void;\n  onRetry?: (id: string) => void;\n  onDropFiles?: (files: FileList) => void;\n  className?: string;\n}\n\nexport function AttachmentTray({\n  attachments,\n  onRemove,\n  onRetry,\n  onDropFiles,\n  className,\n}: AttachmentTrayProps) {\n  const [dragging, setDragging] = React.useState(false);\n\n  return (\n    <div\n      onDragOver={(e) => {\n        e.preventDefault();\n        setDragging(true);\n      }}\n      onDragLeave={() => setDragging(false)}\n      onDrop={(e) => {\n        e.preventDefault();\n        setDragging(false);\n        if (e.dataTransfer.files.length) onDropFiles?.(e.dataTransfer.files);\n      }}\n      className={cn(\"relative rounded-2xl border p-3\", className)}\n    >\n      {dragging && (\n        <div className=\"pointer-events-none absolute inset-1 z-10 flex flex-col items-center justify-center gap-1.5 rounded-xl border-2 border-dashed border-foreground/30 bg-background/90 text-sm text-muted-foreground\">\n          <Upload className=\"size-4\" aria-hidden />\n          Drop to attach\n        </div>\n      )}\n\n      {attachments.length > 0 ? (\n        <ul className=\"flex gap-2 overflow-x-auto pb-1\">\n          {attachments.map((attachment) => (\n            <AttachmentChip\n              key={attachment.id}\n              attachment={attachment}\n              onRemove={onRemove}\n              onRetry={onRetry}\n            />\n          ))}\n        </ul>\n      ) : (\n        !dragging && (\n          <p className=\"text-sm text-muted-foreground\">Drag files here, or use the attach button.</p>\n        )\n      )}\n    </div>\n  );\n}\n\nexport function AttachmentChip({\n  attachment,\n  onRemove,\n  onRetry,\n}: {\n  attachment: Attachment;\n  onRemove?: (id: string) => void;\n  onRetry?: (id: string) => void;\n}) {\n  const { id, name, size, progress, status, previewUrl } = attachment;\n  const radius = 16;\n  const circumference = 2 * Math.PI * radius;\n\n  return (\n    <li\n      className={cn(\n        \"relative flex w-40 shrink-0 items-center gap-2 rounded-xl border bg-card p-2\",\n        status === \"error\" && \"border-destructive/40 bg-destructive/5\"\n      )}\n    >\n      <div\n        className=\"relative flex size-9 shrink-0 items-center justify-center\"\n        {...(status === \"uploading\"\n          ? { role: \"progressbar\", \"aria-valuenow\": progress, \"aria-valuemin\": 0, \"aria-valuemax\": 100, \"aria-label\": `Uploading ${name}` }\n          : {})}\n      >\n        {previewUrl ? (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img src={previewUrl} alt=\"\" className=\"size-9 rounded-lg object-cover\" />\n        ) : (\n          <span className=\"flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground\">\n            <File className=\"size-4\" aria-hidden />\n          </span>\n        )}\n        {status === \"uploading\" && (\n          <svg width={36} height={36} className=\"absolute inset-0 -rotate-90\" aria-hidden>\n            <circle cx={18} cy={18} r={radius} strokeWidth={2} className=\"stroke-background/70\" fill=\"none\" />\n            <motion.circle\n              cx={18}\n              cy={18}\n              r={radius}\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n              className=\"stroke-foreground\"\n              fill=\"none\"\n              strokeDasharray={circumference}\n              animate={{ strokeDashoffset: circumference * (1 - progress / 100) }}\n              transition={{ duration: 0.2 }}\n            />\n          </svg>\n        )}\n        {status === \"error\" && (\n          <span className=\"absolute inset-0 flex items-center justify-center rounded-lg bg-destructive/10\">\n            <AlertCircle className=\"size-4 text-destructive\" aria-hidden />\n          </span>\n        )}\n      </div>\n\n      <div className=\"min-w-0 flex-1\">\n        <p className=\"truncate text-xs font-medium\">{truncateMiddle(name)}</p>\n        <p aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n          {status === \"error\" ? (\n            <button\n              type=\"button\"\n              onClick={() => onRetry?.(id)}\n              className=\"inline-flex items-center gap-1 text-destructive hover:underline\"\n            >\n              <RotateCcw className=\"size-3\" aria-hidden /> Retry\n            </button>\n          ) : (\n            formatBytes(size)\n          )}\n        </p>\n      </div>\n\n      <button\n        type=\"button\"\n        onClick={() => onRemove?.(id)}\n        aria-label={`Remove ${name}`}\n        className=\"absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm transition-colors hover:text-foreground\"\n      >\n        <X className=\"size-2.5\" aria-hidden />\n      </button>\n    </li>\n  );\n}\n\nfunction truncateMiddle(name: string, max = 20) {\n  if (name.length <= max) return name;\n  const dot = name.lastIndexOf(\".\");\n  const ext = dot > 0 ? name.slice(dot) : \"\";\n  const base = dot > 0 ? name.slice(0, dot) : name;\n  const keep = max - ext.length - 1;\n  if (keep <= 1) return `${name.slice(0, max - 1)}…`;\n  return `${base.slice(0, Math.ceil(keep / 2))}…${base.slice(-Math.floor(keep / 2))}${ext}`;\n}\n\nfunction formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}