← All components

Attachment

A file on its way into a conversation: thumbnail, a name that keeps its extension, and a thin ink line while it uploads.

  • q3-board-memo-final-v2.pdfUploading · 0%
  • renewals-october.xlsxUploading · 0%
  • dashboard.pngPNG · 820 KB
  • pricing.tsTS · 12 KB
  • workspace-reference.pngPNG · 1.1 MB
  • office-north-wing.jpgUploading · 0%

The idea

The name truncates in the middle, because the end of a file name is what tells two drafts apart — “q3-board-me…l-v2.pdf” is useful where “q3-board-memo-fi…” is not — and it does it in CSS, as a stem that ellipses beside a tail that does not. Progress is the one solid fill on the chip. A failure is said in words with a mark in place of the glyph, never in red: what helps is knowing the connection dropped.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Attachment — usage19 lines
import { Attachment, AttachmentList } from "mihirmodi-ui";

<AttachmentList>
  {files.map((f) => (
    <Attachment
      key={f.id}
      name={f.name}                 // the extension survives truncation
      size={f.bytes}
      src={f.previewUrl}            // an image becomes the thumbnail
      progress={f.uploaded}         // 0–1 while uploading
      error={f.failed && "Upload failed: the connection dropped"}
      onRetry={() => retry(f.id)}
      onRemove={() => remove(f.id)}
    />
  ))}
</AttachmentList>

// Images can lead with the picture instead.
<Attachment layout="tile" name="workspace.png" size={820_000} src={url} />

Props

PropNotes
namestringWith its extension, which always survives truncation.
size / kindnumber / stringBytes, formatted for you; the kind defaults to the extension.
srcstringAn image URL. The media slot becomes a thumbnail.
progressnumber0–1. Below 1 it reads “Uploading · 64%” and the bar fills.
errorReactNodeWhat went wrong, in words. Pair with `onRetry`.
onRemove() => voidDraws the remove button, named for the file.
layout"row" | "tile"A chip for a composer, or a tile that leads with the picture.
AttachmentListcomponentA wrapping list; each file fades in, and out where it stands when removed.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/chat/Attachment.tsx237 lines
"use client";

import { Children, isValidElement, type ReactNode } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CloseIcon, FileIcon } from "../../lib/glyph.js";
import { CLOSE, FLAT, enter } from "../../lib/motion.js";

export type AttachmentProps = {
  /** The file name, extension included. The extension always survives truncation. */
  name: string;
  /** Size in bytes. */
  size?: number;
  /** An image URL. Given, the media slot is a thumbnail instead of a glyph. */
  src?: string;
  /** The kind in the meta line — "PDF", "Spreadsheet". Defaults to the extension, upper-cased. */
  kind?: string;
  /** 0–1 while uploading. Below 1 the meta line says so and an ink bar fills; at 1 or unset the file is in. */
  progress?: number;
  /** Uploaded, but still being read on the far side. */
  processing?: boolean;
  /** What went wrong, in words — "Too large: 25 MB is the limit". */
  error?: ReactNode;
  /** Draws a remove button. */
  onRemove?: () => void;
  /** Draws a retry button while `error` is set. */
  onRetry?: () => void;
  /** `row` is a chip for a composer or a list; `tile` leads with the picture, for images. */
  layout?: "row" | "tile";
  className?: string;
};

function formatBytes(n: number) {
  if (n < 1024) return `${n} B`;
  const units = ["KB", "MB", "GB"];
  let v = n / 1024;
  let i = 0;
  while (v >= 1024 && i < units.length - 1) {
    v /= 1024;
    i++;
  }
  return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
}

/** "q3-board-memo-final.pdf" → ["q3-board-memo-fi", "nal.pdf"]: the tail never truncates. */
function splitName(name: string) {
  const dot = name.lastIndexOf(".");
  const ext = dot > 0 ? name.slice(dot) : "";
  const stem = dot > 0 ? name.slice(0, dot) : name;
  const keep = Math.min(4, Math.max(0, stem.length - 4));
  return [stem.slice(0, stem.length - keep), stem.slice(stem.length - keep) + ext] as const;
}

function AlertGlyph({ className = "h-4 w-4" }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden className={className}>
      <circle cx="12" cy="12" r="8.5" />
      <path d="M12 7.8v5" />
      <path d="M12 16.2h.01" strokeWidth="2.2" />
    </svg>
  );
}

/**
 * A file on its way into a conversation, or already in it.
 *
 * The name truncates in the middle, not at the end: the end of a file name is
 * the part that tells two drafts apart and says what kind of thing it is, so
 * "q3-board-me…l-v2.pdf" is useful where "q3-board-memo-fi…" is not. It is two
 * spans — a stem that ellipses and a tail that does not — so it is CSS, and it
 * re-fits as the chip's width changes without measuring anything.
 *
 * Upload progress is a thin ink line under the meta, the one solid fill on the
 * chip, filling by transform. An error says what went wrong in words and swaps
 * the glyph for a mark, rather than turning anything red: the palette is
 * monochrome, and "Too large: 25 MB is the limit" is the part that helps.
 */
