{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typing-indicator",
  "type": "registry:ui",
  "title": "Typing Indicator",
  "description": "Who is typing, in words and a dot wave, opening its own height.",
  "categories": [
    "collaboration"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/typing-indicator.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type TypingPerson = { id: string; name: string; src?: string };\n\n/* -------------------------------------------------------------------------------------------------\n * Words\n * -----------------------------------------------------------------------------------------------*/\n\nconst firstName = (name: string) => name.trim().split(/\\s+/)[0] ?? name;\n\n/**\n * The sentence, as parts so names can be set apart from the verb. Names are first names: in a\n * thread everyone already knows who Ana is, and the line has to fit beside a composer.\n *   1 → \"Ana is typing\"   2 → \"Ana and Ben are typing\"   3 → \"Ana, Ben and Chen are typing\"\n *   4+ → \"Ana, Ben and 3 others are typing\"\n */\nexport function typingParts(people: TypingPerson[]): { names: string[]; rest: number; text: string } {\n  const all = people.map((p) => firstName(p.name));\n  const names = all.length <= 3 ? all : all.slice(0, 2);\n  const rest = all.length - names.length;\n  const joined =\n    rest > 0\n      ? `${names.join(\", \")} and ${rest} others`\n      : names.length === 1\n        ? names[0]\n        : `${names.slice(0, -1).join(\", \")} and ${names[names.length - 1]}`;\n  return { names, rest, text: all.length ? `${joined} ${all.length === 1 ? \"is\" : \"are\"} typing` : \"\" };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Dots\n * -----------------------------------------------------------------------------------------------*/\n\n// Continuous motion stops when nobody can see it: off screen, or the tab hidden.\nfunction useVisible<T extends Element>() {\n  const ref = useRef<T>(null);\n  const [visible, setVisible] = useState(true);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    let inView = true;\n    const update = () => setVisible(inView && !document.hidden);\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      update();\n    });\n    io.observe(el);\n    document.addEventListener(\"visibilitychange\", update);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", update);\n    };\n  }, []);\n  return [ref, visible] as const;\n}\n\nexport type TypingDotsProps = React.ComponentProps<\"span\"> & {\n  /** Dot diameter in px. */\n  dot?: number;\n};\n\n/** Three dots in a wave, 150ms apart. Pauses off screen. Decorative: pair it with words. */\nexport function TypingDots({ dot = 4, className, style, ...rest }: TypingDotsProps) {\n  const [ref, visible] = useVisible<HTMLSpanElement>();\n  return (\n    <span\n      ref={ref}\n      aria-hidden\n      data-paused={visible ? undefined : \"\"}\n      className={cn(\"inline-flex items-center data-paused:[&>span]:[animation-play-state:paused]\", className)}\n      style={{ gap: Math.max(2, Math.round(dot * 0.75)), ...style }}\n      {...rest}\n    >\n      {[0, 1, 2].map((i) => (\n        <span\n          key={i}\n          className=\"block animate-dot-wave rounded-full bg-current motion-reduce:animate-none motion-reduce:opacity-60\"\n          style={{ width: dot, height: dot, animationDelay: `${i * 150}ms` }}\n        />\n      ))}\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Faces (bubble variant)\n * -----------------------------------------------------------------------------------------------*/\n\nconst tones = [\"bg-fg/[0.07] text-fg-2\", \"bg-fg/[0.1] text-fg-2\", \"bg-fg/[0.13] text-fg\", \"bg-fg/[0.17] text-fg\", \"bg-fg/[0.22] text-fg\"];\nfunction tone(key: string) {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193);\n  return tones[(h >>> 0) % tones.length];\n}\n\nfunction Face({ person, size, cut }: { person: TypingPerson; size: number; cut?: string }) {\n  const [broken, setBroken] = useState(false);\n  const letter = (Array.from(person.name.trim())[0] ?? \"\").toUpperCase();\n  return (\n    <span\n      className={cn(\"relative grid shrink-0 place-items-center overflow-hidden rounded-full font-medium leading-none\", tone(person.id))}\n      style={{ width: size, height: size, fontSize: Math.round(size * 0.42), maskImage: cut, WebkitMaskImage: cut }}\n    >\n      {letter}\n      {person.src && !broken && (\n        // eslint-disable-next-line @next/next/no-img-element -- a 20px face from any host, no optimizer in the way\n        <img src={person.src} alt=\"\" width={size} height={size} onError={() => setBroken(true)} className=\"absolute inset-0 size-full object-cover\" />\n      )}\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * TypingIndicator\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TypingIndicatorProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Everyone typing right now, the viewer excluded. Empty collapses the row to nothing. */\n  people: TypingPerson[];\n  /** A line of text under a composer, or a message-shaped bubble at the end of a thread. */\n  variant?: \"inline\" | \"bubble\";\n  /** Announce who started typing to screen readers. Off for busy channels. */\n  announce?: boolean;\n};\n\n/**\n * Says who is typing, with three dots in a wave. It opens and closes its own height so the thread\n * above never jumps, and the sentence slides to its new wording as people start and stop.\n */\nexport function TypingIndicator({ people, variant = \"inline\", announce = true, className, ...rest }: TypingIndicatorProps) {\n  const reduce = useReducedMotion();\n  const { names, rest: others, text } = typingParts(people);\n  const active = people.length > 0;\n\n  const height = reduce\n    ? { duration: 0 }\n    : { height: { duration: 0.24, ease: ease.inOut }, opacity: { duration: 0.2, ease: ease.out }, y: { duration: 0.24, ease: ease.out } };\n\n  return (\n    <div data-variant={variant} data-state={active ? \"active\" : \"idle\"} className={cn(\"relative\", className)} {...rest}>\n      {/* The live region stays in the DOM when empty, so the first \"is typing\" is read out. */}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce ? text : \"\"}\n      </span>\n      <AnimatePresence initial={false}>\n        {active && (\n          <motion.div\n            key=\"row\"\n            aria-hidden\n            className=\"overflow-hidden\"\n            // Named variants so the bubble inside can inherit them and grow with the row.\n            variants={{\n              hidden: { height: 0, opacity: 0, y: reduce ? 0 : 4 },\n              shown: { height: \"auto\", opacity: 1, y: 0, transition: height },\n              gone: { height: 0, opacity: 0, y: 0, transition: reduce ? { duration: 0 } : { height: { duration: 0.18, ease: ease.inOut }, opacity: { duration: 0.12 } } },\n            }}\n            initial=\"hidden\"\n            animate=\"shown\"\n            exit=\"gone\"\n          >\n            {variant === \"bubble\" ? <Bubble people={people} reduce={!!reduce} /> : <Line names={names} others={others} text={text} reduce={!!reduce} />}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Line({ names, others, text, reduce }: { names: string[]; others: number; text: string; reduce: boolean }) {\n  const verb = names.length + others === 1 ? \"is\" : \"are\";\n  return (\n    <div className=\"flex h-6 min-w-0 items-center gap-2 text-[12px] text-fg-3\">\n      <TypingDots dot={4} className=\"w-5 shrink-0 justify-center text-fg-3\" />\n      {/* Each wording is its own layer, so a change rolls the line instead of rewriting it in place. */}\n      <span className=\"relative grid min-w-0 flex-1 overflow-hidden\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={text}\n            className=\"col-start-1 row-start-1 truncate\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n            transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}\n          >\n            {names.map((n, i) => (\n              <span key={i}>\n                {i > 0 && (i === names.length - 1 && others === 0 ? \" and \" : \", \")}\n                <span className=\"font-medium text-fg-2\">{n}</span>\n              </span>\n            ))}\n            {others > 0 && (\n              <>\n                {\" and \"}\n                <span className=\"font-medium text-fg-2 tabular\">{others} others</span>\n              </>\n            )}{\" \"}\n            {verb} typing\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </div>\n  );\n}\n\nfunction Bubble({ people, reduce }: { people: TypingPerson[]; reduce: boolean }) {\n  const faces = people.slice(0, 2);\n  const size = 22;\n  const overlap = 7;\n  const cut = `radial-gradient(circle at ${overlap - size / 2}px 50%, transparent ${size / 2 + 1.5}px, var(--fg) ${size / 2 + 2}px)`;\n  return (\n    <div className=\"flex items-end gap-2 pt-1.5 pb-0.5\">\n      <span className=\"flex shrink-0\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {faces.map((p, i) => (\n            <motion.span\n              key={p.id}\n              layout={!reduce}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}\n              transition={spring.pop}\n              className=\"relative flex\"\n              style={{ zIndex: 2 - i, marginLeft: i ? -overlap : 0 }}\n            >\n              <Face person={p} size={size} cut={i ? cut : undefined} />\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n      {/* The bubble grows out of its tail corner, the way the message it stands in for will. */}\n      <motion.span\n        variants={{\n          hidden: reduce ? {} : { scale: 0.8 },\n          shown: { scale: 1, transition: reduce ? { duration: 0 } : { ...spring.snappy, delay: 0.04 } },\n          gone: {},\n        }}\n        className=\"flex h-8 origin-bottom-left items-center rounded-2xl rounded-bl-md border border-line bg-hover px-3 text-fg-3\"\n      >\n        <TypingDots dot={5} />\n      </motion.span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/typing-indicator"
}
