{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chat-bubbles-attachments","type":"registry:component","title":"Chat bubbles · Attachments","description":"File message with a ring-progress download and a voice note with a seekable waveform and speed control.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/examples/chat-bubbles/attachments.tsx","type":"registry:component","target":"components/examples/chat-bubbles/attachments.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowDown, Check, FileText, Pause, Play } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AttachmentMessagesProps {\n  sender?: string;\n  note?: string;\n  file?: { name: string; size: string; pages?: number };\n  /** Voice note length in seconds. */\n  voiceSeconds?: number;\n  /** Simulated download time, in ms. */\n  downloadMs?: number;\n  onDownload?: () => void;\n  className?: string;\n}\n\nconst BARS = Array.from({ length: 42 }, (_, i) => Math.round(5 + Math.abs(Math.sin(i * 1.7) * 0.55 + Math.sin(i * 0.43) * 0.45) * 20));\nconst SPEEDS = [1, 1.5, 2] as const;\nconst clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, \"0\")}`;\n\n/** Chat messages carrying a file with a ring-progress download and a voice note with a seekable waveform, play/pause morph and speed control. */\nexport function AttachmentMessages({\n  sender = \"Priya\",\n  note = \"Brand audit is ready. Pages 4 to 9 cover the new type scale.\",\n  file = { name: \"Q3-brand-audit.pdf\", size: \"2.4 MB\", pages: 18 },\n  voiceSeconds = 34,\n  downloadMs = 1600,\n  onDownload,\n  className,\n}: AttachmentMessagesProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [dl, setDl] = React.useState<\"idle\" | \"loading\" | \"done\">(\"idle\");\n  const [playing, setPlaying] = React.useState(false);\n  const [pos, setPos] = React.useState(0);\n  const [speed, setSpeed] = React.useState<(typeof SPEEDS)[number]>(1);\n  const dlTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const posRef = React.useRef(0);\n\n  function seek(seconds: number) {\n    posRef.current = seconds;\n    setPos(seconds);\n  }\n\n  React.useEffect(() => () => {\n    if (dlTimer.current) clearTimeout(dlTimer.current);\n  }, []);\n\n  React.useEffect(() => {\n    if (!playing) return;\n    const t = setInterval(() => {\n      const next = posRef.current + 0.1 * speed;\n      const ended = next >= voiceSeconds;\n      posRef.current = ended ? 0 : next;\n      setPos(posRef.current);\n      if (ended) setPlaying(false);\n    }, 100);\n    return () => clearInterval(t);\n  }, [playing, speed, voiceSeconds]);\n\n  function download() {\n    if (dl !== \"idle\") return;\n    setDl(\"loading\");\n    onDownload?.();\n    dlTimer.current = setTimeout(() => setDl(\"done\"), downloadMs);\n  }\n\n  function seekFromPointer(e: React.PointerEvent<HTMLDivElement>) {\n    const r = e.currentTarget.getBoundingClientRect();\n    seek(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * voiceSeconds);\n  }\n\n  const played = pos / voiceSeconds;\n\n  return (\n    <div role=\"log\" aria-label={`Conversation with ${sender}`} className={cn(\"flex w-full max-w-[400px] flex-col gap-2 rounded-xl border border-border bg-surface-raised p-4 shadow-sm\", className)}>\n      <p className=\"max-w-[85%] self-start rounded-lg rounded-bl-xs border border-border bg-surface px-3.5 py-2 text-[13.5px] leading-5 text-ink\">{note}</p>\n\n      <div className=\"flex w-[85%] items-center gap-3 self-start rounded-lg rounded-tl-xs border border-border bg-surface p-2.5\">\n        <span aria-hidden className=\"relative grid h-11 w-9 shrink-0 place-items-center rounded-xs bg-surface-sunken text-ink-muted\">\n          <FileText className=\"size-4\" />\n          <span className=\"absolute -bottom-1 -right-1 rounded-[4px] bg-ink px-1 font-mono text-[8px] font-semibold text-bg\">PDF</span>\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"truncate text-[13px] font-medium text-ink\">{file.name}</p>\n          <p className=\"text-[11.5px] text-ink-subtle\">\n            {file.size}\n            {file.pages ? ` · ${file.pages} pages` : \"\"}\n            {dl === \"done\" && <span className=\"text-ink-muted\"> · Saved</span>}\n          </p>\n        </div>\n        <button\n          type=\"button\"\n          onClick={download}\n          aria-label={dl === \"done\" ? `${file.name} downloaded` : dl === \"loading\" ? `Downloading ${file.name}` : `Download ${file.name}`}\n          className=\"relative grid size-9 shrink-0 place-items-center rounded-full border border-border-strong text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]\"\n        >\n          {dl === \"loading\" && (\n            <svg viewBox=\"0 0 36 36\" className=\"absolute -inset-px -rotate-90\" aria-hidden>\n              <motion.circle cx=\"18\" cy=\"18\" r=\"16.5\" fill=\"none\" strokeWidth=\"2.5\" strokeLinecap=\"round\" className=\"stroke-volt-ink\" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: downloadMs / 1000, ease: \"easeInOut\" }} />\n            </svg>\n          )}\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span key={dl === \"done\" ? \"d\" : \"a\"} initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.5, opacity: 0 }} transition={{ type: \"spring\", stiffness: 500, damping: 26 }}>\n              {dl === \"done\" ? <Check aria-hidden className=\"size-4 text-volt-ink\" strokeWidth={2.75} /> : <ArrowDown aria-hidden className=\"size-4\" />}\n            </motion.span>\n          </AnimatePresence>\n        </button>\n      </div>\n\n      <span className=\"mb-1 self-start px-1 text-[11px] text-ink-subtle\">{sender} · 4:12</span>\n\n      <div className=\"flex w-[88%] items-center gap-2.5 self-end rounded-lg rounded-br-xs bg-ink py-2 pl-2 pr-3 text-bg\">\n        <motion.button\n          type=\"button\"\n          aria-label={playing ? \"Pause voice note\" : \"Play voice note\"}\n          onClick={() => setPlaying((p) => !p)}\n          whileTap={reduce ? undefined : { scale: 0.9 }}\n          className=\"grid size-9 shrink-0 place-items-center rounded-full bg-bg text-ink outline-none focus-visible:shadow-[var(--focus-ring)]\"\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span key={playing ? \"p\" : \"s\"} initial={{ scale: 0.4, opacity: 0, rotate: reduce ? 0 : -45 }} animate={{ scale: 1, opacity: 1, rotate: 0 }} exit={{ scale: 0.4, opacity: 0 }} transition={{ type: \"spring\", stiffness: 520, damping: 28 }}>\n              {playing ? <Pause aria-hidden className=\"size-4 fill-current\" /> : <Play aria-hidden className=\"ml-0.5 size-4 fill-current\" />}\n            </motion.span>\n          </AnimatePresence>\n        </motion.button>\n        <div\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Voice note position\"\n          aria-valuemin={0}\n          aria-valuemax={voiceSeconds}\n          aria-valuenow={Math.round(pos)}\n          aria-valuetext={`${clock(pos)} of ${clock(voiceSeconds)}`}\n          onPointerDown={seekFromPointer}\n          onKeyDown={(e) => {\n            const step = e.key === \"ArrowRight\" ? 5 : e.key === \"ArrowLeft\" ? -5 : 0;\n            if (!step) return;\n            e.preventDefault();\n            seek(Math.max(0, Math.min(voiceSeconds - 0.1, posRef.current + step)));\n          }}\n          className=\"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-[2px] rounded-xs outline-none focus-visible:shadow-[var(--focus-ring)]\"\n        >\n          {BARS.map((h, i) => (\n            <span\n              key={i}\n              aria-hidden\n              style={{ height: h }}\n              className={cn(\"w-[3px] flex-1 rounded-full transition-colors duration-150\", i / BARS.length < played ? \"bg-bg\" : \"bg-bg/30\")}\n            />\n          ))}\n        </div>\n        <span className=\"w-9 shrink-0 text-right font-mono text-[11px] tabular-nums text-bg/80\">{clock(playing || pos > 0 ? pos : voiceSeconds)}</span>\n        <button\n          type=\"button\"\n          aria-label={`Playback speed ${speed}x`}\n          onClick={() => setSpeed((s) => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length])}\n          className=\"h-6 w-10 shrink-0 rounded-full bg-bg/15 font-mono text-[11px] font-semibold text-bg outline-none transition-colors hover:bg-bg/25 focus-visible:shadow-[var(--focus-ring)]\"\n        >\n          {speed}x\n        </button>\n      </div>\n      <span className=\"self-end px-1 text-[11px] text-ink-subtle\">You · 4:15</span>\n      <span role=\"status\" className=\"sr-only\">\n        {dl === \"done\" ? `${file.name} downloaded` : \"\"}\n      </span>\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["examples","chat","message","bubble","support","typing indicator","reactions","voice note","attachment","read receipt"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/chat-bubbles#attachments"}}