{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-text",
  "title": "Animated Text",
  "description": "Character-level text animation — typewriter, scramble and flowing gradient variants in one component.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/animated-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nconst KEYFRAMES = `@keyframes at-gradient{to{background-position:200% center}}`\n\nconst SCRAMBLE_CHARS = \"!<>-_\\\\/[]{}=+*^?#\"\n\n// 渲染必须是纯函数(react-hooks/purity):用 (step, i) 的确定性哈希代替 Math.random,\n// step 每 tick 变化,乱序观感不变\nfunction scrambleChar(step: number, i: number) {\n  return SCRAMBLE_CHARS[(Math.imul(step * 31 + i * 17 + 7, 2654435761) >>> 0) % SCRAMBLE_CHARS.length]\n}\n\nexport type AnimatedTextVariant = \"typewriter\" | \"scramble\" | \"gradient\"\n\nexport interface AnimatedTextProps extends React.HTMLAttributes<HTMLSpanElement> {\n  text: string\n  variant?: AnimatedTextVariant\n  /** ms per character step (typewriter / scramble) */\n  speed?: number\n  /** ms before the animation starts */\n  delay?: number\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  mq.addEventListener(\"change\", callback)\n  return () => mq.removeEventListener(\"change\", callback)\n}\n\nfunction useReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  )\n}\n\nexport const AnimatedText = React.forwardRef<HTMLSpanElement, AnimatedTextProps>(\n  ({ text, variant = \"typewriter\", speed = 45, delay = 0, className, ...props }, ref) => {\n    const reduced = useReducedMotion()\n    // 已定字符数;显示文本在渲染期派生,effect 只管计时器\n    const [progress, setProgress] = React.useState(0)\n\n    // props 变化时在渲染期重置进度(React 官方 adjust-state-during-render 模式)\n    const resetKey = `${variant}|${speed}|${delay}|${text}`\n    const [prevKey, setPrevKey] = React.useState(resetKey)\n    if (prevKey !== resetKey) {\n      setPrevKey(resetKey)\n      setProgress(0)\n    }\n\n    React.useEffect(() => {\n      if (variant === \"gradient\" || reduced) return\n      let interval: ReturnType<typeof setInterval> | undefined\n      const timeout = setTimeout(() => {\n        interval = setInterval(() => {\n          setProgress(p => {\n            if (p + 1 >= text.length) clearInterval(interval)\n            return Math.min(p + 1, text.length)\n          })\n        }, speed)\n      }, delay)\n      return () => {\n        clearTimeout(timeout)\n        if (interval) clearInterval(interval)\n      }\n    }, [text, variant, speed, delay, reduced])\n\n    if (variant === \"gradient\") {\n      return (\n        <span className={cn(\"inline-block\", className)} ref={ref} {...props}>\n          <style href=\"zyeon-animated-text\" precedence=\"medium\">\n            {KEYFRAMES}\n          </style>\n          {/* via-primary/50:primary 与 foreground 同色的单色主题下,靠透明度差保证高光可见 */}\n          <span className=\"bg-gradient-to-r from-foreground via-primary/50 to-foreground bg-[length:200%_auto] bg-clip-text text-transparent [animation:at-gradient_3s_linear_infinite] motion-reduce:[animation:none]\">\n            {text}\n          </span>\n        </span>\n      )\n    }\n\n    const display = reduced\n      ? text\n      : variant === \"typewriter\"\n        ? text.slice(0, progress)\n        : text.slice(0, progress) +\n          Array.from(text.slice(progress), (ch, i) => (ch === \" \" ? \" \" : scrambleChar(progress, i))).join(\"\")\n\n    return (\n      <span aria-label={text} className={cn(\"inline-block\", className)} ref={ref} {...props}>\n        {/* 动画中的中间字符对读屏是噪音:真实文本走 aria-label,动画层 aria-hidden */}\n        <span aria-hidden=\"true\">{display}</span>\n        {variant === \"typewriter\" && (\n          <span\n            aria-hidden=\"true\"\n            className=\"ml-0.5 inline-block h-[1em] w-px translate-y-[0.12em] animate-pulse bg-current motion-reduce:hidden\"\n          />\n        )}\n      </span>\n    )\n  },\n)\n\nAnimatedText.displayName = \"AnimatedText\"\n\nexport default AnimatedText\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}