For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Attachment

A square attachment chip for composer rows — image thumbnails, file glyphs, and upload progress drawn around the card's own border.

raven.jpg
8%
8%

brand-assets.zip

payrollz-2026-q1-final.xlsx

quarterly-report.pdf

"use client";

import { useEffect, useState } from "react";

Installation

$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/attachment.json

Usage

import { Attachment } from "@/components/ui/attachment";
 
export function Composer() {
  return (
    <div className="flex flex-wrap gap-4">
      <Attachment name="raven.jpg" src="/raven.jpg" onRemove={remove} />
      <Attachment name="payrollz.xlsx" progress={72} />
      <Attachment name="report.pdf" onRemove={remove} />
    </div>
  );
}

Size

The chip is 56px, which is the floor — smaller and the name and remove button stop being usable. There is no size prop: the chip is a container query context and every internal dimension is a share of its own width, so a width in className scales the whole thing (glyph, type, ring, button) proportionally. Text keeps an absolute minimum so it stays legible at 56px.

States

status defaults to uploading while progress is under 100 and ready after, so passing progress is usually enough. Every attachment shares one status layer — the percentage marker, the ring and the remove button; src only swaps the glyph for a preview, which carries its own identity and never shows the name.

StatusRenders
uploadingProgress arc around the card plus the percentage; a preview fades toward the background
readyGlyph and file name, or a bare preview, with the remove button when onRemove is set
errorThe arc fills and turns text-destructive; a file's name follows suit

The remove button stays hidden while uploading — the mock keeps in-flight chips bare, so cancelling mid-upload is out of scope.

The arc is an SVG path tracing the chip's border: it starts at the top centre and runs clockwise in the theme's primary, or destructive once an upload has failed. Because a preview can be any image, its uploading and error states fade it toward background instead of dimming it, so the arc and percentage keep the same contrast as they do on a glyph chip. The reference mock used a blue accent; swap the token for a literal blue if you want that hue.

Glyph

File attachments pick their glyph from the extension — pdf/docx/txt a page, xlsx/csv a spreadsheet, zip/tar an archive, mp3/wav audio, mp4/mov video, ts/js code, json, and images an image — falling back to a plain page for anything unlisted. Pass icon to override it, or src to render a real thumbnail instead.

Component source

attachment.tsx
"use client";

