{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-typography",
  "title": "Animated Typography",
  "author": "AlgorizeTech",
  "description": "Headings and paragraphs that animate in with scramble, typewriter, rotating-word, blur and reveal effects. Use it for section titles that should draw the eye once.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/AnimatedTypographyBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport React, { forwardRef, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { getThemeStyles } from \"./animated-typography-theme\";\n\n// ==========================================\n// THEME & CONFIGURATION\n// ==========================================\nexport type TypographyVariant =\n  | \"matrix-scramble\"\n  | \"color-reveal\"\n  | \"wavy-float\"\n  | \"3d-perspective-flip\"\n  | \"elastic-spring\"\n  | \"spotlight-reveal\"\n  | \"split-stagger\"\n  | \"underline-draw\"\n  | \"letter-bounce\"\n  | \"typewriter\"\n  | \"word-flip\"\n  | \"blur-swap\"\n  | \"blur-rise\"\n  | \"color-wipe\";\n\nexport type TypographyTag =\n  | \"h1\"\n  | \"h2\"\n  | \"h3\"\n  | \"h4\"\n  | \"h5\"\n  | \"h6\"\n  | \"p\"\n  | \"span\"\n  | \"blockquote\";\n\nexport type TypographyColor =\n  | \"indigo\"\n  | \"rose\"\n  | \"emerald\"\n  | \"zinc\"\n  | \"default\"\n  | (string & {});\n\nexport type TypographyAlign = \"left\" | \"center\" | \"right\" | \"justify\";\n\nconst alignStyles: Record<TypographyAlign, string> = {\n  left: \"text-left\",\n  center: \"text-center\",\n  right: \"text-right\",\n  justify: \"text-justify\",\n};\n\nconst baseStyles: Record<TypographyTag, string> = {\n  h1: \"font-extrabold tracking-tight leading-snug whitespace-nowrap max-w-full overflow-visible\",\n  h2: \"font-semibold tracking-tight leading-snug whitespace-nowrap max-w-full overflow-visible\",\n  h3: \"font-semibold tracking-tight leading-snug whitespace-nowrap max-w-full overflow-visible\",\n  h4: \"font-semibold tracking-tight leading-snug whitespace-nowrap max-w-full overflow-visible\",\n  h5: \"font-medium leading-normal whitespace-nowrap max-w-full overflow-visible\",\n  h6: \"font-medium leading-normal whitespace-nowrap max-w-full overflow-visible\",\n  p: \"leading-relaxed whitespace-nowrap max-w-full overflow-visible\",\n  span: \"whitespace-nowrap max-w-full overflow-visible\",\n  blockquote: \"pl-2 sm:pl-4 italic border-l-2 sm:border-l-4 border-border whitespace-nowrap max-w-full overflow-visible\",\n};\n\n// Map of valid tailwind text sizes to responsive scaling classes (scales down on small screens to fit 1 line)\nconst tailwindSizeMap: Record<string, string> = {\n  xs: \"text-[10px] sm:text-xs\",\n  sm: \"text-[11px] sm:text-sm\",\n  base: \"text-xs sm:text-base\",\n  lg: \"text-xs sm:text-lg\",\n  xl: \"text-xs sm:text-xl\",\n  \"2xl\": \"text-xs sm:text-2xl\",\n  \"3xl\": \"text-xs sm:text-3xl\",\n  \"4xl\": \"text-xs sm:text-2xl md:text-4xl\",\n  \"5xl\": \"text-sm sm:text-3xl lg:text-5xl\",\n  \"6xl\": \"text-sm sm:text-4xl lg:text-6xl\",\n  \"7xl\": \"text-base sm:text-5xl lg:text-7xl\",\n  \"8xl\": \"text-base sm:text-6xl lg:text-8xl\",\n  \"9xl\": \"text-lg sm:text-7xl lg:text-9xl\",\n};\n\n// Matrix scramble helper\nconst MATRIX_CHARS = \"ABCDEFGHJKLMNOPQRSTUVWXYZ0123456789@#$%&*!?\";\n\nfunction MatrixScrambleText({\n  text,\n  speed = 1,\n  delay = 0,\n}: {\n  text: string;\n  speed?: number;\n  delay?: number;\n}) {\n  const [displayText, setDisplayText] = useState(\"\");\n\n  useEffect(() => {\n    let iteration = 0;\n    let interval: ReturnType<typeof setInterval> | null = null;\n    const timeout = setTimeout(() => {\n      interval = setInterval(() => {\n        setDisplayText(\n          text\n            .split(\"\")\n            .map((char, index) => {\n              if (char === \" \") return \" \";\n              if (index < iteration) {\n                return text[index];\n              }\n              return MATRIX_CHARS[\n                Math.floor(Math.random() * MATRIX_CHARS.length)\n              ];\n            })\n            .join(\"\"),\n        );\n\n        if (iteration >= text.length) {\n          if (interval) clearInterval(interval);\n        }\n        iteration += 1 / 2.5;\n      }, 35 * speed);\n    }, delay);\n\n    return () => {\n      clearTimeout(timeout);\n      if (interval) clearInterval(interval);\n    };\n  }, [text, speed, delay]);\n\n  const targetText = displayText || text;\n  const words = targetText.split(\" \");\n\n  return (\n    <span className=\"inline whitespace-nowrap\">\n      {words.map((word, i) => (\n        <span key={i} className=\"inline-block whitespace-nowrap mr-[0.2em]\">\n          {word}\n        </span>\n      ))}\n    </span>\n  );\n}\n\n// ==========================================\n// ROTATING WORDS\n// ==========================================\n/** How long each word stays before the next one comes in. */\nconst WORD_INTERVAL_MS = 2400;\n/** How long the typewriter holds a finished word before deleting it. */\nconst TYPEWRITER_HOLD_MS = 1800;\n/** The quick fade used for a word's opacity: 0.3s. */\nconst FADE_EASE = \"cubic-bezier(0.25, 0.1, 0.35, 1)\";\n/** A spring of stiffness 350, damping 25, as a CSS easing; it settles in 558ms. */\nconst BOUNCE_SPRING =\n  \"linear(0, 0.041, 0.1419, 0.2747, 0.4185, 0.5586, 0.6853, 0.7936, 0.8813, 0.9485, 0.9971, 1.0295, 1.0488, 1.0578, 1.0594, 1.0558, 1.0492, 1.041, 1.0324, 1.0243, 1.017, 1.0108, 1.0059, 1.0022, 0.9996, 0.9979, 0.9969, 0.9965, 0.9965, 0.9968, 0.9972, 0.9977, 0.9982, 0.9987, 1)\";\nconst BOUNCE_SPRING_MS = 558;\n/** When that spring counts as at rest (within half a pixel), and the next word may come in. */\nconst BOUNCE_REST_MS = 450;\nconst FLIP_EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nconst SWAP_EASE = \"cubic-bezier(0.22, 1, 0.36, 1)\";\n/** A long ease-out, as a CSS easing: the blur-rise lands softly. */\nconst RISE_EASE =\n  \"linear(0, .1945, .3591, .4971, .6115, .7052, .7808, .8409, .8877, .9233, .9497, .9686, .9816, .9901, .9952, .998, .9994, .9999, 1, 1)\";\n\n/** A rotating word: plain text animates letter by letter; any other node moves whole. */\ntype Word = React.ReactNode;\nconst asString = (w: Word) =>\n  typeof w === \"string\" || typeof w === \"number\" ? String(w) : null;\n\n/**\n * `fixed` reserves the longest word's width, so the text around the words\n * holds still; `fluid` follows the word on show, so a centred line re-centres.\n */\nexport type PrefixPosition = \"fixed\" | \"fluid\";\n\n/** The word on show: `index` when controlled, otherwise one every `interval` ms. */\nfunction useActiveIndex(count: number, interval: number, index?: number) {\n  const [auto, setAuto] = useState(0);\n  const controlled = index !== undefined;\n  useEffect(() => {\n    if (controlled || count < 2) return;\n    const id = setInterval(() => setAuto((i) => (i + 1) % count), interval);\n    return () => clearInterval(id);\n  }, [controlled, count, interval]);\n  const n = Math.max(count, 1);\n  return (((controlled ? index : auto) % n) + n) % n;\n}\n\n/** \"start\" when the words sit in a line of text; \"inherit\" when they are the whole line. */\ntype WordAlign = \"start\" | \"inherit\";\n\nconst clipLine = (clip?: boolean): React.CSSProperties | undefined =>\n  clip ? { clipPath: \"inset(0 -100vw)\" } : undefined;\n\n/** Where the rotating words sit; see `PrefixPosition`. `clip` cuts off words sliding above or below the line. */\nfunction WordSlot({\n  words,\n  position,\n  align,\n  clip,\n  children,\n}: {\n  words: Word[];\n  position: PrefixPosition;\n  align: WordAlign;\n  clip?: boolean;\n  children: React.ReactNode;\n}) {\n  const alignClass = align === \"start\" ? \"text-start\" : undefined;\n  if (position === \"fluid\") {\n    return (\n      <FluidWidth className={alignClass} style={clipLine(clip)}>\n        {children}\n      </FluidWidth>\n    );\n  }\n  return (\n    <span className={cn(\"relative inline-grid\", alignClass)} style={clipLine(clip)}>\n      {words.map((w, i) => (\n        <span key={i} className=\"invisible col-start-1 row-start-1\">\n          {w}\n        </span>\n      ))}\n      <span className=\"col-start-1 row-start-1\">{children}</span>\n    </span>\n  );\n}\n\n/**\n * As wide as its content, easing between widths as the content changes, so\n * the text beside it glides to its new place rather than jumping there.\n */\nfunction FluidWidth({\n  className,\n  style,\n  children,\n}: {\n  className?: string;\n  style?: React.CSSProperties;\n  children: React.ReactNode;\n}) {\n  const inner = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const measure = () => setWidth(el.getBoundingClientRect().width);\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return (\n    <span\n      className={cn(\"relative inline-block\", className)}\n      style={{ ...style, width, transition: `width 0.45s ${FLIP_EASE}` }}\n    >\n      {/* max-content, so the box narrowing around it never wraps what it measures */}\n      <span ref={inner} className=\"relative inline-block w-max\">\n        {children}\n      </span>\n    </span>\n  );\n}\n\ninterface RotatorProps {\n  words: Word[];\n  interval: number;\n  index?: number;\n  speed: number;\n  delay: number;\n  position: PrefixPosition;\n  align: WordAlign;\n}\n\n/**\n * The word on screen leaves letter by letter; the next one comes in only\n * once the last letter has settled.\n */\nfunction LetterBounceWords({ words, interval, index, speed, delay, position, align }: RotatorProps) {\n  const active = useActiveIndex(words.length, interval, index);\n  const [shown, setShown] = useState(active);\n  const latest = useRef(active);\n  useEffect(() => {\n    latest.current = active;\n  });\n  const leaving = shown !== active;\n  // Only the first word waits for `delay`.\n  const [cycled, setCycled] = useState(false);\n  if (leaving && !cycled) setCycled(true);\n  const lead = cycled ? 0 : delay;\n  const word = words[shown] ?? words[0];\n  const text = asString(word);\n\n  useEffect(() => {\n    if (!leaving) return;\n    const t = window.setTimeout(\n      () => setShown(latest.current),\n      Math.max(300, ((text?.length ?? 1) - 1) * 20 * speed + BOUNCE_REST_MS),\n    );\n    return () => window.clearTimeout(t);\n  }, [leaving, text, speed]);\n\n  return (\n    <WordSlot words={words} position={position} align={align} clip>\n      <span\n        key={shown}\n        className=\"inline-flex\"\n        style={{\n          animation: leaving\n            ? `typo-word-out 0.3s ${FADE_EASE} forwards`\n            : `typo-word-in 0.3s ${FADE_EASE} ${lead}ms backwards`,\n        }}\n      >\n        {text === null\n          ? word\n          : text.split(\"\").map((char, i) => (\n              <span\n                key={i}\n                className=\"inline-block whitespace-pre\"\n                style={{\n                  animation: leaving\n                    ? `typo-char-out ${BOUNCE_SPRING_MS}ms ${BOUNCE_SPRING} ${i * 20 * speed}ms both`\n                    : `typo-char-in ${BOUNCE_SPRING_MS}ms ${BOUNCE_SPRING} ${lead + i * 40 * speed}ms backwards`,\n                }}\n              >\n                {char}\n              </span>\n            ))}\n      </span>\n    </WordSlot>\n  );\n}\n\n/** Coloured by `--typo-caret` when set, otherwise the text colour. */\nconst Caret = () => (\n  <span\n    className=\"animate-pulse font-mono font-light ml-px\"\n    style={{ color: \"var(--typo-caret, currentColor)\" }}\n  >\n    |\n  </span>\n);\n\n/** Types each word, holds it, deletes it, and moves on, behind a blinking caret. */\nfunction TypewriterWords({\n  words,\n  hold,\n  speed,\n  delay,\n  position,\n  align,\n}: Omit<RotatorProps, \"interval\" | \"index\"> & { hold: number }) {\n  const [started, setStarted] = useState(delay <= 0);\n  const [index, setIndex] = useState(0);\n  const [count, setCount] = useState(0);\n  const [deleting, setDeleting] = useState(false);\n  const word = words[index % words.length];\n  const text = asString(word);\n  // A node that is not text types as a single stroke.\n  const length = text === null ? 1 : text.length;\n\n  useEffect(() => {\n    if (!started) {\n      const t = setTimeout(() => setStarted(true), delay);\n      return () => clearTimeout(t);\n    }\n    // Reduced motion types and deletes whole words at once.\n    const whole = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let wait: number;\n    let next: () => void;\n    if (!deleting && count < length) {\n      wait = whole ? 0 : 100 * speed;\n      next = () => setCount(whole ? length : count + 1);\n    } else if (!deleting) {\n      wait = hold;\n      next = () => setDeleting(true);\n    } else {\n      wait = whole ? 0 : 60 * speed;\n      next = () => {\n        const left = whole ? 0 : count - 1;\n        if (left <= 0) {\n          setDeleting(false);\n          setIndex((i) => (i + 1) % words.length);\n        }\n        setCount(Math.max(0, left));\n      };\n    }\n    const t = setTimeout(next, wait);\n    return () => clearTimeout(t);\n  }, [started, delay, count, deleting, length, words.length, hold, speed]);\n\n  const typed =\n    text === null ? (\n      count > 0 ? word : null\n    ) : (\n      <span className=\"whitespace-pre\">{text.slice(0, count)}</span>\n    );\n\n  // Fluid is the typed text's own width, so a centred line re-centres a\n  // letter at a time.\n  if (position === \"fluid\") {\n    return (\n      <span className=\"inline-flex items-baseline\">\n        {typed}\n        <Caret />\n      </span>\n    );\n  }\n  return (\n    <span className={cn(\"relative inline-grid\", align === \"start\" && \"text-start\")}>\n      {words.map((w, i) => (\n        <span key={i} className=\"invisible col-start-1 row-start-1\">\n          {w}\n          <Caret />\n        </span>\n      ))}\n      <span className=\"col-start-1 row-start-1\">\n        {typed}\n        <Caret />\n      </span>\n    </span>\n  );\n}\n\n/** The word on screen slides up and out, and only then does the next one slide up in. */\nfunction WordFlipWords({ words, interval, index, speed, position, align }: RotatorProps) {\n  const active = useActiveIndex(words.length, interval, index);\n  const [shown, setShown] = useState(active);\n  const [swapped, setSwapped] = useState(false);\n  const latest = useRef(active);\n  useEffect(() => {\n    latest.current = active;\n  });\n  const leaving = shown !== active;\n  const duration = 0.45 * speed;\n  const swapIn = () => {\n    setShown(latest.current);\n    setSwapped(true);\n  };\n\n  // In case the leaving word's animationend never arrives.\n  useEffect(() => {\n    if (!leaving) return;\n    const t = window.setTimeout(swapIn, duration * 1000 + 150);\n    return () => window.clearTimeout(t);\n  }, [leaving, duration]);\n\n  return (\n    <WordSlot words={words} position={position} align={align} clip>\n      <span\n        key={shown}\n        onAnimationEnd={(e) => {\n          if (leaving && e.target === e.currentTarget) swapIn();\n        }}\n        className=\"inline-block\"\n        style={\n          leaving\n            ? { animation: `typo-flip-out ${duration}s ${FLIP_EASE} forwards` }\n            : swapped\n              ? { animation: `typo-flip-in ${duration}s ${FLIP_EASE} backwards` }\n              : undefined\n        }\n      >\n        {words[shown] ?? words[0]}\n      </span>\n    </WordSlot>\n  );\n}\n\n/**\n * The incoming word sharpens out of a blur as the outgoing one lifts away\n * into one.\n */\nfunction BlurSwapWords({ words, interval, index, speed, position, align }: RotatorProps) {\n  const active = useActiveIndex(words.length, interval, index);\n  const [track, setTrack] = useState<{ active: number; leaving: number | null }>({\n    active,\n    leaving: null,\n  });\n  if (track.active !== active) setTrack({ active, leaving: track.active });\n  const leaving = track.leaving;\n  const duration = 700 * speed;\n\n  // The word that just left keeps its \"leaving\" pose until it has faded.\n  useEffect(() => {\n    if (leaving === null) return;\n    const t = window.setTimeout(() => setTrack((s) => ({ ...s, leaving: null })), duration);\n    return () => window.clearTimeout(t);\n  }, [leaving, duration]);\n\n  const transition = (lag: number) =>\n    [\"opacity\", \"transform\", \"filter\"]\n      .map((p) => `${p} ${duration}ms ${SWAP_EASE} ${lag}ms`)\n      .join(\", \");\n  const fluid = position === \"fluid\";\n\n  const cells = words.map((w, i) => {\n    const state = i === active ? \"active\" : i === leaving ? \"leaving\" : \"idle\";\n    return (\n      <span\n        key={i}\n        // Fixed stacks every word in one cell, so the widest sizes it; fluid\n        // keeps only the word on show in the flow.\n        className={\n          fluid\n            ? state === \"active\"\n              ? \"inline-block\"\n              : \"absolute left-0 top-0\"\n            : \"col-start-1 row-start-1\"\n        }\n        style={\n          state === \"active\"\n            ? { opacity: 1, transform: \"none\", filter: \"blur(0)\", transition: transition(150) }\n            : state === \"leaving\"\n              ? { opacity: 0, transform: \"translateY(-12px)\", filter: \"blur(6px)\", transition: transition(0) }\n              : { opacity: 0, transform: \"translateY(16px)\", filter: \"blur(8px)\", transition: \"none\" }\n        }\n      >\n        {w}\n      </span>\n    );\n  });\n\n  const alignClass = align === \"start\" ? \"text-start\" : undefined;\n  return fluid ? (\n    <FluidWidth className={alignClass}>{cells}</FluidWidth>\n  ) : (\n    <span className={cn(\"inline-grid\", alignClass)}>{cells}</span>\n  );\n}\n\n// ==========================================\n// INTERFACES\n// ==========================================\nexport interface AnimatedTypographyProps\n  extends React.HTMLAttributes<HTMLElement> {\n  as?: TypographyTag;\n  variant?: TypographyVariant;\n  /**\n   * The text to animate, and what screen readers announce. The rotating\n   * variants announce it in place of the word on show.\n   */\n  text?: string;\n  /**\n   * Rich content for the variants that move the text whole (blur-rise,\n   * color-wipe), in place of `text` — a heading with an `<em>` in it, say.\n   */\n  children?: React.ReactNode;\n  titlePrefix?: string;\n  titleSuffix?: string;\n  color?: TypographyColor;\n  align?: TypographyAlign;\n  size?: string;\n  delay?: number; // Base delay in ms\n  speed?: number; // Speed multiplier for staggered effects\n  /**\n   * The words the rotating variants (letter-bounce, typewriter, word-flip,\n   * blur-swap) cycle through, between `titlePrefix` and `titleSuffix`.\n   * Plain strings animate letter by letter; other nodes move whole.\n   * Defaults to `[text]`.\n   */\n  words?: React.ReactNode[];\n  /**\n   * Milliseconds each word stays before the next comes in (2400). The\n   * typewriter holds a finished word this long instead (1800).\n   */\n  interval?: number;\n  /**\n   * Whether the text before the rotating words holds still. `fixed` reserves\n   * the longest word's width; `fluid` follows the word on show, so a centred\n   * line re-centres as it changes. Defaults to `fluid` for the typewriter and\n   * `fixed` for the others.\n   */\n  prefixPosition?: PrefixPosition;\n  /**\n   * Shows `words[index]` instead of stepping on a timer, for rotating words\n   * kept in step with something else, like a carousel's slides. Not the\n   * typewriter, which keeps its own time.\n   */\n  index?: number;\n  /**\n   * `false` renders the text at rest, without animating; the animation\n   * starts when it turns `true`. For reveals that wait on something, like\n   * scrolling into view.\n   */\n  play?: boolean;\n  /**\n   * Leaves the typography to `className` and the parent: no weight, size,\n   * colour, alignment or padding of its own unless those props are given.\n   * For composing it into a section's heading.\n   */\n  bare?: boolean;\n}\n\n// ==========================================\n// COMPONENT\n// ==========================================\nexport const AnimatedTypographyBlock = forwardRef<\n  HTMLElement,\n  AnimatedTypographyProps\n>(\n  (\n    {\n      as: Tag = \"p\",\n      variant = \"matrix-scramble\",\n      text: textProp,\n      children,\n      titlePrefix,\n      titleSuffix,\n      color: colorProp,\n      align: alignProp,\n      size,\n      delay = 0,\n      speed = 1,\n      words: wordList,\n      interval,\n      prefixPosition,\n      index,\n      play = true,\n      bare = false,\n      className,\n      style,\n      ...props\n    },\n    ref,\n  ) => {\n    // `as` picks the element. Every tag it allows accepts HTML attributes\n    // and an HTMLElement ref, which is exactly what this says; JSX cannot\n    // work that out from the union on its own.\n    const Component = Tag as unknown as React.ComponentType<\n      React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>\n    >;\n\n    const color = colorProp ?? (bare ? undefined : \"primary\");\n    const align = alignProp ?? (bare ? undefined : \"left\");\n    const { className: colorClass, style: colorStyle } = color\n      ? getThemeStyles(color as string)\n      : { className: undefined, style: {} };\n\n    // Responsive Sizing (small on mobile so text fits on 1 line)\n    let sizeClass = \"\";\n    let customSizeStyle: React.CSSProperties = {};\n\n    if (size) {\n      if (tailwindSizeMap[size]) {\n        sizeClass = tailwindSizeMap[size];\n      } else {\n        customSizeStyle = { fontSize: `clamp(10px, 3.2vw, ${size})` };\n      }\n    } else if (!bare) {\n      const defaultSizeMap: Record<TypographyTag, string> = {\n        h1: \"text-base sm:text-2xl md:text-4xl lg:text-5xl\",\n        h2: \"text-sm sm:text-xl md:text-3xl lg:text-4xl\",\n        h3: \"text-xs sm:text-lg md:text-2xl lg:text-3xl\",\n        h4: \"text-[11px] sm:text-base md:text-xl lg:text-2xl\",\n        h5: \"text-[10px] sm:text-sm md:text-lg lg:text-xl\",\n        h6: \"text-[9px] sm:text-xs md:text-base lg:text-lg\",\n        p: \"text-[10px] sm:text-base\",\n        span: \"text-inherit\",\n        blockquote: \"text-[11px] sm:text-lg\",\n      };\n      sizeClass = defaultSizeMap[Tag];\n    }\n\n    const text = textProp ?? \"\";\n    const words = text.split(\" \");\n    const rotating: React.ReactNode[] = wordList?.length ? wordList : [textProp ?? children];\n    const isRotating =\n      variant === \"letter-bounce\" ||\n      variant === \"typewriter\" ||\n      variant === \"word-flip\" ||\n      variant === \"blur-swap\";\n    const rotator = {\n      words: rotating,\n      interval: interval ?? WORD_INTERVAL_MS,\n      index,\n      speed,\n      delay,\n      position: prefixPosition ?? (variant === \"typewriter\" ? \"fluid\" : \"fixed\"),\n      // Words inside a line start where the line leaves off; words that are\n      // the whole line align like it.\n      align: titlePrefix || titleSuffix || Tag === \"span\" ? \"start\" : \"inherit\",\n    } satisfies React.ComponentProps<typeof LetterBounceWords>;\n    // What the text is at rest, and what screen readers hear.\n    const still = isRotating\n      ? (textProp ?? rotating[index ?? 0])\n      : (children ?? text);\n\n    // Render logic based on variant\n    const renderContent = () => {\n      if (!play) return still;\n      switch (variant) {\n        case \"matrix-scramble\":\n          return <MatrixScrambleText text={text} speed={speed} delay={delay} />;\n\n        case \"color-reveal\": {\n          let globalCharIndex = 0;\n          return (\n            <span className=\"inline whitespace-nowrap\">\n              {words.map((word, wordIdx) => {\n                const wordChars = word.split(\"\");\n                const wordNode = (\n                  <span key={wordIdx} className=\"inline-block whitespace-nowrap mr-[0.2em]\">\n                    {wordChars.map((char) => {\n                      const idx = globalCharIndex++;\n                      return (\n                        <span\n                          key={idx}\n                          className=\"inline-block text-muted-foreground animate-[typo-color-reveal_0.7s_cubic-bezier(0.16,1,0.3,1)_both]\"\n                          style={{\n                            animationDelay: `${delay + idx * 45 * speed}ms`,\n                          }}\n                        >\n                          {char}\n                        </span>\n                      );\n                    })}\n                  </span>\n                );\n                globalCharIndex++;\n                return wordNode;\n              })}\n            </span>\n          );\n        }\n\n        case \"wavy-float\": {\n          let globalCharIndex = 0;\n          return (\n            <span className=\"inline whitespace-nowrap\">\n              {words.map((word, wordIdx) => {\n                const wordChars = word.split(\"\");\n                const wordNode = (\n                  <span key={wordIdx} className=\"inline-block whitespace-nowrap mr-[0.2em]\">\n                    {wordChars.map((char) => {\n                      const idx = globalCharIndex++;\n                      return (\n                        <span\n                          key={idx}\n                          className=\"inline-block animate-[typo-wavy_2s_ease-in-out_infinite]\"\n                          style={{\n                            animationDelay: `${delay + idx * 80 * speed}ms`,\n                          }}\n                        >\n                          {char}\n                        </span>\n                      );\n                    })}\n                  </span>\n                );\n                globalCharIndex++;\n                return wordNode;\n              })}\n            </span>\n          );\n        }\n\n        case \"3d-perspective-flip\":\n          return words.map((word, i) => (\n            <span key={i} className=\"inline-block whitespace-nowrap perspective-[1000px] mr-[0.2em]\">\n              <span\n                className=\"inline-block origin-bottom animate-[typo-perspective_0.75s_cubic-bezier(0.34,1.56,0.64,1)_both]\"\n                style={{ animationDelay: `${delay + i * 70 * speed}ms` }}\n              >\n                {word}\n              </span>\n            </span>\n          ));\n\n        case \"elastic-spring\":\n          return words.map((word, i) => (\n            <span\n              key={i}\n              className=\"inline-block whitespace-nowrap animate-[typo-elastic_0.8s_cubic-bezier(0.68,-0.55,0.265,1.55)_both] mr-[0.2em]\"\n              style={{ animationDelay: `${delay + i * 65 * speed}ms` }}\n            >\n              {word}\n            </span>\n          ));\n\n        case \"spotlight-reveal\":\n          return words.map((word, i) => (\n            <span\n              key={i}\n              className=\"inline-block whitespace-nowrap animate-[typo-spotlight_1s_cubic-bezier(0.16,1,0.3,1)_both] mr-[0.2em]\"\n              style={{ animationDelay: `${delay + i * 80 * speed}ms` }}\n            >\n              {word}\n            </span>\n          ));\n\n        case \"split-stagger\": {\n          let globalCharIndex = 0;\n          return (\n            <span className=\"inline whitespace-nowrap\">\n              {words.map((word, wordIdx) => {\n                const wordChars = word.split(\"\");\n                const wordNode = (\n                  <span key={wordIdx} className=\"inline-block whitespace-nowrap mr-[0.2em]\">\n                    {wordChars.map((char) => {\n                      const idx = globalCharIndex++;\n                      const isEven = idx % 2 === 0;\n                      return (\n                        <span\n                          key={idx}\n                          className={cn(\n                            \"inline-block\",\n                            isEven\n                              ? \"animate-[typo-split-top_0.7s_cubic-bezier(0.16,1,0.3,1)_both]\"\n                              : \"animate-[typo-split-bottom_0.7s_cubic-bezier(0.16,1,0.3,1)_both]\",\n                          )}\n                          style={{\n                            animationDelay: `${delay + idx * 35 * speed}ms`,\n                          }}\n                        >\n                          {char}\n                        </span>\n                      );\n                    })}\n                  </span>\n                );\n                globalCharIndex++;\n                return wordNode;\n              })}\n            </span>\n          );\n        }\n\n        case \"underline-draw\":\n          return (\n            <span className=\"relative inline-block max-w-full pb-2 whitespace-nowrap\">\n              <span className=\"inline-block max-w-full whitespace-nowrap animate-[typo-fade_0.6s_ease-out_both]\" style={{ animationDelay: `${delay}ms` }}>\n                {text}\n              </span>\n              <svg\n                className=\"absolute left-0 bottom-0 w-full h-3 overflow-visible text-primary pointer-events-none\"\n                viewBox=\"0 0 100 12\"\n                preserveAspectRatio=\"none\"\n              >\n                {/* Draws itself in: `pathLength=\"1\"` makes the dash pattern\n                    a fraction of the curve, so a dash growing 0 → 1 traces it. */}\n                <path\n                  d=\"M0,5 Q50,12 100,5\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"3.5\"\n                  strokeLinecap=\"round\"\n                  pathLength={1}\n                  className=\"animate-[typo-underline-draw_0.9s_cubic-bezier(0,0,0.58,1)_backwards]\"\n                  style={{\n                    animationDuration: `${0.9 * speed}s`,\n                    animationDelay: `${delay / 1000 + 0.2}s`,\n                  }}\n                />\n              </svg>\n            </span>\n          );\n\n        case \"letter-bounce\":\n          return <LetterBounceWords {...rotator} />;\n\n        case \"typewriter\":\n          // The typewriter paces itself, so it takes neither interval nor index.\n          return (\n            <TypewriterWords\n              words={rotator.words}\n              speed={rotator.speed}\n              delay={rotator.delay}\n              position={rotator.position}\n              align={rotator.align}\n              hold={interval ?? TYPEWRITER_HOLD_MS}\n            />\n          );\n\n        case \"word-flip\":\n          return <WordFlipWords {...rotator} />;\n\n        case \"blur-swap\":\n          return <BlurSwapWords {...rotator} />;\n\n        case \"blur-rise\":\n          return (\n            <span\n              className=\"inline-block\"\n              style={{\n                animation: `typo-blur-rise ${0.8 * speed}s ${RISE_EASE} ${delay}ms backwards`,\n              }}\n            >\n              {children ?? text}\n            </span>\n          );\n\n        case \"color-wipe\":\n          // Two copies clipped at the same moving edge: the coloured one is\n          // uncovered from the left as the foreground one is cut away, so the\n          // two never stack and only the coloured copy is left at the end.\n          return (\n            <span className=\"relative inline-block\">\n              <span\n                className=\"inline-block\"\n                style={{\n                  animation: `typo-wipe-in ${1.4 * speed}s ease-in-out ${delay}ms both`,\n                }}\n              >\n                {children ?? text}\n              </span>\n              <span\n                className=\"absolute inset-0 text-foreground\"\n                style={{\n                  animation: `typo-wipe-out ${1.4 * speed}s ease-in-out ${delay}ms both`,\n                }}\n              >\n                {children ?? text}\n              </span>\n            </span>\n          );\n\n        default:\n          return still;\n      }\n    };\n\n    return (\n      <>\n        <style>{`\n          @keyframes typo-color-reveal {\n            0% {\n              color: color-mix(in oklab, var(--muted-foreground) 40%, transparent);\n              opacity: 0.5;\n            }\n            100% {\n              color: inherit;\n              opacity: 1;\n            }\n          }\n          @keyframes typo-wavy {\n            0%, 100% { transform: translateY(0); }\n            50% { transform: translateY(-6px); }\n          }\n          @keyframes typo-perspective {\n            0% { opacity: 0; transform: rotateX(-90deg) scale(0.8); }\n            100% { opacity: 1; transform: rotateX(0deg) scale(1); }\n          }\n          @keyframes typo-elastic {\n            0% { opacity: 0; transform: scale(0.3); }\n            50% { opacity: 0.8; transform: scale(1.15); }\n            100% { opacity: 1; transform: scale(1); }\n          }\n          @keyframes typo-spotlight {\n            0% { opacity: 0; filter: blur(14px); transform: scale(0.9) translateY(6px); }\n            100% { opacity: 1; filter: blur(0px); transform: scale(1) translateY(0); }\n          }\n          @keyframes typo-split-top {\n            0% { opacity: 0; transform: translateY(-100%); }\n            100% { opacity: 1; transform: translateY(0); }\n          }\n          @keyframes typo-split-bottom {\n            0% { opacity: 0; transform: translateY(100%); }\n            100% { opacity: 1; transform: translateY(0); }\n          }\n          @keyframes typo-underline-draw {\n            0% { stroke-dasharray: 0px 1px; opacity: 0; }\n            100% { stroke-dasharray: 1px 1px; opacity: 1; }\n          }\n          @keyframes typo-fade {\n            0% { opacity: 0; transform: translateY(4px); }\n            100% { opacity: 1; transform: translateY(0); }\n          }\n          @keyframes typo-word-in { from { opacity: 0; } }\n          @keyframes typo-word-out { to { opacity: 0; } }\n          @keyframes typo-char-in { from { opacity: 0; transform: translateY(24px) scale(0.5); } }\n          @keyframes typo-char-out { to { opacity: 0; transform: translateY(-24px) scale(0.5); } }\n          @keyframes typo-flip-out { to { transform: translateY(-100%); opacity: 0; } }\n          @keyframes typo-flip-in { from { transform: translateY(100%); opacity: 0; } }\n          @keyframes typo-blur-rise { from { opacity: 0; transform: translateY(18px); filter: blur(8px); } }\n          @keyframes typo-wipe-in {\n            from { clip-path: inset(-0.2em 100% -0.2em -0.1em); }\n            to { clip-path: inset(-0.2em -0.1em -0.2em -0.1em); }\n          }\n          @keyframes typo-wipe-out {\n            from { clip-path: inset(-0.2em -0.1em -0.2em -0.1em); }\n            to { clip-path: inset(-0.2em -0.1em -0.2em 100%); }\n          }\n          @media (prefers-reduced-motion: reduce) {\n            [data-typo-root] *, [data-typo-root] *::before, [data-typo-root] *::after {\n              animation-duration: 0.01ms !important;\n              animation-delay: 0ms !important;\n              animation-iteration-count: 1 !important;\n              transition-duration: 0.01ms !important;\n              transition-delay: 0ms !important;\n            }\n          }\n        `}</style>\n\n        <Component\n          ref={ref}\n          data-typo-root=\"\"\n          className={cn(\n            !bare && baseStyles[Tag],\n            align && alignStyles[align],\n            sizeClass,\n            colorClass,\n            className,\n          )}\n          style={{ ...colorStyle, ...customSizeStyle, ...style }}\n          {...props}\n        >\n          <span className=\"sr-only\">\n            {titlePrefix}\n            {still}\n            {titleSuffix}\n          </span>\n          <span\n            aria-hidden=\"true\"\n            className={\n              bare\n                ? undefined\n                : \"inline-block w-full max-w-full whitespace-nowrap overflow-visible py-2\"\n            }\n          >\n            {titlePrefix}\n            {renderContent()}\n            {titleSuffix}\n          </span>\n        </Component>\n      </>\n    );\n  },\n);\nAnimatedTypographyBlock.displayName = \"AnimatedTypographyBlock\";\n",
      "type": "registry:component",
      "target": "components/custom/blocks/AnimatedTypographyBlock.tsx"
    },
    {
      "path": "src/components/custom/blocks/animated-typography-theme.ts",
      "content": "\"use client\";\n\nimport React from \"react\";\n\nconst colorThemes: Record<string, string> = {\n  primary: \"text-primary\",\n  indigo: \"text-indigo-600 dark:text-indigo-400\",\n  rose: \"text-rose-600 dark:text-rose-400\",\n  emerald: \"text-emerald-600 dark:text-emerald-400\",\n  zinc: \"text-zinc-900 dark:text-zinc-100\",\n  default: \"text-primary\",\n};\n\nconst hexToRgb = (hex: string) => {\n  let cleanHex = hex.replace(/^#/, \"\");\n  if (cleanHex.length === 3)\n    cleanHex = cleanHex\n      .split(\"\")\n      .map((x) => x + x)\n      .join(\"\");\n  const result = /^([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(cleanHex);\n  return result\n    ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`\n    : \"99, 102, 241\";\n};\n\nexport const getThemeStyles = (color: string) => {\n  if (color && color.startsWith(\"#\")) {\n    const rgb = hexToRgb(color);\n    return {\n      className: \"text-[var(--typo-color)]\",\n      style: {\n        \"--typo-color\": color,\n        \"--typo-rgb\": rgb,\n      } as React.CSSProperties,\n    };\n  }\n  return { className: colorThemes[color] || colorThemes.primary, style: {} };\n};\n",
      "type": "registry:component",
      "target": "components/custom/blocks/animated-typography-theme.ts"
    }
  ],
  "docs": "Named exports: AnimatedTypographyBlock, getThemeStyles (from animated-typography-theme.ts, which installs alongside). `as` picks the tag (h1-h6, p, span, blockquote) so semantics stay correct whatever the effect. The rotating variants (letter-bounce, typewriter, word-flip, blur-swap) cycle `words` between `titlePrefix` and `titleSuffix`, one every `interval` ms (or `index`, when something else keeps time); `text` stays what screen readers announce. `prefixPosition` is fixed (the text before the words holds still) or fluid (it re-centres as the word changes). `bare` hands the type to `className` for composing into a section heading, and `play={false}` holds a reveal until it is wanted. No runtime dependencies — CSS and timers only. For per-character motion, use `text-animation`.",
  "categories": [
    "typography",
    "marketing"
  ],
  "type": "registry:component"
}
