{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"attachment-chip","type":"registry:component","title":"Attachment chip","description":"A file on its way into a prompt: uploading, being read, ready or failed.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/attachment-chip.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, File, FileCode, FileText, Image as ImageIcon, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * ATTACHMENT CHIP: a file on its way into a prompt\n *\n *   uploading   a progress ring fills around the file icon and\n *               \"Uploading 42%\" rolls\n *   processing  the ring fades; the line morphs to \"Reading\"\n *               under a sweep of light\n *   ready       it morphs again into type and size; can be removed\n *   error       the icon blurs into an alert, the line morphs to\n *               what went wrong, and Retry opens in\n *\n * Images show a thumbnail. The remove button appears on hover or\n * focus, and is always visible on touch screens.\n * ───────────────────────────────────────────────────────── */\n\nexport type AttachmentStatus = \"uploading\" | \"processing\" | \"ready\" | \"error\";\n\nexport interface AttachmentChipProps extends React.HTMLAttributes<HTMLDivElement> {\n  name: string;\n  /** Size in bytes. */\n  size?: number;\n  /** MIME type, e.g. \"application/pdf\". Picks the icon. */\n  type?: string;\n  /** Thumbnail URL for images, e.g. from URL.createObjectURL(file). */\n  previewUrl?: string;\n  status?: AttachmentStatus;\n  /** Upload progress, 0 to 1. */\n  progress?: number;\n  error?: string;\n  onRemove?: () => void;\n  onRetry?: () => void;\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, children }: { show: boolean; reduced: boolean; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className=\"grid shrink-0\"\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity ${show ? \"300ms\" : \"160ms\"} ${MORPH}`,\n      }}\n    >\n      <span className=\"flex min-w-0 items-baseline whitespace-nowrap [clip-path:inset(-4px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\nfunction formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction extension(name: string) {\n  const dot = name.lastIndexOf(\".\");\n  return dot > 0 ? name.slice(dot + 1).toUpperCase() : \"FILE\";\n}\n\nfunction TypeIcon({ type, name }: { type?: string; name: string }) {\n  const t = type ?? \"\";\n  if (t.startsWith(\"image/\")) return <ImageIcon className=\"size-4\" />;\n  if (t === \"application/pdf\" || t.startsWith(\"text/\") || /\\.(pdf|txt|md|docx?)$/i.test(name)) return <FileText className=\"size-4\" />;\n  if (/\\.(tsx?|jsx?|py|go|rs|json|css|html)$/i.test(name)) return <FileCode className=\"size-4\" />;\n  return <File className=\"size-4\" />;\n}\n\nexport function AttachmentChip({\n  name,\n  size,\n  type,\n  previewUrl,\n  status = \"ready\",\n  progress = 0,\n  error,\n  onRemove,\n  onRetry,\n  className = \"\",\n  ...props\n}: AttachmentChipProps) {\n  const reduced = useReducedMotion();\n  const pct = Math.round(Math.min(1, Math.max(0, progress)) * 100);\n  const circumference = 2 * Math.PI * 16;\n  const failed = status === \"error\";\n\n  // One line for the whole journey: it morphs from \"Uploading\" to \"Reading\" to \"PDF · 2.4 MB\" (or the error).\n  const line =\n    status === \"uploading\" ? \"Uploading\" : status === \"processing\" ? \"Reading\" : failed ? (error ?? \"Upload failed\") : `${extension(name)}${size !== undefined ? ` · ${formatBytes(size)}` : \"\"}`;\n  const spoken = status === \"uploading\" ? `Uploading ${name}` : status === \"processing\" ? `Reading ${name}` : failed ? `${name}: ${error ?? \"Upload failed\"}` : `${name} is ready`;\n\n  return (\n    <div\n      className={`group relative inline-flex h-12 max-w-[260px] items-center gap-2.5 rounded-xl border bg-background pl-1.5 pr-3 animate-[ui-fade-up_260ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none ${\n        failed ? \"border-red-500/40\" : \"border-border\"\n      } ${className}`}\n      {...props}\n    >\n      <span className=\"relative flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground\">\n        <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(!failed, reduced)}>\n          {previewUrl ? (\n            <img src={previewUrl} alt=\"\" className={`size-full object-cover transition-opacity duration-300 ${status === \"ready\" ? \"opacity-100\" : \"opacity-50\"}`} />\n          ) : (\n            <TypeIcon type={type} name={name} />\n          )}\n        </span>\n        <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(failed, reduced)}>\n          <AlertCircle className=\"size-4 text-red-500\" />\n        </span>\n        {/* The ring fills while it uploads, then fades rather than vanishing. */}\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 36 36\"\n          className=\"absolute inset-0 size-full -rotate-90\"\n          style={{ opacity: status === \"uploading\" ? 1 : 0, transition: reduced ? \"none\" : `opacity 360ms ${MORPH}` }}\n        >\n          <circle cx=\"18\" cy=\"18\" r=\"16\" fill=\"none\" strokeWidth=\"2\" className=\"stroke-border\" />\n          <circle\n            cx=\"18\"\n            cy=\"18\"\n            r=\"16\"\n            fill=\"none\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeDasharray={circumference}\n            strokeDashoffset={circumference * (1 - (status === \"uploading\" ? pct : 100) / 100)}\n            className=\"stroke-foreground transition-[stroke-dashoffset] duration-300 motion-reduce:transition-none\"\n          />\n        </svg>\n      </span>\n\n      <span className=\"min-w-0\">\n        <span className=\"block truncate text-[12.5px] font-medium leading-4 text-foreground\">{name}</span>\n        <span className={`mt-0.5 flex min-w-0 items-baseline text-[11.5px] leading-4 transition-colors duration-300 ${failed ? \"text-red-500\" : \"text-muted-foreground\"}`}>\n          <span aria-hidden=\"true\" className={`min-w-0 truncate ${status === \"processing\" ? SHEEN : \"\"}`}>\n            <TextMorph>{line}</TextMorph>\n          </span>\n          <Reveal show={status === \"uploading\"} reduced={reduced}>\n            &nbsp;\n            <span aria-hidden=\"true\" className=\"tabular-nums\">\n              <NumberRoll value={pct} suffix=\"%\" duration={400} />\n            </span>\n          </Reveal>\n          {onRetry && (\n            <Reveal show={failed} reduced={reduced}>\n              <span aria-hidden=\"true\">&nbsp;·&nbsp;</span>\n              <button\n                type=\"button\"\n                inert={!failed}\n                onClick={onRetry}\n                className={`rounded font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground ${FOCUS}`}\n              >\n                Retry\n              </button>\n            </Reveal>\n          )}\n        </span>\n        {/* Announces each step, never every percent. */}\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {spoken}\n        </span>\n      </span>\n\n      {onRemove && (\n        <button\n          type=\"button\"\n          aria-label={`Remove ${name}`}\n          onClick={onRemove}\n          className={`absolute -right-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100 ${FOCUS}`}\n        >\n          <X className=\"size-3\" />\n        </button>\n      )}\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}},"@keyframes ui-fade-up":{"from":{"opacity":"0","transform":"translateY(4px)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/attachment-chip"}