import {
  File,
  FileArchive,
  FileAudio,
  FileCode,
  FileImage,
  FileJson,
  FileSpreadsheet,
  FileText,
  FileVideo,
  Paperclip,
  X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";

import { cn } from "@/lib/utils";

export type AttachmentStatus = "error" | "ready" | "uploading";

export interface AttachmentProps {
  className?: string;
  /** Replaces the default per-type glyph. */
  icon?: ReactNode;
  /** Used as the label under a file attachment and for assistive tech. */
  name: string;
  onRemove?: () => void;
  /** 0–100. Omit once the upload has finished. */
  progress?: number;
  /** Image attachments render the picture instead of the glyph. */
  src?: string;
  /** Defaults to `uploading` while `progress` is under 100, otherwise `ready`. */
  status?: AttachmentStatus;
}

/**
 * The chip is a container query context and every internal dimension is a share of its
 * own width, so `className="size-20"` scales the whole chip instead of leaving a 56px
 * layout in a bigger box. 56px is the floor: below that the name and the remove button
 * stop being usable. Ratios come from the 112px reference, where 1cqw = 1.12px.
 */
/** The ring traces the card's own border, so the viewBox matches the base size. */
const RING_SIZE = 112;

/** Geometry of the traced rounded rect: inset from the card edge, corner radius and the
 *  stroke weight. */
const RING = { inset: 1.5, radius: 22.5, stroke: 3 } as const;

const RING_EDGE = RING_SIZE - RING.inset;
const RING_CORNER = RING.inset + RING.radius;
const RING_CORNER_END = RING_EDGE - RING.radius;
const arc = `A ${RING.radius} ${RING.radius} 0 0 1`;

/** The rounded rect as an explicit path, starting at the top centre and running
 *  clockwise. A `<rect>` would start just after the top-left corner, which `dashoffset`
 *  cannot fix: with a dash as long as the path the visible run is always pinned to the
 *  path start. */
const RING_PATH = [
  `M ${RING_SIZE / 2} ${RING.inset}`,
  `H ${RING_CORNER_END}`,
  `${arc} ${RING_EDGE} ${RING_CORNER}`,
  `V ${RING_CORNER_END}`,
  `${arc} ${RING_CORNER_END} ${RING_EDGE}`,
  `H ${RING_CORNER}`,
  `${arc} ${RING.inset} ${RING_CORNER_END}`,
  `V ${RING_CORNER}`,
  `${arc} ${RING_CORNER} ${RING.inset}`,
  "Z",
].join(" ");

/** Glyph per file extension; anything unlisted falls back to a plain page. */
const FILE_GLYPHS: Record<string, LucideIcon> = {
  avif: FileImage,
  bmp: FileImage,
  csv: FileSpreadsheet,
  doc: FileText,
  docx: FileText,
  gif: FileImage,
  gz: FileArchive,
  heic: FileImage,
  jpeg: FileImage,
  jpg: FileImage,
  js: FileCode,
  json: FileJson,
  jsx: FileCode,
  m4a: FileAudio,
  md: FileText,
  mov: FileVideo,
  mp3: FileAudio,
  mp4: FileVideo,
  ods: FileSpreadsheet,
  odt: FileText,
  pdf: FileText,
  png: FileImage,
  ppt: FileSpreadsheet,
  pptx: FileSpreadsheet,
  rar: FileArchive,
  rs: FileCode,
  svg: FileImage,
  tar: FileArchive,
  ts: FileCode,
  tsx: FileCode,
  txt: FileText,
  wav: FileAudio,
  webm: FileVideo,
  webp: FileImage,
  xls: FileSpreadsheet,
  xlsx: FileSpreadsheet,
  zip: FileArchive,
};

const glyphFor = (name: string) => {
  const extension = name.split(".").pop()?.toLowerCase() ?? "";
  return FILE_GLYPHS[extension] ?? File;
};

const clamp = (value: number) => Math.max(0, Math.min(100, value));

/** Failed uploads read in the destructive token, everything else in `primary`. */
const ringTone = (failed: boolean) =>
  failed ? "stroke-destructive" : "stroke-primary";

const resolveStatus = ({
  progress,
  status,
}: {
  progress?: number;
  status?: AttachmentStatus;
}): AttachmentStatus => {
  if (status) {
    return status;
  }
  if (progress === undefined || progress >= 100) {
    return "ready";
  }
  return "uploading";
};

const resolveA11y = ({
  failed,
  name,
  percent,
  uploading,
}: {
  failed: boolean;
  name: string;
  percent: number;
  uploading: boolean;
}) => {
  if (uploading) {
    return {
      "aria-busy": true,
      "aria-label": name,
      "aria-valuemax": 100,
      "aria-valuemin": 0,
      "aria-valuenow": percent,
      role: "progressbar",
    };
  }
  if (failed) {
    return { "aria-label": name };
  }
  return {};
};

const AttachmentRing = ({
  failed,
  percent,
}: {
  failed: boolean;
  percent: number;
}) => (
  <svg
    aria-hidden="true"
    className="pointer-events-none absolute inset-0 size-full"
    fill="none"
    viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
  >
    {/* `pathLength` normalises the perimeter to 100, so the dash maths is just
        `100 - percent` and the run grows clockwise from the top centre. */}
    <path
      className={cn(
        "transition-[stroke-dashoffset] duration-500 ease-linear",
        ringTone(failed)
      )}
      d={RING_PATH}
      pathLength={100}
      strokeDasharray="100"
      strokeDashoffset={100 - percent}
      strokeLinecap="round"
      strokeWidth={RING.stroke}
    />
  </svg>
);

const AttachmentGlyph = ({
  children,
  name,
}: {
  children?: ReactNode;
  name: string;
}) => {
  const Glyph = glyphFor(name);

  if (children) {
    return (
      <span className="shrink-0 text-muted-foreground [&_svg]:size-[28.6cqw]">
        {children}
      </span>
    );
  }

  return (
    // No tile behind the glyph — the icon stands on the card, at the full slot size.
    <span className="relative grid size-[28.6cqw] shrink-0 place-items-center">
      <Glyph
        className="size-[28.6cqw] fill-card stroke-muted-foreground"
        strokeWidth="1.5"
      />
      {/* Card-coloured disc: it knocks the clip out of the page outline. */}
      <span className="absolute -bottom-[5.4cqw] -left-[7.1cqw] grid size-[17.9cqw] place-items-center rounded-full bg-card">
        <Paperclip
          className="size-[14.3cqw] stroke-muted-foreground"
          strokeWidth="2"
        />
      </span>
    </span>
  );
};

const AttachmentBody = ({
  failed,
  icon,
  image,
  name,
  percent,
  uploading,
}: {
  failed: boolean;
  icon?: ReactNode;
  image: boolean;
  name: string;
  percent: number;
  uploading: boolean;
}) => (
  // Container units resolve against an ancestor container, never the element that
  // declares it, so the padding has to sit one layer in.
  <div className="absolute inset-0 flex flex-col p-[12.5cqw]">
    <div className="flex items-start justify-between gap-2">
      {image ? null : <AttachmentGlyph name={name}>{icon}</AttachmentGlyph>}
      {uploading ? (
        <span
          // Ratios would render 8px type at the 56px floor, so text keeps a minimum.
          className="ml-auto pt-0.5 font-medium text-[max(10px,14.3cqw)] text-primary tabular-nums"
        >
          {percent}%
        </span>
      ) : null}
    </div>
    {/* A preview carries its own identity, so it never shows the name. */}
    {image ? null : (
      <p
        className={cn(
          "mt-auto truncate text-[max(9px,12.5cqw)]",
          failed ? "text-destructive" : "text-muted-foreground"
        )}
      >
        {name}
      </p>
    )}
  </div>
);

const AttachmentRemove = ({
  image,
  name,
  onRemove,
}: {
  image: boolean;
  name: string;
  onRemove: () => void;
}) => (
  <button
    aria-label={`Remove ${name}`}
    className={cn(
      "absolute top-[3.6cqw] right-[3.6cqw] grid size-[30.4cqw] cursor-pointer place-items-center rounded-full outline-none transition-[background-color,color,scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring active:scale-95",
      image
        ? "bg-background/50 text-foreground/60 backdrop-blur-sm hover:bg-background/80 hover:text-foreground"
        : "text-muted-foreground/40 hover:bg-foreground/10 hover:text-foreground"
    )}
    onClick={onRemove}
    type="button"
  >
    <X
      className={cn("size-[16.1cqw]", image && "drop-shadow-sm")}
      strokeWidth="1.75"
    />
  </button>
);

export const Attachment = ({
  className,
  icon,
  name,
  onRemove,
  progress,
  src,
  status,
}: AttachmentProps) => {
  const state = resolveStatus({ progress, status });
  const uploading = state === "uploading";
  const failed = state === "error";
  const percent = clamp(progress ?? 0);
  const image = Boolean(src);
  // The mock keeps the uploading chip bare — relax this if cancelling an upload
  // matters more than matching it.
  const removable = Boolean(onRemove) && !uploading;

  return (
    <div
      {...resolveA11y({ failed, name, percent, uploading })}
      className={cn(
        "@container relative size-14 shrink-0 rounded-[21.4%] border border-border bg-card",
        className
      )}
      data-state={state}
    >
      {image ? (
        // The preview replaces the glyph. The wash pulls the photo towards the theme
        // background so the shared ring and percentage stay legible in either mode.
        <span className="absolute inset-0 overflow-hidden rounded-[21.4%]">
          {/* The name still labels the image for assistive tech. */}
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            alt={uploading || failed ? "" : name}
            className="size-full object-cover"
            src={src}
          />
          <span
            aria-hidden="true"
            className={cn(
              "absolute inset-0",
              (uploading || failed) && "bg-background/60"
            )}
          />
        </span>
      ) : null}

      <AttachmentBody
        failed={failed}
        icon={icon}
        image={image}
        name={name}
        percent={percent}
        uploading={uploading}
      />

      {uploading || failed ? (
        <AttachmentRing failed={failed} percent={failed ? 100 : percent} />
      ) : null}

      {removable && onRemove ? (
        <AttachmentRemove image={image} name={name} onRemove={onRemove} />
      ) : null}
    </div>
  );
};