export function Attachment({
  name,
  size,
  src,
  kind,
  progress,
  processing = false,
  error,
  onRemove,
  onRetry,
  layout = "row",
  className = "",
}: AttachmentProps) {
  const [head, tail] = splitName(name);
  const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1).toUpperCase() : "";
  const uploading = !error && progress !== undefined && progress < 1;
  const pct = Math.round(Math.max(0, Math.min(1, progress ?? 0)) * 100);

  const meta = error
    ? error
    : uploading
      ? `Uploading · ${pct}%`
      : processing
        ? "Processing…"
        : [kind ?? ext, size !== undefined ? formatBytes(size) : null].filter(Boolean).join(" · ");

  const title = (
    <span className="flex min-w-0 text-[13px] font-medium leading-[1.3] text-gray-1000" title={name}>
      <span className="truncate">{head}</span>
      <span className="shrink-0 whitespace-pre">{tail}</span>
    </span>
  );

  const metaLine = (
    <span className={`block truncate text-[12px] leading-[1.35] tabular-nums ${error ? "text-muted" : "text-faint"}`} aria-live="polite">
      {meta}
    </span>
  );

  const bar = uploading && (
    <span
      role="progressbar"
      aria-label={`Uploading ${name}`}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={pct}
      className="mt-1.5 block h-[2px] overflow-hidden rounded-full bg-gray-alpha-200"
    >
      <span
        className="block h-full origin-left rounded-full bg-ink transition-transform duration-200 ease-out motion-reduce:transition-none"
        style={{ transform: `scaleX(${pct / 100})` }}
      />
    </span>
  );

  const glyph = error ? <AlertGlyph /> : <FileIcon className="h-4 w-4" />;

  const remove = onRemove && (
    <button
      type="button"
      onClick={onRemove}
      aria-label={`Remove ${name}`}
      className={
        layout === "tile"
          ? "glass-nav absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-gray-alpha-400 text-gray-1000 transition-transform duration-150 ease-geist active:scale-[0.92] motion-reduce:transition-none"
          : "flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-quiet transition-[background-color,color,transform] duration-150 ease-geist hover:bg-gray-alpha-200 hover:text-gray-1000 active:scale-[0.92] motion-reduce:transition-none"
      }
    >
      <CloseIcon className="h-3 w-3" />
    </button>
  );

  const retry = error && onRetry && (
    <button
      type="button"
      onClick={onRetry}
      className="shrink-0 rounded-[6px] px-1.5 py-0.5 text-[12px] font-medium text-gray-1000 transition-colors duration-150 hover:bg-gray-alpha-200"
    >
      Retry
    </button>
  );

  if (layout === "tile") {
    return (
      <div className={`flex w-[132px] flex-col gap-2 ${className}`}>
        <div className="relative aspect-[4/3] overflow-hidden rounded-[12px] border-[0.5px] border-gray-alpha-400 bg-background-100">
          {src && !error ? (
            <img src={src} alt="" className={`h-full w-full object-cover ${uploading ? "opacity-60" : ""} transition-opacity duration-200`} />
          ) : (
            <span className="flex h-full w-full flex-col items-center justify-center gap-1 text-quiet">
              {glyph}
              {!error && ext && <span className="text-[11px] font-medium tabular-nums">{ext}</span>}
            </span>
          )}
          {remove}
        </div>
        <div className="min-w-0 px-0.5">
          {title}
          <span className="flex items-center gap-1">
            <span className="min-w-0 flex-1">{metaLine}</span>
            {retry}
          </span>
          {bar}
        </div>
      </div>
    );
  }

  return (
    <div
      className={`flex min-w-0 max-w-[280px] items-center gap-2.5 rounded-[12px] border-[0.5px] border-gray-alpha-400 bg-background-100 p-1.5 pr-1 ${className}`}
    >
      {/* Outer 12 less 6 of padding: the thumbnail nests at 6. */}
      <span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-gray-alpha-100 text-quiet">
        {src && !error ? <img src={src} alt="" className="h-full w-full object-cover" /> : glyph}
      </span>
      <span className="min-w-0 flex-1 py-0.5">
        {title}
        {metaLine}
        {bar}
      </span>
      {retry}
      {remove}
    </div>
  );
}

export type AttachmentListProps = {
  /** Attachments, each with a `key`. Removing one fades it out where it stands. */
  children: ReactNode;
  "aria-label"?: string;
  className?: string;
};

/**
 * Attachments in a wrapping row, as a list a screen reader can count. Each one
 * arrives with the thread's fade and 4px, and leaves with the quick flat close
 * — removing a file is not an event worth a bounce.
 */
export function AttachmentList({ children, "aria-label": ariaLabel = "Attachments", className = "" }: AttachmentListProps) {
  const reduce = useReducedMotion();
  const items = Children.toArray(children).filter(isValidElement);
  return (
    <LazyMotion features={domAnimation} strict>
      <ul aria-label={ariaLabel} className={`flex flex-wrap items-start gap-2 ${className}`}>
        <AnimatePresence initial={false}>
          {items.map((child) => (
            <m.li
              key={child.key ?? undefined}
              {...enter(reduce)}
              exit={reduce ? { opacity: 0, transition: FLAT } : { opacity: 0, scale: 0.96, transition: CLOSE }}
              className="min-w-0"
            >
              {child}
            </m.li>
          ))}
        </AnimatePresence>
      </ul>
    </LazyMotion>
  );
}