{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chat-bubbles-support-thread","type":"registry:component","title":"Chat bubbles · Support thread","description":"Support conversation with grouped bubbles, quick-reply chips and a typing indicator before each answer.","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":"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#support-thread"}}