{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typewriter",
  "type": "registry:ui",
  "title": "Typewriter",
  "description": "Phrases typed out and deleted in a loop, with a caret and no layout shift.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/typewriter.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface TypewriterProps extends Omit<\n  React.ComponentProps<\"span\">,\n  \"children\"\n> {\n  /** Phrases typed in order. A single phrase types once and stops. */\n  words: string[]\n  /** Milliseconds per character while typing. */\n  typingSpeed?: number\n  /** Milliseconds per character while deleting. Deletes read faster. */\n  deletingSpeed?: number\n  /** Milliseconds held on a finished phrase before it deletes. */\n  holdDelay?: number\n  /** Start over after the last phrase. */\n  loop?: boolean\n  /** Show the blinking caret. */\n  caret?: boolean\n  /** Render the first phrase in full with no typing. */\n  disabled?: boolean\n}\n\nexport function Typewriter({\n  words,\n  typingSpeed = 70,\n  deletingSpeed = 40,\n  holdDelay = 1600,\n  loop = true,\n  caret = true,\n  disabled = false,\n  className,\n  ...props\n}: TypewriterProps) {\n  const phrases = words.length > 0 ? words : [\"\"]\n  const longest = React.useMemo(\n    () => phrases.reduce((a, b) => (a.length >= b.length ? a : b), \"\"),\n    [phrases]\n  )\n\n  const [index, setIndex] = React.useState(0)\n  const [text, setText] = React.useState(\"\")\n  const [deleting, setDeleting] = React.useState(false)\n  const [enabled, setEnabled] = React.useState(false)\n\n  // Typing is a text change, not a motion effect, but it is still motion to\n  // anyone who asked for less of it.\n  React.useEffect(() => {\n    if (disabled) {\n      return\n    }\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setEnabled(!query.matches)\n\n    const onChange = (event: MediaQueryListEvent) => setEnabled(!event.matches)\n    query.addEventListener(\"change\", onChange)\n    return () => query.removeEventListener(\"change\", onChange)\n  }, [disabled])\n\n  React.useEffect(() => {\n    if (!enabled) {\n      return\n    }\n\n    const current = phrases[index % phrases.length]\n    const done = text === current\n    const atLast = index === phrases.length - 1\n\n    if (done && !deleting) {\n      if (!loop && atLast) {\n        return\n      }\n      const timer = setTimeout(() => setDeleting(true), holdDelay)\n      return () => clearTimeout(timer)\n    }\n\n    if (deleting && text === \"\") {\n      setDeleting(false)\n      setIndex((previous) => (previous + 1) % phrases.length)\n      return\n    }\n\n    const timer = setTimeout(\n      () => {\n        setText((previous) =>\n          deleting\n            ? current.slice(0, previous.length - 1)\n            : current.slice(0, previous.length + 1)\n        )\n      },\n      deleting ? deletingSpeed : typingSpeed\n    )\n\n    return () => clearTimeout(timer)\n  }, [\n    enabled,\n    text,\n    deleting,\n    index,\n    phrases,\n    loop,\n    holdDelay,\n    typingSpeed,\n    deletingSpeed,\n  ])\n\n  const visible = enabled ? text : phrases[0]\n\n  return (\n    <span\n      data-slot=\"typewriter\"\n      className={cn(\"inline-grid\", className)}\n      {...props}\n    >\n      {/* The longest phrase reserves the width, so nothing around the\n          typewriter reflows while it types. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"invisible col-start-1 row-start-1 whitespace-pre\"\n      >\n        {longest}\n      </span>\n      {/* A live region here would announce every keystroke, so the phrases are\n          read once as static text and the typing itself is decoration. */}\n      <span className=\"sr-only\">{phrases.join(\", \")}</span>\n      <span\n        aria-hidden=\"true\"\n        className=\"col-start-1 row-start-1 whitespace-pre\"\n      >\n        {visible}\n        {caret ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"animate-caret-blink ml-0.5 inline-block w-px translate-y-[0.1em] self-stretch bg-current align-middle motion-reduce:animate-none\"\n            style={{ height: \"1em\" }}\n          />\n        ) : null}\n      </span>\n    </span>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "animate-caret-blink": "caret-blink 1.1s steps(2, jump-none) infinite"
    }
  },
  "css": {
    "@keyframes caret-blink": {
      "0%, 100%": {
        "opacity": "1"
      },
      "50%": {
        "opacity": "0"
      }
    }
  }
}