{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chat-thread","type":"registry:component","title":"Chat thread","description":"Replies arrive after a beat of typing, and the thread follows only if you are.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/chat-thread.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ChatMessage = { id: number; from: \"me\" | \"them\"; text: string };\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst ENTER = { duration: 0.25, ease: EASE_OUT };\nconst LEAVE = { duration: 0.15, ease: EASE_OUT };\nconst INSTANT = { duration: 0 };\n// Within this distance of the end still counts as \"reading the latest\", so\n// a few pixels of drift never stops the thread from following along.\nconst NEAR_BOTTOM = 64;\n// A beat before the dots show, as if the reply is being read first, then\n// long enough typing to register without making anyone wait.\nconst READ_DELAY = 500;\nconst TYPING_TIME = 1200;\n// Bubble corners: open 20px, and 6px where two bubbles from the same sender\n// meet, so a run reads as one grouped block.\nconst OPEN = 20;\nconst JOINED = 6;\n\nconst CSS = `\n.chat-dot {\n  /* Symmetric ease so each dot rises and falls like a bounce, not a tick. */\n  animation: chat-bounce 1.2s cubic-bezier(0.45, 0, 0.55, 1) infinite;\n}\n.chat-dot:nth-child(2) { animation-delay: 150ms; }\n.chat-dot:nth-child(3) { animation-delay: 300ms; }\n@keyframes chat-bounce {\n  0%, 60%, 100% { translate: 0 0; opacity: 0.4; }\n  30% { translate: 0 -4px; opacity: 1; }\n}\n@keyframes chat-fade {\n  0%, 60%, 100% { opacity: 0.4; }\n  30% { opacity: 1; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .chat-dot { animation-name: chat-fade; }\n}\n`;\n\nfunction radius(from: ChatMessage[\"from\"], prevSame: boolean, nextSame: boolean) {\n  const top = prevSame ? JOINED : OPEN;\n  const bottom = nextSame ? JOINED : OPEN;\n  // Corners go top-left, top-right, bottom-right, bottom-left; only the\n  // side the bubbles hang from gets tightened.\n  return from === \"me\"\n    ? `${OPEN}px ${top}px ${bottom}px ${OPEN}px`\n    : `${top}px ${OPEN}px ${OPEN}px ${bottom}px`;\n}\n\nexport function ChatThread({\n  name,\n  initialMessages,\n  replies,\n  className,\n}: {\n  name: string;\n  initialMessages: ChatMessage[];\n  replies: string[];\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [messages, setMessages] = useState(initialMessages);\n  const [typing, setTyping] = useState(false);\n  const [draft, setDraft] = useState(\"\");\n  const [unseen, setUnseen] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n  const nextId = useRef(initialMessages.length);\n  const nextReply = useRef(0);\n  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n  // Decided before each update, while the old scroll position still says\n  // whether the user was following the thread.\n  const follow = useRef(false);\n\n  const nearBottom = () => {\n    const el = scrollerRef.current;\n    return !el || el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM;\n  };\n\n  useLayoutEffect(() => {\n    const el = scrollerRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, []);\n\n  useEffect(() => {\n    const pending = timers.current;\n    return () => pending.forEach(clearTimeout);\n  }, []);\n\n  useEffect(() => {\n    if (!follow.current) return;\n    follow.current = false;\n    const el = scrollerRef.current;\n    el?.scrollTo({\n      top: el.scrollHeight,\n      behavior: reduceMotion ? \"auto\" : \"smooth\",\n    });\n  }, [messages, typing, reduceMotion]);\n\n  const send = () => {\n    const text = draft.trim();\n    if (!text) return;\n    // Sending is a deliberate act, so your own message always scrolls into\n    // view, even from far up the thread.\n    follow.current = true;\n    setMessages((m) => [...m, { id: nextId.current++, from: \"me\", text }]);\n    setDraft(\"\");\n    // Typing again means they're reading again; the dots come back after.\n    setTyping(false);\n    inputRef.current?.focus();\n\n    // A burst of messages gets one reply, timed from the last of them.\n    // Mutated in place so the unmount cleanup always sees the live timers.\n    timers.current.forEach(clearTimeout);\n    timers.current.length = 0;\n    timers.current.push(\n      setTimeout(() => {\n        follow.current = nearBottom();\n        setTyping(true);\n      }, READ_DELAY),\n      setTimeout(() => {\n        const reply = replies[nextReply.current++ % replies.length];\n        follow.current = nearBottom();\n        if (!follow.current) setUnseen(true);\n        setTyping(false);\n        setMessages((m) => [\n          ...m,\n          { id: nextId.current++, from: \"them\", text: reply },\n        ]);\n        setAnnouncement(`${name}: ${reply}`);\n      }, READ_DELAY + TYPING_TIME),\n    );\n  };\n\n  const toEnd = () => {\n    const el = scrollerRef.current;\n    el?.scrollTo({\n      top: el.scrollHeight,\n      behavior: reduceMotion ? \"auto\" : \"smooth\",\n    });\n  };\n\n  const enter = (from: ChatMessage[\"from\"]) =>\n    reduceMotion\n      ? { opacity: 0 }\n      : from === \"me\"\n        ? // Rises out of the composer it was typed into.\n          { opacity: 0, y: 16, scale: 0.95 }\n        : { opacity: 0, scale: 0.98, filter: \"blur(4px)\" };\n\n  return (\n    // 28px corners around the composer's 8px padding leave 20px, matching\n    // the fully rounded 40px input.\n    <div\n      className={cn(\n        \"flex h-[440px] w-[min(400px,100%)] flex-col overflow-hidden rounded-[28px] bg-background shadow-raised\",\n        className,\n      )}\n    >\n      <style href=\"chat-thread\" precedence=\"default\">\n        {CSS}\n      </style>\n\n      <div className=\"flex h-14 shrink-0 items-center gap-3 border-b border-border px-4\">\n        <span\n          aria-hidden\n          className=\"flex size-8 items-center justify-center rounded-full bg-foreground/10 text-sm font-medium text-foreground\"\n        >\n          {name[0]}\n        </span>\n        <div className=\"flex flex-col\">\n          <span className=\"text-[15px] leading-5 font-medium text-foreground\">\n            {name}\n          </span>\n          <span className=\"text-[13px] leading-4 text-muted-foreground\">Online</span>\n        </div>\n      </div>\n\n      <div className=\"relative min-h-0 flex-1\">\n        <motion.div\n          ref={scrollerRef}\n          layoutScroll\n          onScroll={() => {\n            if (nearBottom()) setUnseen(false);\n          }}\n          className=\"h-full overflow-y-auto overscroll-contain px-3 py-4 [scrollbar-width:thin]\"\n        >\n          <ul aria-label={`Conversation with ${name}`} className=\"relative flex flex-col\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {messages.map((m, i) => {\n                const prevSame = messages[i - 1]?.from === m.from;\n                const nextSame = messages[i + 1]?.from === m.from;\n                return (\n                  <motion.li\n                    key={m.id}\n                    // Rows only ever change place, never size, so\n                    // \"position\" keeps the text from stretching.\n                    layout=\"position\"\n                    initial={enter(m.from)}\n                    animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n                    transition={{ ...ENTER, layout: reduceMotion ? INSTANT : ENTER }}\n                    style={{\n                      transformOrigin:\n                        m.from === \"me\" ? \"bottom right\" : \"bottom left\",\n                    }}\n                    className={cn(\n                      \"flex\",\n                      m.from === \"me\" ? \"justify-end\" : \"justify-start\",\n                      i > 0 && (prevSame ? \"mt-0.5\" : \"mt-3\"),\n                    )}\n                  >\n                    <p\n                      style={{ borderRadius: radius(m.from, prevSame, nextSame) }}\n                      className={cn(\n                        // A message joining a run tightens the corner of the\n                        // one above it; easing that keeps the join soft.\n                        \"max-w-[78%] px-3.5 py-2 text-[15px] leading-5 break-words whitespace-pre-wrap transition-[border-radius] duration-200 ease-out\",\n                        m.from === \"me\"\n                          ? \"bg-foreground text-background\"\n                          : \"bg-muted text-foreground\",\n                      )}\n                    >\n                      <span className=\"sr-only\">\n                        {m.from === \"me\" ? \"You: \" : `${name}: `}\n                      </span>\n                      {m.text}\n                    </p>\n                  </motion.li>\n                );\n              })}\n              {typing && (\n                <motion.li\n                  key=\"typing\"\n                  layout=\"position\"\n                  initial={\n                    reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95 }\n                  }\n                  animate={{ opacity: 1, scale: 1 }}\n                  // Fades out under the reply that takes its place, so the\n                  // swap reads as the dots turning into words.\n                  exit={{ opacity: 0, transition: LEAVE }}\n                  transition={{ ...ENTER, layout: reduceMotion ? INSTANT : ENTER }}\n                  style={{ transformOrigin: \"bottom left\" }}\n                  className={cn(\n                    \"flex\",\n                    messages.length > 0 &&\n                      (messages[messages.length - 1].from === \"them\"\n                        ? \"mt-0.5\"\n                        : \"mt-3\"),\n                  )}\n                >\n                  <span className=\"sr-only\">{name} is typing</span>\n                  <span className=\"flex h-9 items-center gap-1 rounded-[20px] bg-muted px-3.5\">\n                    <span className=\"chat-dot size-1.5 rounded-full bg-foreground\" />\n                    <span className=\"chat-dot size-1.5 rounded-full bg-foreground\" />\n                    <span className=\"chat-dot size-1.5 rounded-full bg-foreground\" />\n                  </span>\n                </motion.li>\n              )}\n            </AnimatePresence>\n          </ul>\n        </motion.div>\n\n        {/* A reply that lands while you're reading older messages doesn't\n            yank the thread down; it offers a way there instead. */}\n        <button\n          type=\"button\"\n          onClick={toEnd}\n          inert={!unseen}\n          className={cn(\n            \"absolute bottom-3 left-1/2 flex h-8 -translate-x-1/2 touch-manipulation items-center gap-1.5 rounded-full bg-background pr-3 pl-2.5 text-[13px] font-medium text-foreground shadow-raised outline-hidden select-none\",\n            \"transition-[opacity,translate,scale] ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[opacity]\",\n            unseen\n              ? \"translate-y-0 opacity-100 duration-200\"\n              : \"translate-y-1 opacity-0 duration-150 motion-reduce:translate-y-0\",\n          )}\n        >\n          <svg\n            viewBox=\"0 0 16 16\"\n            className=\"size-3.5\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n          >\n            <path d=\"M8 3v10M3.75 8.75 8 13l4.25-4.25\" />\n          </svg>\n          New message\n        </button>\n      </div>\n\n      <form\n        onSubmit={(e) => {\n          e.preventDefault();\n          send();\n        }}\n        className=\"flex shrink-0 items-end gap-2 border-t border-border p-2\"\n      >\n        <textarea\n          ref={inputRef}\n          rows={1}\n          value={draft}\n          aria-label={`Message ${name}`}\n          placeholder=\"Message\"\n          onChange={(e) => setDraft(e.target.value)}\n          onKeyDown={(e) => {\n            // Enter sends, Shift+Enter keeps the newline, and a key that\n            // confirms an IME composition is left alone.\n            if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n              e.preventDefault();\n              send();\n            }\n          }}\n          // Grows with its content up to five lines, then scrolls.\n          className=\"max-h-[120px] min-h-10 flex-1 resize-none rounded-[20px] bg-muted px-4 py-2.5 text-[15px] leading-5 text-foreground outline-hidden [field-sizing:content] placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-0 focus-visible:outline-foreground\"\n        />\n        <button\n          type=\"submit\"\n          aria-label=\"Send\"\n          disabled={!draft.trim()}\n          className=\"flex size-10 shrink-0 touch-manipulation items-center justify-center rounded-full bg-foreground text-background outline-hidden transition-[scale,opacity] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] disabled:opacity-30 disabled:active:scale-100 motion-reduce:transition-[opacity]\"\n        >\n          <svg\n            viewBox=\"0 0 16 16\"\n            className=\"size-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n          >\n            <path d=\"M8 13V3M3.75 7.25 8 3l4.25 4.25\" />\n          </svg>\n        </button>\n      </form>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nconst SEED: ChatMessage[] = [\n  { id: 0, from: \"them\", text: \"Hey! Did the new build go out?\" },\n  { id: 1, from: \"them\", text: \"Design wants to see the drawer changes.\" },\n  { id: 2, from: \"me\", text: \"Just shipped it\" },\n  { id: 3, from: \"me\", text: \"The spring on the sheet feels a lot better now\" },\n  { id: 4, from: \"them\", text: \"Nice, checking it on my phone\" },\n];\n\nconst REPLIES = [\n  \"Oh that's so much smoother\",\n  \"Can you send me the link again?\",\n  \"Ha, fair enough\",\n  \"Let's go over it tomorrow morning\",\n  \"Looks great on my end\",\n];\n\nexport default function ChatThreadDemo() {\n  return <ChatThread name=\"Maya\" initialMessages={SEED} replies={REPLIES} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/chat-thread"}