{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typing-indicators",
  "type": "registry:component",
  "title": "Typing Indicators",
  "description": "Four quiet ways to say a reply is coming: a dotted bubble, a named line, a stack of faces for busy threads and a soft shimmer for an assistant at work.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/typing-indicators.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { SparklesIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction useReducedMotionAfterMount() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport interface Typer {\n  id: string;\n  name: string;\n  initials: string;\n  avatarClassName?: string;\n}\n\nconst ease = [0.23, 1, 0.32, 1] as const;\n\nfunction Dots({ size = \"md\", className }: { size?: \"sm\" | \"md\"; className?: string }) {\n  const reduce = useReducedMotionAfterMount();\n  return (\n    <span aria-hidden className={cn(\"inline-flex items-center\", size === \"md\" ? \"gap-1\" : \"gap-[3px]\", className)}>\n      {[0, 1, 2].map((i) => (\n        <motion.span\n          animate={reduce ? { opacity: [0.4, 0.9, 0.4] } : { y: [0, size === \"md\" ? -2 : -1.5, 0], opacity: [0.4, 0.9, 0.4] }}\n          className={cn(\"rounded-full bg-current\", size === \"md\" ? \"size-1.5\" : \"size-1\")}\n          key={i}\n          transition={{ duration: 1.4, repeat: Number.POSITIVE_INFINITY, ease: \"easeInOut\", delay: i * 0.18 }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction Avatar({ person, className }: { person: Typer; className?: string }) {\n  return (\n    <span\n      className={cn(\n        \"flex shrink-0 select-none items-center justify-center rounded-full font-medium\",\n        person.avatarClassName ?? \"bg-muted text-foreground\",\n        className,\n      )}\n    >\n      {person.initials}\n    </span>\n  );\n}\n\nexport interface TypingDotsProps {\n  label?: string;\n  className?: string;\n}\n\nexport function TypingDots({ label = \"Someone is typing\", className }: TypingDotsProps) {\n  return (\n    <span\n      aria-label={label}\n      className={cn(\n        \"inline-flex h-9 items-center rounded-2xl rounded-bl-md bg-muted px-3.5 text-muted-foreground\",\n        className,\n      )}\n      role=\"status\"\n    >\n      <Dots />\n    </span>\n  );\n}\n\nexport interface TypingNameProps {\n  person: Typer;\n  className?: string;\n}\n\nexport function TypingName({ person, className }: TypingNameProps) {\n  return (\n    <span className={cn(\"inline-flex items-center gap-2 text-muted-foreground text-xs\", className)} role=\"status\">\n      <Avatar className=\"size-5 text-[9px]\" person={person} />\n      <span>\n        <span className=\"font-medium text-foreground\">{person.name}</span> is typing\n      </span>\n      <Dots className=\"-ms-1 translate-y-[3px]\" size=\"sm\" />\n    </span>\n  );\n}\n\nfunction crowdLabel(people: Typer[]) {\n  const names = people.map((p) => p.name.split(\" \")[0]);\n  if (names.length === 0) return \"\";\n  if (names.length === 1) return `${names[0]} is typing`;\n  if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;\n  return `${names.length} people are typing`;\n}\n\nexport interface TypingCrowdProps {\n  people: Typer[];\n  maxAvatars?: number;\n  className?: string;\n}\n\nexport function TypingCrowd({ people, maxAvatars = 3, className }: TypingCrowdProps) {\n  const reduce = useReducedMotionAfterMount();\n  const shown = people.slice(0, maxAvatars);\n  const label = crowdLabel(people);\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 520, damping: 34 };\n\n  return (\n    <div className={cn(\"flex h-7 items-center gap-2.5 text-muted-foreground text-xs\", className)} role=\"status\">\n      <motion.div className=\"flex items-center\" layout transition={spring}>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {shown.map((p, i) => (\n            <motion.span\n              animate={{ opacity: 1, scale: 1, x: 0 }}\n              className={cn(\"relative rounded-full ring-2 ring-card\", i > 0 && \"-ms-1\")}\n              exit={{ opacity: 0, scale: 0.85, transition: { duration: 0.14 } }}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, x: -6 }}\n              key={p.id}\n              layout\n              style={{ zIndex: maxAvatars - i }}\n              transition={spring}\n            >\n              <Avatar className=\"size-6 text-[9px] tracking-tight\" person={p} />\n            </motion.span>\n          ))}\n        </AnimatePresence>\n        <AnimatePresence initial={false}>\n          {people.length > 0 && (\n            <motion.span\n              animate={{ opacity: 1, scale: 1 }}\n              className=\"relative -ms-1 flex h-5 items-center rounded-full border bg-card px-1.5 text-muted-foreground\"\n              exit={{ opacity: 0, scale: 0.85 }}\n              initial={{ opacity: 0, scale: 0.85 }}\n              layout\n              transition={spring}\n            >\n              <Dots size=\"sm\" />\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </motion.div>\n      <span className=\"relative min-w-0 flex-1\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, y: 0 }}\n            className=\"block truncate\"\n            exit={{ opacity: 0, y: reduce ? 0 : -4, transition: { duration: 0.12 } }}\n            initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n            key={label}\n            transition={{ duration: 0.18, ease }}\n          >\n            {label}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </div>\n  );\n}\n\nexport interface ComposingIndicatorProps {\n  name?: string;\n  label?: string;\n  showElapsed?: boolean;\n  className?: string;\n}\n\nexport function ComposingIndicator({\n  name = \"Lumen\",\n  label = \"Composing a reply\",\n  showElapsed = true,\n  className,\n}: ComposingIndicatorProps) {\n  const reduce = useReducedMotionAfterMount();\n  const [seconds, setSeconds] = useState(0);\n\n  useEffect(() => {\n    if (!showElapsed) return;\n    const id = setInterval(() => setSeconds((s) => s + 1), 1000);\n    return () => clearInterval(id);\n  }, [showElapsed]);\n\n  return (\n    <span className={cn(\"inline-flex items-center gap-2 text-xs\", className)} role=\"status\">\n      <span className=\"flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n        <SparklesIcon className=\"size-3\" />\n      </span>\n      <span className=\"sr-only\">\n        {name} is {label.toLowerCase()}\n      </span>\n      <motion.span\n        animate={reduce ? undefined : { backgroundPosition: [\"100% 0\", \"-100% 0\"] }}\n        aria-hidden\n        className=\"bg-clip-text font-medium text-transparent\"\n        style={{\n          backgroundImage:\n            \"linear-gradient(90deg, var(--color-muted-foreground) 0%, var(--color-muted-foreground) 35%, var(--color-foreground) 50%, var(--color-muted-foreground) 65%, var(--color-muted-foreground) 100%)\",\n          backgroundSize: \"200% 100%\",\n          backgroundPosition: reduce ? \"0 0\" : undefined,\n        }}\n        transition={{ duration: 2.2, repeat: Number.POSITIVE_INFINITY, ease: \"linear\" }}\n      >\n        {label}\n      </motion.span>\n      {showElapsed && (\n        <span aria-hidden className=\"text-muted-foreground tabular-nums\">\n          {seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`}\n        </span>\n      )}\n    </span>\n  );\n}\n"
    }
  ]
}