Attachment Chip
A composer's file/image attachment tray with drag-drop, upload progress, and inline preview.
sunset-photo.png
2.3 MB
quarterl…inal-v2.pdf
793 KB
notes.txt
4 KB
archive.zip
"use client";
import * as React from "react";
import { motion } from "motion/react";
import { AlertCircle, File, RotateCcw, Upload, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Attachment {
id: string;
name: string;
size: number;
/** 0-100. Ignored once status is "done" or "error". */
progress: number;
status: "uploading" | "done" | "error";
previewUrl?: string;
}
export interface AttachmentTrayProps {
attachments: Attachment[];
onRemove?: (id: string) => void;
onRetry?: (id: string) => void;
onDropFiles?: (files: FileList) => void;
className?: string;
}
export function AttachmentTray({
attachments,
onRemove,
onRetry,
onDropFiles,
className,
}: AttachmentTrayProps) {
const [dragging, setDragging] = React.useState(false);
return (
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length) onDropFiles?.(e.dataTransfer.files);
}}
className={cn("relative rounded-2xl border p-3", className)}
>
{dragging && (
<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">
<Upload className="size-4" aria-hidden />
Drop to attach
</div>
)}
{attachments.length > 0 ? (
<ul className="flex gap-2 overflow-x-auto pb-1">
{attachments.map((attachment) => (
<AttachmentChip
key={attachment.id}
attachment={attachment}
onRemove={onRemove}
onRetry={onRetry}
/>
))}
</ul>
) : (
!dragging && (
<p className="text-sm text-muted-foreground">Drag files here, or use the attach button.</p>
)
)}
</div>
);
}
export function AttachmentChip({
attachment,
onRemove,
onRetry,
}: {
attachment: Attachment;
onRemove?: (id: string) => void;
onRetry?: (id: string) => void;
}) {
const { id, name, size, progress, status, previewUrl } = attachment;
const radius = 16;
const circumference = 2 * Math.PI * radius;
return (
<li
className={cn(
"relative flex w-40 shrink-0 items-center gap-2 rounded-xl border bg-card p-2",
status === "error" && "border-destructive/40 bg-destructive/5"
)}
>
<div
className="relative flex size-9 shrink-0 items-center justify-center"
{...(status === "uploading"
? { role: "progressbar", "aria-valuenow": progress, "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": `Uploading ${name}` }
: {})}
>
{previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" className="size-9 rounded-lg object-cover" />
) : (
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<File className="size-4" aria-hidden />
</span>
)}
{status === "uploading" && (
<svg width={36} height={36} className="absolute inset-0 -rotate-90" aria-hidden>
<circle cx={18} cy={18} r={radius} strokeWidth={2} className="stroke-background/70" fill="none" />
<motion.circle
cx={18}
cy={18}
r={radius}
strokeWidth={2}
strokeLinecap="round"
className="stroke-foreground"
fill="none"
strokeDasharray={circumference}
animate={{ strokeDashoffset: circumference * (1 - progress / 100) }}
transition={{ duration: 0.2 }}
/>
</svg>
)}
{status === "error" && (
<span className="absolute inset-0 flex items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-4 text-destructive" aria-hidden />
</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{truncateMiddle(name)}</p>
<p aria-live="polite" className="text-xs text-muted-foreground">
{status === "error" ? (
<button
type="button"
onClick={() => onRetry?.(id)}
className="inline-flex items-center gap-1 text-destructive hover:underline"
>
<RotateCcw className="size-3" aria-hidden /> Retry
</button>
) : (
formatBytes(size)
)}
</p>
</div>
<button
type="button"
onClick={() => onRemove?.(id)}
aria-label={`Remove ${name}`}
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"
>
<X className="size-2.5" aria-hidden />
</button>
</li>
);
}
function truncateMiddle(name: string, max = 20) {
if (name.length <= max) return name;
const dot = name.lastIndexOf(".");
const ext = dot > 0 ? name.slice(dot) : "";
const base = dot > 0 ? name.slice(0, dot) : name;
const keep = max - ext.length - 1;
if (keep <= 1) return `${name.slice(0, max - 1)}…`;
return `${base.slice(0, Math.ceil(keep / 2))}…${base.slice(-Math.floor(keep / 2))}${ext}`;
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Attachment Chip
## Summary
A 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.
## When to use
- Any composer that accepts file or image attachments and needs to show what's queued before the user sends.
- When uploads can take real time (large files, slow networks) and the user benefits from per-file progress instead of a single blocking spinner.
- Any time an attachment might fail and the user needs a way to retry or remove it without retyping their message.
## When not to use
- For attachments on a message that has already been sent — show those inline in the message itself, not as an in-progress chip.
- 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.
- When the host truly cannot support removing an in-flight upload — don't show a remove control that doesn't actually cancel anything.
## Anatomy
- 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.
- Chip: a thumbnail (for images) or a file-type icon, the filename, the file size, and a remove (×) button.
- Progress ring: a circular indicator over the thumbnail/icon while uploading; replaced by the plain thumbnail/icon once the upload completes.
- Error state: an alert tint and icon with an inline "Retry" affordance if the upload fails.
## Behavior
- 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.
- The progress ring fills as the upload progresses; if no real progress fraction is available, it spins indeterminately instead of freezing at 0.
- The remove button works at any stage — including mid-upload, where it also cancels the in-flight request.
- Multiple attachments lay out in a horizontal, scrollable row above the text input rather than stacking vertically and pushing the input down.
- On failure, the chip switches to its error state with a visible "Retry" rather than silently disappearing or failing the whole send.
## Content guidelines
- 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.
- Show file size once the upload starts, not before there's anything to report.
## Accessibility
- 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.
- Expose upload progress as `role="progressbar"` with `aria-valuenow`, not through animation alone.
- Give each remove button a descriptive accessible name (`Remove quarterly-report.pdf`), not a bare "×".
## Related patterns
- **Prompt Bar** — Use it as well, since it's the composer this tray usually lives inside.
- **Prompt Bar Pro** — Use it as well on a first-run composer that accepts files.
- **Image Peek** — Use it instead for images the agent has already used or produced rather than images going in.
- **Diff Summary Card** — Use it instead when the batch of items with per-item status is file edits rather than uploads.
Do’s and don’ts
Do use it for
- Any composer that accepts file or image attachments and needs to show what's queued before the user sends.
- When uploads can take real time (large files, slow networks) and the user benefits from per-file progress instead of a single blocking spinner.
- Any time an attachment might fail and the user needs a way to retry or remove it without retyping their message.
Don’t use it for
- For attachments on a message that has already been sent — show those inline in the message itself, not as an in-progress chip.
- 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.
- When the host truly cannot support removing an in-flight upload — don't show a remove control that doesn't actually cancel anything.
Related patterns
Prompt Bar
Use it as well, since it's the composer this tray usually lives inside.
Prompt Bar Pro
Use it as well on a first-run composer that accepts files.
Image Peek
Use it instead for images the agent has already used or produced rather than images going in.
Diff Summary Card
Use it instead when the batch of items with per-item status is file edits rather than uploads.