AI Patterns

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