uploads
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 and Prompt Bar Pro are the composers this tray typically lives inside.
- Diff Summary is a similar "batch of items with per-item status" pattern for a different context (file edits instead of uploads).