{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-animate",
  "title": "文字动效",
  "description": "关键词轮换、分字显现、乱码解码与高亮扫过，支持中文与渐进增强。",
  "dependencies": [
    "motion",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/text-animate/text-animate.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useInView } from \"motion/react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  flowToneStyle,\n  type FlowToneName,\n} from \"@/lib/flow-tone\"\nimport {\n  useTextTuning,\n  useTextMotion,\n  useTextEntrance,\n  segmentText,\n  textStyles,\n} from \"./shared\"\nimport { TextScramble } from \"./text-scramble\"\nimport { TextAnimateTuningContext } from \"./shared\"\nexport type { TextAnimateTuning, TextAnimateReport } from \"./shared\"\n\nconst textFlipVariants = cva(\"relative inline-grid max-w-full align-baseline\", {\n  variants: {\n    variant: {\n      pill: \"rounded-lg border border-edge bg-(--flow-tone)/10 px-2.5 py-0.5 text-(--flow-tone)\",\n      underline: \"border-b-2 border-(--flow-tone) px-1 text-(--flow-tone)\",\n    },\n  },\n  defaultVariants: { variant: \"pill\" },\n})\n\nfunction TextFlip({\n  words,\n  interval = 2600,\n  variant,\n  tone = \"primary\",\n  paused = false,\n  className,\n  style,\n  children,\n  ...props\n}: Omit<React.ComponentProps<\"span\">, \"children\"> &\n  VariantProps<typeof textFlipVariants> & {\n    words: string[]\n    interval?: number\n    /** 胶囊 / 下划线的色调，默认主题强调色。 */\n    tone?: FlowToneName\n    paused?: boolean\n    /** 屏幕阅读器的完整替代文案。 */\n    children?: string\n  }) {\n  const tuning = useTextTuning({ interval, paused, tone })\n  interval = tuning.interval ?? interval\n  paused = tuning.paused ?? paused\n  tone = tuning.tone ?? tone\n  const ref = React.useRef<HTMLSpanElement>(null)\n  const measure = React.useRef<HTMLSpanElement>(null)\n  const inView = useInView(ref, { amount: 0.2 })\n  const reduced = useTextMotion()\n  const [index, setIndex] = React.useState(0)\n  const [width, setWidth] = React.useState<number>()\n  const activeIndex = index % Math.max(1, words.length)\n  const word = words[activeIndex] ?? \"\"\n\n  React.useEffect(() => {\n    if (paused || reduced || !inView || words.length < 2) return\n    const timer = window.setInterval(\n      () => {\n        if (!document.hidden) setIndex((value) => value + 1)\n      },\n      Math.max(800, interval),\n    )\n    return () => window.clearInterval(timer)\n  }, [paused, reduced, inView, interval, words.length])\n\n  React.useEffect(() => {\n    const element = measure.current\n    if (!element) return\n    const observer = new ResizeObserver(() =>\n      setWidth(element.getBoundingClientRect().width),\n    )\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [word])\n\n  return (\n    <span\n      {...props}\n      ref={ref}\n      data-slot=\"text-flip\"\n      data-interval={interval}\n      data-paused={paused}\n      data-tone={tone}\n      className={cn(textFlipVariants({ variant }), className)}\n      style={{ ...flowToneStyle(tone), ...style }}\n    >\n      <span className=\"sr-only\">{children ?? words.join(\"、\")}</span>\n      <motion.span\n        aria-hidden\n        className=\"relative block overflow-hidden\"\n        animate={{ width: width ?? \"auto\" }}\n        transition={{ duration: reduced ? 0 : 0.25 }}\n      >\n        <span\n          ref={measure}\n          className=\"pointer-events-none invisible absolute top-0 left-0 whitespace-nowrap\"\n        >\n          {word}\n        </span>\n        <motion.span\n          key={word}\n          className=\"block whitespace-nowrap\"\n          initial={reduced ? false : { opacity: 0, y: \".4em\", rotateX: -60 }}\n          animate={{ opacity: 1, y: \"0em\", rotateX: 0 }}\n          transition={{ duration: reduced ? 0 : 0.3 }}\n        >\n          {word}\n        </motion.span>\n      </motion.span>\n    </span>\n  )\n}\n\nfunction TextReveal({\n  children,\n  by = \"char\",\n  trigger = \"in-view\",\n  stagger = 35,\n  locale = \"zh-CN\",\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  children: string\n  by?: \"char\" | \"word\"\n  trigger?: \"mount\" | \"in-view\"\n  /** 相邻片段的延迟，毫秒。 */\n  stagger?: number\n  locale?: string\n}) {\n  const tuning = useTextTuning({ by, stagger })\n  by = tuning.by ?? by\n  stagger = tuning.stagger ?? stagger\n  const { ref, active } = useTextEntrance(trigger)\n  const parts = segmentText(children, by, locale, true)\n  return (\n    <span\n      {...props}\n      ref={ref}\n      data-slot=\"text-reveal\"\n      data-by={by}\n      data-stagger={stagger}\n      data-active={active}\n      className={cn(\"whitespace-pre-wrap\", className)}\n    >\n      {textStyles()}\n      <span className=\"sr-only\">{children}</span>\n      <span aria-hidden key={by + \"-\" + stagger}>\n        {parts.map((part, index) => (\n          <span\n            key={index}\n            data-slot=\"text-reveal-part\"\n            className={cn(!/^\\s+$/.test(part) && \"inline-block\")}\n            style={\n              {\n                \"--text-delay\": `${index * Math.max(0, stagger)}ms`,\n              } as React.CSSProperties\n            }\n          >\n            {part}\n          </span>\n        ))}\n      </span>\n    </span>\n  )\n}\n\nconst textHighlightVariants = cva(\"rounded-sm px-0.5\", {\n  variants: { variant: { block: \"\", underline: \"rounded-none pb-0.5\" } },\n  defaultVariants: { variant: \"block\" },\n})\n\nfunction TextHighlight({\n  children,\n  tone = \"primary\",\n  variant = \"block\",\n  trigger = \"in-view\",\n  className,\n  style,\n  ...props\n}: React.ComponentProps<\"span\"> &\n  VariantProps<typeof textHighlightVariants> & {\n    tone?: FlowToneName\n    trigger?: \"mount\" | \"in-view\"\n  }) {\n  const tuning = useTextTuning({ tone })\n  tone = tuning.tone ?? tone\n  const { ref, active } = useTextEntrance(trigger)\n  return (\n    <span\n      {...props}\n      ref={ref}\n      data-slot=\"text-highlight\"\n      data-tone={tone}\n      data-variant={variant}\n      data-active={active}\n      className={cn(textHighlightVariants({ variant }), className)}\n      style={\n        {\n          ...flowToneStyle(tone),\n          \"--text-tone\": \"var(--flow-tone)\",\n          ...style,\n        } as React.CSSProperties\n      }\n    >\n      {textStyles()}\n      {children}\n    </span>\n  )\n}\n\nexport {\n  TextAnimateTuningContext,\n  TextFlip,\n  TextReveal,\n  TextScramble,\n  TextHighlight,\n  textFlipVariants,\n  textHighlightVariants,\n}\n",
      "type": "registry:ui",
      "target": "@ui/text-animate/text-animate.tsx"
    },
    {
      "path": "src/registry/ui/text-animate/text-scramble.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useInView } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { segmentText, textStyles, useTextMotion, useTextTuning } from \"./shared\"\n\nconst TEXT_CHARSETS = {\n  latin: \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\",\n  digits: \"0123456789\",\n  symbols: \"!<>_+?/[]{}=\",\n  katakana: \"アイウエオカキクケコサシスセソタチツテト\",\n  chinese: \"界面组件线条网格构建系统开源设计\",\n}\ntype TextScrambleCharset = keyof typeof TEXT_CHARSETS\n\nfunction TextScramble({\n  children,\n  trigger = \"in-view\",\n  charset = \"latin\",\n  characters,\n  duration = 1000,\n  className,\n  onMouseEnter,\n  onFocus,\n  ...props\n}: Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  children: string\n  trigger?: \"mount\" | \"hover\" | \"in-view\"\n  charset?: TextScrambleCharset\n  /** 覆盖预设字符集；空字符串回退为拉丁字母。 */\n  characters?: string\n  duration?: number\n}) {\n  const tuning = useTextTuning({ charset, duration })\n  charset = tuning.charset ?? charset\n  duration = tuning.duration ?? duration\n  // 面板明确选择预设时覆盖示例的自定义字符池。\n  if (tuning.charset !== undefined) characters = undefined\n  const ref = React.useRef<HTMLSpanElement>(null)\n  const inView = useInView(ref, { once: true, amount: 0.2 })\n  const reduced = useTextMotion()\n  const [run, setRun] = React.useState(0)\n  const [frame, setFrame] = React.useState<number | null>(null)\n  const parts = segmentText(children, \"char\")\n  const pool = Array.from(characters || TEXT_CHARSETS[charset])\n  const active =\n    !reduced &&\n    (trigger === \"mount\" ||\n      (trigger === \"in-view\" && inView) ||\n      (trigger === \"hover\" && run > 0))\n\n  React.useEffect(() => {\n    if (!active) return\n    const start = performance.now()\n    const length = segmentText(children, \"char\").length\n    const totalFrames = Math.max(1, length + 8)\n    const timer = window.setInterval(() => {\n      const progress = (performance.now() - start) / Math.max(100, duration)\n      setFrame(progress >= 1 ? null : Math.floor(progress * totalFrames))\n      if (progress >= 1) window.clearInterval(timer)\n    }, 40)\n    return () => window.clearInterval(timer)\n  }, [active, run, children, duration, charset, characters])\n\n  return (\n    <span\n      {...props}\n      ref={ref}\n      data-slot=\"text-scramble\"\n      data-duration={duration}\n      data-charset={charset}\n      tabIndex={trigger === \"hover\" ? 0 : props.tabIndex}\n      className={cn(\n        \"font-mono whitespace-pre-wrap outline-none focus-visible:ring-3 focus-visible:ring-ring/50\",\n        className,\n      )}\n      onMouseEnter={(event) => {\n        onMouseEnter?.(event)\n        if (trigger === \"hover\") setRun((value) => value + 1)\n      }}\n      onFocus={(event) => {\n        onFocus?.(event)\n        if (trigger === \"hover\") setRun((value) => value + 1)\n      }}\n    >\n      {textStyles()}\n      <span className=\"sr-only\">{children}</span>\n      <span aria-hidden>\n        {parts.map((part, index) => {\n          const scrambling =\n            active &&\n            frame !== null &&\n            index >= frame - 8 &&\n            !/^\\s+$/.test(part)\n          return (\n            <span key={index} className=\"relative inline-block\">\n              <span\n                data-slot=\"text-scramble-final\"\n                className={cn(scrambling && \"invisible\")}\n              >\n                {part}\n              </span>\n              {scrambling ? (\n                <span\n                  data-slot=\"text-scramble-noise\"\n                  className=\"absolute inset-0 text-center\"\n                >\n                  {pool[(index * 7 + frame * 11) % pool.length]}\n                </span>\n              ) : null}\n            </span>\n          )\n        })}\n      </span>\n    </span>\n  )\n}\n\nexport { TextScramble }\nexport type { TextScrambleCharset }\n",
      "type": "registry:ui",
      "target": "@ui/text-animate/text-scramble.tsx"
    },
    {
      "path": "src/registry/ui/text-animate/shared.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useInView } from \"motion/react\"\n\nimport type { FlowToneName } from \"@/lib/flow-tone\"\n\nimport type { TextScrambleCharset } from \"./text-scramble\"\n\n/** 各文字原语回报自己负责的属性，同一子树按属性保留第一个示例值。 */\ntype TextAnimateReport = Partial<{\n  interval: number\n  paused: boolean\n  by: \"char\" | \"word\"\n  stagger: number\n  duration: number\n  // 用顶层 import type：shadcn CLI 安装时只改写静态 import 的路径，内联 import() 类型里的别名不会被改写\n  charset: TextScrambleCharset\n  tone: FlowToneName\n}>\ntype TextAnimateTuning = TextAnimateReport & {\n  /** 回报覆盖前的示例取值。 */\n  onReport?: (report: TextAnimateReport) => void\n}\nconst TextAnimateTuningContext = React.createContext<TextAnimateTuning>({})\nconst reports = new WeakMap<\n  NonNullable<TextAnimateTuning[\"onReport\"]>,\n  TextAnimateReport\n>()\n\nfunction useTextTuning(report: TextAnimateReport) {\n  const tuning = React.useContext(TextAnimateTuningContext)\n  const { onReport } = tuning\n  // 效果按原语的属性订阅；不因 Provider 的新对象重复回报。\n  const { interval, paused, by, stagger, duration, charset, tone } = report\n  React.useEffect(() => {\n    if (!onReport) return\n    const previous = reports.get(onReport)\n    const next = Object.fromEntries(\n      Object.entries({\n        interval,\n        paused,\n        by,\n        stagger,\n        duration,\n        charset,\n        tone,\n      }).filter(([, value]) => value !== undefined),\n    )\n    reports.set(onReport, { ...next, ...previous })\n    if (!previous) {\n      // 等四种原语的 effect 都执行后再汇总，兼容面板只接收首次回报。\n      queueMicrotask(() => {\n        const collected = reports.get(onReport) ?? {}\n        reports.delete(onReport)\n        onReport(collected)\n      })\n    }\n  }, [onReport, interval, paused, by, stagger, duration, charset, tone])\n  return tuning\n}\n\nfunction subscribe(callback: () => void) {\n  const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  media.addEventListener(\"change\", callback)\n  return () => media.removeEventListener(\"change\", callback)\n}\nfunction snapshot() {\n  return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n}\nfunction serverSnapshot() {\n  return true\n}\nfunction useTextMotion() {\n  return React.useSyncExternalStore(subscribe, snapshot, serverSnapshot)\n}\n\nfunction useTextEntrance(trigger: \"mount\" | \"in-view\") {\n  const ref = React.useRef<HTMLSpanElement>(null)\n  const inView = useInView(ref, { once: true, amount: 0.2 })\n  const reduced = useTextMotion()\n  return { ref, active: !reduced && (trigger === \"mount\" || inView), reduced }\n}\n\n// 不能出现在行首的标点：每个片段都是 inline-block，浏览器的禁则不再生效，\n// 所以把它们并进前一个片段，避免「。」孤零零掉到下一行\nconst NO_LINE_START = /^[，。、；：！？）」』》”’…·.,;:!?)\\]}]+$/u\n\nfunction segmentText(\n  text: string,\n  by: \"char\" | \"word\",\n  locale = \"zh-CN\",\n  /** 把行首禁则标点并进前一个片段，只有每个片段都是 inline-block 时才需要 */\n  glue = false,\n) {\n  const parts: string[] = []\n  for (const { segment } of new Intl.Segmenter(locale, {\n    granularity: by === \"char\" ? \"grapheme\" : \"word\",\n  }).segment(text)) {\n    if (glue && parts.length && NO_LINE_START.test(segment)) {\n      parts[parts.length - 1] += segment\n    } else {\n      parts.push(segment)\n    }\n  }\n  return parts\n}\n\nconst TEXT_CSS = `\n@keyframes tv-text-reveal { from { opacity:0; filter:blur(6px); transform:translateY(.35em) } to { opacity:1; filter:blur(0); transform:translateY(0) } }\n@keyframes tv-text-sweep { from { background-size:0% 100% } to { background-size:100% 100% } }\n@media(prefers-reduced-motion:no-preference) {\n [data-slot=\"text-reveal\"][data-active=\"true\"] [data-slot=\"text-reveal-part\"] { animation:tv-text-reveal .5s both; animation-delay:var(--text-delay) }\n [data-slot=\"text-highlight\"][data-active=\"true\"] { animation:tv-text-sweep .65s both }\n}\n[data-slot=\"text-highlight\"] { background-image:linear-gradient(color-mix(in oklch,var(--text-tone) 22%,transparent),color-mix(in oklch,var(--text-tone) 22%,transparent)); background-repeat:no-repeat; background-position:0 100%; box-decoration-break:clone; -webkit-box-decoration-break:clone }\n[data-slot=\"text-highlight\"][data-variant=\"underline\"] { background-image:linear-gradient(var(--text-tone),var(--text-tone)); background-origin:content-box; background-size:100% .09em }\n@keyframes tv-text-underline { from { background-size:0% .09em } to { background-size:100% .09em } }\n@media(prefers-reduced-motion:no-preference) { [data-slot=\"text-highlight\"][data-active=\"true\"][data-variant=\"underline\"] { animation-name:tv-text-underline } }\n@media(prefers-reduced-motion:reduce) {\n [data-slot=\"text-scramble-final\"] { visibility:visible!important }\n [data-slot=\"text-scramble-noise\"] { display:none }\n}`\n\nfunction textStyles() {\n  return React.createElement(\n    \"style\",\n    { href: \"travinsy-text-animate\", precedence: \"default\" },\n    TEXT_CSS,\n  )\n}\n\nexport {\n  TextAnimateTuningContext,\n  useTextTuning,\n  useTextMotion,\n  useTextEntrance,\n  segmentText,\n  textStyles,\n}\n\nexport type { TextAnimateTuning, TextAnimateReport }\n",
      "type": "registry:ui",
      "target": "@ui/text-animate/shared.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)",
      "flow-blue": "oklch(0.56 0.2 258)",
      "flow-green": "oklch(0.62 0.16 150)",
      "flow-amber": "oklch(0.7 0.16 70)",
      "flow-rose": "oklch(0.62 0.21 5)",
      "flow-violet": "oklch(0.56 0.22 292)",
      "flow-cyan": "oklch(0.66 0.12 215)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)",
      "flow-blue": "oklch(0.72 0.15 255)",
      "flow-green": "oklch(0.77 0.17 152)",
      "flow-amber": "oklch(0.82 0.15 82)",
      "flow-rose": "oklch(0.74 0.16 8)",
      "flow-violet": "oklch(0.74 0.15 295)",
      "flow-cyan": "oklch(0.82 0.11 208)"
    }
  },
  "type": "registry:ui"
}
