{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chat-bubbles","type":"registry:component","title":"Chat bubbles","description":"Four message threads — support with typing indicator, group chat with reactions, file and voice attachments, and delivery receipts — where every new message springs in.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/examples/chat-bubbles/support-thread.tsx","type":"registry:component","target":"components/examples/chat-bubbles/support-thread.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUp } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SupportMessage {\n  id: string;\n  from: \"agent\" | \"user\";\n  text: string;\n  time: string;\n}\n\nexport interface QuickReply {\n  label: string;\n  /** What the agent answers after a short typing pause. */\n  reply: string;\n}\n\nexport interface SupportThreadProps {\n  agentName?: string;\n  agentRole?: string;\n  messages?: SupportMessage[];\n  quickReplies?: QuickReply[];\n  /** Agent answer for free-typed messages. */\n  fallbackReply?: string;\n  /** How long the typing indicator shows, in ms. */\n  typingDelay?: number;\n  onSend?: (text: string) => void;\n  className?: string;\n}\n\nconst DEFAULT_MESSAGES: SupportMessage[] = [\n  { id: \"m1\", from: \"agent\", text: \"Hi Sam, I'm Noor from the GetUrUI team. I can see your Pro trial ends on Friday.\", time: \"9:41\" },\n  { id: \"m2\", from: \"agent\", text: \"Anything I can help with before then?\", time: \"9:41\" },\n  { id: \"m3\", from: \"user\", text: \"Yes, I want to keep the components my team already installed.\", time: \"9:43\" },\n];\n\nconst DEFAULT_REPLIES: QuickReply[] = [\n  { label: \"Upgrade my plan\", reply: \"Done in a click: open Billing and choose Pro. Everything installed stays exactly where it is.\" },\n  { label: \"Download an invoice\", reply: \"Invoices live under Billing, then History. Each one has a PDF button on the right.\" },\n  { label: \"Add a teammate\", reply: \"Go to Team, then Invite. Seats are added to your next invoice, prorated by the day.\" },\n];\n\nfunction TypingDots({ reduce }: { reduce: boolean }) {\n  return (\n    <span className=\"inline-flex items-center gap-1 px-1 py-1.5\" aria-hidden>\n      {[0, 1, 2].map((i) => (\n        <motion.span\n          key={i}\n          className=\"size-1.5 rounded-full bg-ink-subtle\"\n          animate={reduce ? { opacity: [0.3, 1, 0.3] } : { y: [0, -4, 0], opacity: [0.5, 1, 0.5] }}\n          transition={{ duration: 0.9, repeat: Infinity, delay: i * 0.15, ease: \"easeInOut\" }}\n        />\n      ))}\n    </span>\n  );\n}\n\n/** Support conversation with grouped bubbles, quick-reply chips and an agent typing indicator before each answer. */\nexport function SupportThread({\n  agentName = \"Noor\",\n  agentRole = \"Customer support\",\n  messages = DEFAULT_MESSAGES,\n  quickReplies = DEFAULT_REPLIES,\n  fallbackReply = \"Thanks, I'm looking into it now and will reply right here.\",\n  typingDelay = 1400,\n  onSend,\n  className,\n}: SupportThreadProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [list, setList] = React.useState(messages);\n  const [chips, setChips] = React.useState(quickReplies);\n  const [typing, setTyping] = React.useState(false);\n  const [draft, setDraft] = React.useState(\"\");\n  const scroller = React.useRef<HTMLDivElement>(null);\n  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);\n  const seq = React.useRef(0);\n  const inputId = React.useId();\n\n  React.useEffect(() => {\n    const pending = timers.current;\n    return () => pending.forEach(clearTimeout);\n  }, []);\n  React.useEffect(() => {\n    scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [list, typing, reduce]);\n\n  function send(text: string, reply: string) {\n    const clean = text.trim();\n    if (!clean) return;\n    seq.current += 1;\n    const n = seq.current;\n    setList((l) => [...l, { id: `u${n}`, from: \"user\", text: clean, time: \"Now\" }]);\n    onSend?.(clean);\n    setTyping(true);\n    timers.current.push(\n      setTimeout(() => {\n        setTyping(false);\n        setList((l) => [...l, { id: `a${n}`, from: \"agent\", text: reply, time: \"Now\" }]);\n      }, typingDelay),\n    );\n  }\n\n  return (\n    <section aria-label={`Chat with ${agentName}`} className={cn(\"flex w-full max-w-[400px] flex-col overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm\", className)}>\n      <header className=\"flex items-center gap-3 border-b border-border px-4 py-3\">\n        <span className=\"relative grid size-9 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg\" aria-hidden>\n          {agentName.slice(0, 1)}\n          <span className=\"absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-surface-raised bg-success\" />\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"text-[14px] font-semibold text-ink\">{agentName}</p>\n          <p className=\"text-xs text-ink-subtle\">\n            {agentRole} · <span className=\"text-success\">Online</span>\n          </p>\n        </div>\n        <span className=\"rounded-full bg-surface-sunken px-2 py-0.5 text-[11px] text-ink-muted\">Replies in ~2 min</span>\n      </header>\n\n      <div ref={scroller} role=\"log\" aria-live=\"polite\" className=\"flex h-[280px] flex-col gap-1 overflow-y-auto px-4 py-4\">\n        {list.map((m, i) => {\n          const mine = m.from === \"user\";\n          const lastOfGroup = list[i + 1]?.from !== m.from;\n          return (\n            <motion.div\n              key={m.id}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.96 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              transition={{ type: \"spring\", stiffness: 380, damping: 30 }}\n              style={{ originX: mine ? 1 : 0 }}\n              className={cn(\"flex flex-col\", mine ? \"items-end\" : \"items-start\", lastOfGroup && \"mb-2\")}\n            >\n              <p\n                className={cn(\n                  \"max-w-[82%] px-3.5 py-2 text-[13.5px] leading-5\",\n                  mine ? \"rounded-lg bg-ink text-bg\" : \"rounded-lg border border-border bg-surface text-ink\",\n                  lastOfGroup && (mine ? \"rounded-br-xs\" : \"rounded-bl-xs\"),\n                )}\n              >\n                <span className=\"sr-only\">{mine ? \"You\" : agentName}: </span>\n                {m.text}\n              </p>\n              {lastOfGroup && <span className=\"mt-1 px-1 text-[11px] text-ink-subtle\">{m.time}</span>}\n            </motion.div>\n          );\n        })}\n        <AnimatePresence>\n          {typing && (\n            <motion.div\n              key=\"typing\"\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, scale: reduce ? 1 : 0.9 }}\n              className=\"w-fit rounded-lg rounded-bl-xs border border-border bg-surface px-3 py-1.5\"\n            >\n              <TypingDots reduce={reduce} />\n              <span className=\"sr-only\">{agentName} is typing</span>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"border-t border-border p-3\">\n        {chips.length > 0 && (\n          <div className=\"mb-2.5 flex flex-wrap gap-1.5\">\n            <AnimatePresence initial={false}>\n              {chips.map((c) => (\n                <motion.button\n                  key={c.label}\n                  type=\"button\"\n                  layout={!reduce}\n                  exit={{ opacity: 0, scale: 0.8 }}\n                  whileTap={reduce ? undefined : { scale: 0.95 }}\n                  disabled={typing}\n                  onClick={() => {\n                    setChips((all) => all.filter((x) => x.label !== c.label));\n                    send(c.label, c.reply);\n                  }}\n                  className=\"rounded-full border border-border-strong px-3 py-1 text-[12.5px] font-medium text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)] disabled:opacity-50\"\n                >\n                  {c.label}\n                </motion.button>\n              ))}\n            </AnimatePresence>\n          </div>\n        )}\n        <form\n          onSubmit={(e) => {\n            e.preventDefault();\n            send(draft, fallbackReply);\n            setDraft(\"\");\n          }}\n          className=\"flex items-center gap-2 rounded-full border border-border-strong bg-surface py-1 pl-4 pr-1 focus-within:shadow-[var(--focus-ring)]\"\n        >\n          <label htmlFor={inputId} className=\"sr-only\">\n            Message {agentName}\n          </label>\n          <input\n            id={inputId}\n            value={draft}\n            onChange={(e) => setDraft(e.target.value)}\n            placeholder={`Message ${agentName}`}\n            autoComplete=\"off\"\n            className=\"min-w-0 flex-1 bg-transparent text-[13.5px] text-ink outline-none placeholder:text-ink-subtle\"\n          />\n          <motion.button\n            type=\"submit\"\n            aria-label=\"Send message\"\n            disabled={!draft.trim() || typing}\n            whileTap={reduce ? undefined : { scale: 0.9 }}\n            className=\"grid size-8 shrink-0 place-items-center rounded-full bg-volt text-on-volt outline-none transition-colors disabled:bg-surface-sunken disabled:text-ink-subtle\"\n          >\n            <ArrowUp aria-hidden className=\"size-4\" strokeWidth={2.5} />\n          </motion.button>\n        </form>\n      </div>\n    </section>\n  );\n}\n"},{"path":"registry/geturui/examples/chat-bubbles/group-reactions.tsx","type":"registry:component","target":"components/examples/chat-bubbles/group-reactions.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Heart, Laugh, SmilePlus, Sparkles, ThumbsUp, type LucideIcon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ReactionKey = \"agree\" | \"love\" | \"brilliant\" | \"funny\";\n\nexport interface GroupMessage {\n  id: string;\n  author: string;\n  time: string;\n  text: string;\n  /** Count from other people per reaction. */\n  reactions?: Partial<Record<ReactionKey, number>>;\n  /** Reactions the current user has added. */\n  mine?: ReactionKey[];\n}\n\nexport interface GroupChatProps {\n  channel?: string;\n  members?: number;\n  messages?: GroupMessage[];\n  onReact?: (messageId: string, reaction: ReactionKey, active: boolean) => void;\n  className?: string;\n}\n\nconst REACTIONS: { key: ReactionKey; label: string; Icon: LucideIcon }[] = [\n  { key: \"agree\", label: \"Agree\", Icon: ThumbsUp },\n  { key: \"love\", label: \"Love\", Icon: Heart },\n  { key: \"brilliant\", label: \"Brilliant\", Icon: Sparkles },\n  { key: \"funny\", label: \"Funny\", Icon: Laugh },\n];\n\nconst DEFAULT_MESSAGES: GroupMessage[] = [\n  { id: \"g1\", author: \"Priya Nair\", time: \"10:02\", text: \"Launch checklist is green. Docs, changelog and the pricing page are live on staging.\", reactions: { agree: 3, love: 1 } },\n  { id: \"g2\", author: \"Priya Nair\", time: \"10:02\", text: \"Can someone give the hero animation one last look on a slow phone?\" },\n  { id: \"g3\", author: \"Jonah Reyes\", time: \"10:05\", text: \"Checked on a four-year-old Android. Smooth, and it falls back to a fade with reduced motion on.\", reactions: { brilliant: 2 }, mine: [\"agree\"] },\n  { id: \"g4\", author: \"Theo Lind\", time: \"10:07\", text: \"Then we ship at 11. I'll post in the announcements channel.\", reactions: { love: 4, funny: 1 } },\n];\n\nconst TONES = [\"bg-ink text-bg\", \"bg-surface-sunken text-ink border border-border\", \"bg-ember-soft text-ember-ink\"];\nconst initialsOf = (n: string) => n.split(/\\s+/).map((p) => p[0]).slice(0, 2).join(\"\").toUpperCase();\nconst spring = { type: \"spring\", stiffness: 420, damping: 30 } as const;\n\n/** Group chat with grouped senders, toggleable reaction pills whose counts roll, and an inline reaction picker per message. */\nexport function GroupChat({ channel = \"launch-week\", members = 8, messages = DEFAULT_MESSAGES, onReact, className }: GroupChatProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [mine, setMine] = React.useState<Record<string, ReactionKey[]>>(() => Object.fromEntries(messages.map((m) => [m.id, m.mine ?? []])));\n  const [picker, setPicker] = React.useState<string | null>(null);\n  const authors = Array.from(new Set(messages.map((m) => m.author)));\n\n  function toggle(messageId: string, key: ReactionKey) {\n    const has = mine[messageId]?.includes(key) ?? false;\n    setMine((s) => ({ ...s, [messageId]: has ? s[messageId].filter((k) => k !== key) : [...(s[messageId] ?? []), key] }));\n    onReact?.(messageId, key, !has);\n  }\n\n  return (\n    <section aria-label={`#${channel}`} className={cn(\"w-full max-w-[460px] rounded-xl border border-border bg-surface-raised shadow-sm\", className)}>\n      <header className=\"flex items-center justify-between border-b border-border px-4 py-3\">\n        <p className=\"font-display text-[15px] font-semibold text-ink\">\n          <span className=\"text-ink-subtle\">#</span>\n          {channel}\n        </p>\n        <p className=\"text-xs text-ink-subtle\">{members} members</p>\n      </header>\n      <ol className=\"flex flex-col px-2 py-3\">\n        {messages.map((m, i) => {\n          const first = messages[i - 1]?.author !== m.author;\n          const active = mine[m.id] ?? [];\n          const shown = REACTIONS.filter((r) => (m.reactions?.[r.key] ?? 0) + (active.includes(r.key) ? 1 : 0) > 0);\n          const tone = TONES[authors.indexOf(m.author) % TONES.length];\n          return (\n            <li\n              key={m.id}\n              onKeyDown={(e) => {\n                if (e.key !== \"Escape\" || picker !== m.id) return;\n                setPicker(null);\n                e.currentTarget.querySelector<HTMLButtonElement>(\"[data-add-reaction]\")?.focus();\n              }}\n              className={cn(\"group relative flex gap-3 rounded-lg px-2 py-1 transition-colors hover:bg-surface-hover\", first && i > 0 && \"mt-2\")}\n            >\n              <span aria-hidden className={cn(\"mt-0.5 grid size-8 shrink-0 place-items-center rounded-full font-display text-[11px] font-semibold\", first ? tone : \"invisible\")}>\n                {initialsOf(m.author)}\n              </span>\n              <div className=\"min-w-0 flex-1\">\n                {first && (\n                  <p className=\"flex items-baseline gap-2\">\n                    <span className=\"text-[13.5px] font-semibold text-ink\">{m.author}</span>\n                    <span className=\"text-[11px] text-ink-subtle\">{m.time}</span>\n                  </p>\n                )}\n                <p className=\"text-[13.5px] leading-5 text-ink-muted\">{m.text}</p>\n                {shown.length > 0 && (\n                  <div className=\"mt-1.5 flex flex-wrap gap-1\">\n                    {shown.map(({ key, label, Icon }) => {\n                      const on = active.includes(key);\n                      const n = (m.reactions?.[key] ?? 0) + (on ? 1 : 0);\n                      return (\n                        <motion.button\n                          key={key}\n                          type=\"button\"\n                          layout={!reduce}\n                          initial={reduce ? false : { scale: 0.6, opacity: 0 }}\n                          animate={{ scale: 1, opacity: 1 }}\n                          whileTap={reduce ? undefined : { scale: 0.9 }}\n                          transition={spring}\n                          aria-pressed={on}\n                          aria-label={`${label}, ${n} ${n === 1 ? \"reaction\" : \"reactions\"}`}\n                          onClick={() => toggle(m.id, key)}\n                          className={cn(\n                            \"inline-flex h-6 items-center gap-1 rounded-full border px-2 text-[11.5px] font-medium outline-none transition-colors focus-visible:shadow-[var(--focus-ring)]\",\n                            on ? \"border-volt-ink/40 bg-volt-soft text-ink\" : \"border-border bg-surface text-ink-muted hover:border-border-strong\",\n                          )}\n                        >\n                          <Icon aria-hidden className={cn(\"size-3\", on && \"text-volt-ink\")} strokeWidth={2.4} />\n                          <span className=\"relative inline-flex overflow-hidden tabular-nums\">\n                            <AnimatePresence mode=\"popLayout\" initial={false}>\n                              <motion.span key={n} initial={{ y: reduce ? 0 : 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: reduce ? 0 : -10, opacity: 0 }} transition={spring}>\n                                {n}\n                              </motion.span>\n                            </AnimatePresence>\n                          </span>\n                        </motion.button>\n                      );\n                    })}\n                  </div>\n                )}\n              </div>\n\n              <div className=\"absolute -top-3 right-2 z-10 flex items-center\">\n                <AnimatePresence>\n                  {picker === m.id && (\n                    <motion.div\n                      role=\"group\"\n                      aria-label=\"Pick a reaction\"\n                      initial={{ opacity: 0, scale: reduce ? 1 : 0.85, x: reduce ? 0 : 8 }}\n                      animate={{ opacity: 1, scale: 1, x: 0 }}\n                      exit={{ opacity: 0, scale: reduce ? 1 : 0.85 }}\n                      transition={spring}\n                      className=\"mr-1 flex origin-right gap-0.5 rounded-full border border-border bg-surface-raised p-1 shadow-md\"\n                    >\n                      {REACTIONS.map(({ key, label, Icon }, j) => (\n                        <button\n                          key={key}\n                          type=\"button\"\n                          autoFocus={j === 0}\n                          aria-label={label}\n                          aria-pressed={active.includes(key)}\n                          onClick={() => {\n                            toggle(m.id, key);\n                            setPicker(null);\n                          }}\n                          className=\"grid size-7 place-items-center rounded-full text-ink-muted outline-none transition-colors hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)] aria-pressed:text-volt-ink\"\n                        >\n                          <Icon aria-hidden className=\"size-3.5\" />\n                        </button>\n                      ))}\n                    </motion.div>\n                  )}\n                </AnimatePresence>\n                <button\n                  type=\"button\"\n                  data-add-reaction\n                  aria-label=\"Add reaction\"\n                  aria-expanded={picker === m.id}\n                  onClick={() => setPicker((p) => (p === m.id ? null : m.id))}\n                  className={cn(\n                    \"grid size-7 place-items-center rounded-full border border-border bg-surface-raised text-ink-muted shadow-sm outline-none transition-opacity hover:text-ink focus-visible:opacity-100 focus-visible:shadow-[var(--focus-ring)]\",\n                    picker === m.id ? \"opacity-100\" : \"opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100\",\n                  )}\n                >\n                  <SmilePlus aria-hidden className=\"size-3.5\" />\n                </button>\n              </div>\n            </li>\n          );\n        })}\n      </ol>\n    </section>\n  );\n}\n"},{"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":"registry/geturui/examples/chat-bubbles/delivery-states.tsx","type":"registry:component","target":"components/examples/chat-bubbles/delivery-states.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { AlertCircle, ArrowUp, Check, CheckCheck, Clock3, RotateCw } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DeliveryStatus = \"sending\" | \"sent\" | \"delivered\" | \"read\" | \"failed\";\n\nexport interface DeliveryMessage {\n  id: string;\n  mine: boolean;\n  text: string;\n  time: string;\n  status?: DeliveryStatus;\n}\n\nexport interface DeliveryStatesProps {\n  contact?: string;\n  messages?: DeliveryMessage[];\n  /** Walk new messages through sent, delivered and read. */\n  autoRead?: boolean;\n  onSend?: (text: string) => void;\n  onRetry?: (id: string) => void;\n  className?: string;\n}\n\nconst DEFAULT_MESSAGES: DeliveryMessage[] = [\n  { id: \"d1\", mine: false, text: \"Are the new invoice templates ready for review?\", time: \"9:30\" },\n  { id: \"d2\", mine: true, text: \"Yes, both versions are in the shared folder.\", time: \"9:31\", status: \"read\" },\n  { id: \"d3\", mine: true, text: \"The second one has the tax breakdown you asked for.\", time: \"9:32\", status: \"delivered\" },\n  { id: \"d4\", mine: true, text: \"Sending the PDF export now.\", time: \"9:33\", status: \"failed\" },\n];\n\nconst LABEL: Record<DeliveryStatus, string> = { sending: \"Sending\", sent: \"Sent\", delivered: \"Delivered\", read: \"Read\", failed: \"Not delivered\" };\nconst STEPS: [DeliveryStatus, number][] = [[\"sent\", 700], [\"delivered\", 1500], [\"read\", 2800]];\n\nfunction StatusIcon({ status }: { status: DeliveryStatus }) {\n  const cls = \"size-3.5\";\n  if (status === \"sending\") return <Clock3 aria-hidden className={cls} />;\n  if (status === \"sent\") return <Check aria-hidden className={cls} strokeWidth={2.5} />;\n  if (status === \"failed\") return <AlertCircle aria-hidden className={cn(cls, \"text-danger\")} />;\n  return <CheckCheck aria-hidden className={cn(cls, status === \"read\" && \"text-volt-ink\")} strokeWidth={2.5} />;\n}\n\n/** Direct-message thread where each sent message animates through sending, sent, delivered and read, with a failed state you can retry. */\nexport function DeliveryStates({ contact = \"Theo Lind\", messages = DEFAULT_MESSAGES, autoRead = true, onSend, onRetry, className }: DeliveryStatesProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [list, setList] = React.useState(messages);\n  const [draft, setDraft] = React.useState(\"\");\n  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);\n  const seq = React.useRef(0);\n  const inputId = React.useId();\n\n  React.useEffect(() => {\n    const pending = timers.current;\n    return () => pending.forEach(clearTimeout);\n  }, []);\n\n  function setStatus(id: string, status: DeliveryStatus) {\n    setList((l) => l.map((m) => (m.id === id ? { ...m, status } : m)));\n  }\n\n  function walk(id: string, fail = false) {\n    setStatus(id, \"sending\");\n    if (fail) return;\n    STEPS.filter(([s]) => autoRead || s !== \"read\").forEach(([s, ms]) => timers.current.push(setTimeout(() => setStatus(id, s), ms)));\n  }\n\n  function send(e: React.FormEvent) {\n    e.preventDefault();\n    const text = draft.trim();\n    if (!text) return;\n    seq.current += 1;\n    const id = `new-${seq.current}`;\n    setList((l) => [...l, { id, mine: true, text, time: \"Now\", status: \"sending\" }]);\n    setDraft(\"\");\n    onSend?.(text);\n    walk(id);\n  }\n\n  const lastRead = list.reduce((acc, m, i) => (m.mine && m.status === \"read\" ? i : acc), -1);\n  const lastMine = list.reduce((acc, m, i) => (m.mine ? i : acc), -1);\n\n  return (\n    <section aria-label={`Messages with ${contact}`} className={cn(\"flex w-full max-w-[400px] flex-col rounded-xl border border-border bg-surface-raised shadow-sm\", className)}>\n      <header className=\"flex items-center gap-3 border-b border-border px-4 py-3\">\n        <span aria-hidden className=\"grid size-8 place-items-center rounded-full bg-surface-sunken font-display text-[12px] font-semibold text-ink\">\n          {contact.split(\" \").map((p) => p[0]).join(\"\")}\n        </span>\n        <p className=\"text-[14px] font-semibold text-ink\">{contact}</p>\n      </header>\n\n      <ol aria-live=\"polite\" className=\"flex max-h-[320px] min-h-[240px] flex-col gap-1.5 overflow-y-auto px-4 py-4\">\n        <AnimatePresence initial={false}>\n          {list.map((m, i) => {\n            const status = m.status;\n            return (\n              <motion.li\n                key={m.id}\n                layout={reduce ? false : \"position\"}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.95 }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                transition={{ type: \"spring\", stiffness: 380, damping: 30 }}\n                style={{ originX: m.mine ? 1 : 0 }}\n                className={cn(\"flex flex-col\", m.mine ? \"items-end\" : \"items-start\")}\n              >\n                <p\n                  className={cn(\n                    \"max-w-[82%] rounded-lg px-3.5 py-2 text-[13.5px] leading-5 transition-opacity\",\n                    m.mine ? \"rounded-br-xs bg-ink text-bg\" : \"rounded-bl-xs border border-border bg-surface text-ink\",\n                    status === \"sending\" && \"opacity-70\",\n                    status === \"failed\" && \"opacity-80\",\n                  )}\n                >\n                  {m.text}\n                </p>\n                {m.mine && status && (\n                  <div className=\"mt-1 flex h-5 items-center gap-1.5 px-1 text-[11px] text-ink-subtle\">\n                    <span>{m.time}</span>\n                    <AnimatePresence mode=\"popLayout\" initial={false}>\n                      <motion.span\n                        key={status}\n                        initial={{ opacity: 0, scale: reduce ? 1 : 0.5, filter: \"blur(4px)\" }}\n                        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                        exit={{ opacity: 0, scale: reduce ? 1 : 0.5 }}\n                        transition={{ type: \"spring\", stiffness: 520, damping: 28 }}\n                        className={cn(\"inline-flex items-center gap-1\", status === \"failed\" && \"text-danger\")}\n                      >\n                        <StatusIcon status={status} />\n                        {status === \"failed\" || i === lastRead || i === lastMine ? LABEL[status] : <span className=\"sr-only\">{LABEL[status]}</span>}\n                      </motion.span>\n                    </AnimatePresence>\n                    {status === \"failed\" && (\n                      <button\n                        type=\"button\"\n                        onClick={() => {\n                          onRetry?.(m.id);\n                          walk(m.id);\n                        }}\n                        className=\"inline-flex items-center gap-1 rounded-full px-1.5 font-medium text-ink outline-none underline-offset-2 hover:underline focus-visible:shadow-[var(--focus-ring)]\"\n                      >\n                        <RotateCw aria-hidden className=\"size-3\" />\n                        Retry\n                      </button>\n                    )}\n                  </div>\n                )}\n              </motion.li>\n            );\n          })}\n        </AnimatePresence>\n      </ol>\n\n      <form onSubmit={send} className=\"flex items-center gap-2 border-t border-border p-3\">\n        <label htmlFor={inputId} className=\"sr-only\">\n          Message {contact}\n        </label>\n        <input\n          id={inputId}\n          value={draft}\n          onChange={(e) => setDraft(e.target.value)}\n          placeholder=\"Write a message\"\n          autoComplete=\"off\"\n          className=\"h-10 min-w-0 flex-1 rounded-full border border-border-strong bg-surface px-4 text-[13.5px] text-ink outline-none placeholder:text-ink-subtle focus-visible:shadow-[var(--focus-ring)]\"\n        />\n        <motion.button\n          type=\"submit\"\n          aria-label=\"Send message\"\n          disabled={!draft.trim()}\n          whileTap={reduce ? undefined : { scale: 0.9 }}\n          className=\"grid size-10 shrink-0 place-items-center rounded-full bg-volt text-on-volt outline-none transition-colors focus-visible:shadow-[var(--focus-ring)] disabled:bg-surface-sunken disabled:text-ink-subtle\"\n        >\n          <ArrowUp aria-hidden className=\"size-4\" strokeWidth={2.5} />\n        </motion.button>\n      </form>\n    </section>\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"}}