{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-text",
  "title": "Animated Text",
  "description": "Staggered text reveal for headings, pull quotes, and short supporting copy.",
  "dependencies": [
    "@vllnt/ui@^0.3.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/animated-text/animated-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nconst GLYPH_SEGMENTER = new Intl.Segmenter(undefined, {\n  granularity: \"grapheme\",\n});\n\nconst ASCII_RANDOM_CHARACTERS = Array.from({ length: 94 }, (_, index) =>\n  String.fromCodePoint(index + 33),\n).join(\"\");\nconst TERMINAL_RANDOM_CHARACTERS = \"│┃─━┄┅┈┉┌┐└┘├┤┬┴┼╭╮╯╰╱╲╳\";\nconst BLOCK_RANDOM_CHARACTERS = \"░▒▓█▌▐▀▄■□▪▫▖▗▘▙▚▛▜▝▞▟\";\nconst UNICODE_SYMBOL_RANDOM_CHARACTERS = \"◆◇◈○●◎◉◌◍◐◑◒◓◔◕◢◣◤◥◦※✦✧✱✶✷✹\";\nconst MATRIX_RANDOM_CHARACTERS = `${ASCII_RANDOM_CHARACTERS}${TERMINAL_RANDOM_CHARACTERS}${BLOCK_RANDOM_CHARACTERS}${UNICODE_SYMBOL_RANDOM_CHARACTERS}`;\n\nexport const ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS = {\n  ascii: ASCII_RANDOM_CHARACTERS,\n  binary: \"01\",\n  blocks: BLOCK_RANDOM_CHARACTERS,\n  matrix: MATRIX_RANDOM_CHARACTERS,\n  symbols: UNICODE_SYMBOL_RANDOM_CHARACTERS,\n  terminal: TERMINAL_RANDOM_CHARACTERS,\n} as const;\n\nconst DEFAULT_RANDOM_CHARACTERS = ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS.matrix;\n\ntype AnimatedTextSplit = \"character\" | \"word\";\nexport type AnimatedTextVariant =\n  | \"decipher\"\n  | \"matrix\"\n  | \"reveal\"\n  | \"terminal\"\n  | \"typewriter\";\nexport type AnimatedTextDirection = \"center-out\" | \"end\" | \"random\" | \"start\";\nexport type AnimatedTextRandomCharacterPreset =\n  keyof typeof ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS;\n\ntype SegmentFrame = {\n  content: string;\n  isRevealed: boolean;\n  key: string;\n  style?: React.CSSProperties;\n};\n\nexport type AnimatedTextProps = React.ComponentPropsWithoutRef<\"p\"> & {\n  cursor?: boolean;\n  cursorChar?: string;\n  direction?: AnimatedTextDirection;\n  duration?: number;\n  randomCharacters?: string;\n  randomCharactersPreset?: AnimatedTextRandomCharacterPreset;\n  randomness?: number;\n  splitBy?: AnimatedTextSplit;\n  stagger?: number;\n  text: string;\n  variant?: AnimatedTextVariant;\n};\n\nfunction getSegments(text: string, splitBy: AnimatedTextSplit): string[] {\n  if (splitBy === \"character\") {\n    return Array.from(GLYPH_SEGMENTER.segment(text), ({ segment }) => segment);\n  }\n\n  return text.match(/\\S+\\s*/g) ?? [];\n}\n\nfunction getGlyphs(text: string): string[] {\n  return Array.from(GLYPH_SEGMENTER.segment(text), ({ segment }) => segment);\n}\n\nfunction getRandomMatrixGlyph(randomCharacters: string): string {\n  const glyphs = getGlyphs(randomCharacters);\n\n  return glyphs[Math.floor(Math.random() * glyphs.length)] ?? glyphs[0] ?? \"0\";\n}\n\nfunction getResolvedRandomCharacters(\n  randomCharacters: string | undefined,\n  randomCharactersPreset: AnimatedTextRandomCharacterPreset,\n): string {\n  if (randomCharacters && randomCharacters.length > 0) {\n    return randomCharacters;\n  }\n\n  return (\n    ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS[randomCharactersPreset] ??\n    DEFAULT_RANDOM_CHARACTERS\n  );\n}\n\nfunction getCursorToneClass(variant: AnimatedTextVariant): string {\n  return variant === \"matrix\" || variant === \"decipher\"\n    ? \"text-primary\"\n    : \"text-foreground\";\n}\n\nfunction buildRevealFrames(\n  segments: string[],\n  stagger: number,\n): SegmentFrame[] {\n  return segments.map((segment, index) => ({\n    content: segment,\n    isRevealed: true,\n    key: `${segment}-${index}`,\n    style: {\n      animationDelay: `${index * stagger}ms`,\n    },\n  }));\n}\n\nfunction buildIndexOrder(\n  direction: AnimatedTextDirection,\n  length: number,\n): number[] {\n  if (direction === \"end\") {\n    return Array.from({ length }, (_, index) => length - index - 1);\n  }\n\n  if (direction === \"random\") {\n    return Array.from({ length }, (_, index) => index).sort(\n      () => Math.random() - 0.5,\n    );\n  }\n\n  if (direction === \"center-out\") {\n    const center = (length - 1) / 2;\n\n    return Array.from({ length }, (_, index) => index).sort((left, right) => {\n      const leftDistance = Math.abs(left - center);\n      const rightDistance = Math.abs(right - center);\n\n      if (leftDistance === rightDistance) {\n        return left - right;\n      }\n\n      return leftDistance - rightDistance;\n    });\n  }\n\n  return Array.from({ length }, (_, index) => index);\n}\n\nfunction buildRevealPlan(\n  direction: AnimatedTextDirection,\n  length: number,\n  randomness: number,\n): number[] {\n  const orderedIndices = buildIndexOrder(direction, length);\n  const revealPlan = Array.from({ length }, () => 0);\n  const jitterRange = Math.max(0, Math.round(randomness * 4));\n\n  orderedIndices.forEach((segmentIndex, revealIndex) => {\n    const jitter =\n      jitterRange > 0 ? Math.floor(Math.random() * (jitterRange + 1)) : 0;\n    revealPlan[segmentIndex] = revealIndex + jitter;\n  });\n\n  return revealPlan;\n}\n\nfunction useRevealProgress(active: boolean, length: number, stagger: number) {\n  const [progress, setProgress] = React.useState(() => (active ? 0 : length));\n  const [revealKey, setRevealKey] = React.useState({ active, length, stagger });\n\n  if (\n    revealKey.active !== active ||\n    revealKey.length !== length ||\n    revealKey.stagger !== stagger\n  ) {\n    setRevealKey({ active, length, stagger });\n    setProgress(active ? 0 : length);\n  }\n\n  React.useEffect(() => {\n    if (!active) {\n      return;\n    }\n\n    const revealInterval = window.setInterval(\n      () => {\n        setProgress((current) => {\n          if (current >= length + 4) {\n            window.clearInterval(revealInterval);\n            return current;\n          }\n\n          return current + 1;\n        });\n      },\n      Math.max(16, stagger),\n    );\n\n    return () => {\n      window.clearInterval(revealInterval);\n    };\n  }, [active, length, stagger]);\n\n  return progress;\n}\n\nfunction useMatrixFrame({\n  active,\n  progress,\n  randomCharacters,\n  revealPlan,\n  segments,\n}: {\n  active: boolean;\n  progress: number;\n  randomCharacters: string;\n  revealPlan: number[];\n  segments: string[];\n}) {\n  const [matrixFrame, setMatrixFrame] = React.useState(() =>\n    segments.map(() => getRandomMatrixGlyph(randomCharacters)),\n  );\n\n  React.useEffect(() => {\n    if (!active) {\n      return;\n    }\n\n    setMatrixFrame(segments.map(() => getRandomMatrixGlyph(randomCharacters)));\n\n    const scrambleInterval = window.setInterval(() => {\n      setMatrixFrame((current) =>\n        current.map((glyph, index) => {\n          const isWhitespace = /^\\s+$/.test(segments[index] ?? \"\");\n          const isRevealed = progress >= (revealPlan[index] ?? 0);\n\n          if (isWhitespace || isRevealed) {\n            return glyph;\n          }\n\n          return getRandomMatrixGlyph(randomCharacters);\n        }),\n      );\n    }, 48);\n\n    return () => {\n      window.clearInterval(scrambleInterval);\n    };\n  }, [active, progress, randomCharacters, revealPlan, segments]);\n\n  return matrixFrame;\n}\n\nfunction buildOldSchoolFrames({\n  matrixFrame,\n  progress,\n  randomCharacters,\n  revealPlan,\n  segments,\n  variant,\n}: {\n  matrixFrame: string[];\n  progress: number;\n  randomCharacters: string;\n  revealPlan: number[];\n  segments: string[];\n  variant: Exclude<AnimatedTextVariant, \"reveal\">;\n}): SegmentFrame[] {\n  return segments.map((segment, index) => {\n    const isWhitespace = /^\\s+$/.test(segment);\n    const revealStep = revealPlan[index] ?? 0;\n    const isRevealed = progress >= revealStep;\n\n    let content = \"\";\n    if (variant === \"matrix\" || variant === \"decipher\") {\n      content = isWhitespace\n        ? segment\n        : isRevealed\n          ? segment\n          : (matrixFrame[index] ?? getRandomMatrixGlyph(randomCharacters));\n    } else if (isRevealed) {\n      content = segment;\n    }\n\n    return {\n      content,\n      isRevealed,\n      key: `${segment}-${index}`,\n    };\n  });\n}\n\nfunction useAnimatedTextFrames({\n  direction,\n  randomCharacters,\n  randomness,\n  segments,\n  stagger,\n  variant,\n}: {\n  direction: AnimatedTextDirection;\n  randomCharacters: string;\n  randomness: number;\n  segments: string[];\n  stagger: number;\n  variant: AnimatedTextVariant;\n}): SegmentFrame[] {\n  const isOldSchool = variant !== \"reveal\";\n  const revealPlan = React.useMemo(\n    () =>\n      isOldSchool\n        ? buildRevealPlan(direction, segments.length, randomness)\n        : Array.from({ length: segments.length }, (_, index) => index),\n    [direction, isOldSchool, randomness, segments.length],\n  );\n  const progress = useRevealProgress(isOldSchool, segments.length, stagger);\n  const matrixFrame = useMatrixFrame({\n    active: variant === \"matrix\" || variant === \"decipher\",\n    progress,\n    randomCharacters,\n    revealPlan,\n    segments,\n  });\n\n  return React.useMemo(() => {\n    if (!isOldSchool) {\n      return buildRevealFrames(segments, stagger);\n    }\n\n    return buildOldSchoolFrames({\n      matrixFrame,\n      progress,\n      randomCharacters,\n      revealPlan,\n      segments,\n      variant,\n    });\n  }, [\n    isOldSchool,\n    matrixFrame,\n    progress,\n    randomCharacters,\n    revealPlan,\n    segments,\n    stagger,\n    variant,\n  ]);\n}\n\nfunction getSegmentClasses(\n  variant: AnimatedTextVariant,\n  isRevealed: boolean,\n): string {\n  if (variant === \"reveal\") {\n    return \"inline-block whitespace-pre opacity-0 [animation-duration:var(--vllnt-animated-text-duration)] [animation-fill-mode:forwards] [animation-name:vllnt-animated-text-reveal] [animation-timing-function:cubic-bezier(0.16,1,0.3,1)]\";\n  }\n\n  if (variant === \"matrix\" || variant === \"decipher\") {\n    return cn(\n      \"inline-block whitespace-pre font-mono tracking-[0.08em] transition-colors duration-150\",\n      isRevealed ? \"text-foreground\" : \"text-primary/75\",\n    );\n  }\n\n  return \"inline-block whitespace-pre font-mono\";\n}\n\nfunction getContainerClasses(variant: AnimatedTextVariant): string {\n  if (variant === \"matrix\" || variant === \"decipher\") {\n    return \"flex flex-wrap font-mono leading-relaxed tracking-[0.08em]\";\n  }\n\n  if (variant === \"terminal\" || variant === \"typewriter\") {\n    return \"flex flex-wrap font-mono leading-relaxed\";\n  }\n\n  return \"flex flex-wrap leading-relaxed\";\n}\n\nfunction AnimatedTextCursor({\n  cursorChar,\n  cursorToneClass,\n}: {\n  cursorChar: string;\n  cursorToneClass: string;\n}) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"ml-0.5 inline-block whitespace-pre font-mono [animation:vllnt-terminal-cursor-blink_1s_steps(1,end)_infinite]\",\n        cursorToneClass,\n      )}\n    >\n      {cursorChar}\n    </span>\n  );\n}\n\nexport const AnimatedText = ({\n  className,\n  cursor = true,\n  cursorChar = \"█\",\n  direction = \"start\",\n  duration = 600,\n  randomCharacters,\n  randomCharactersPreset = \"matrix\",\n  randomness = 0,\n  ref,\n  splitBy = \"word\",\n  stagger = 70,\n  text,\n  variant = \"terminal\",\n  ...props\n}: AnimatedTextProps & { ref?: React.Ref<HTMLParagraphElement> }) => {\n  const resolvedRandomCharacters = getResolvedRandomCharacters(\n    randomCharacters,\n    randomCharactersPreset,\n  );\n  const resolvedSplitBy = variant === \"reveal\" ? splitBy : \"character\";\n  const segments = React.useMemo(\n    () => getSegments(text, resolvedSplitBy),\n    [resolvedSplitBy, text],\n  );\n  const segmentFrames = useAnimatedTextFrames({\n    direction,\n    randomCharacters: resolvedRandomCharacters,\n    randomness,\n    segments,\n    stagger,\n    variant,\n  });\n  const showCursor =\n    cursor &&\n    variant !== \"reveal\" &&\n    segmentFrames.some((frame) => !frame.isRevealed);\n  const cursorToneClass = getCursorToneClass(variant);\n\n  return (\n    <p\n      aria-label={text}\n      className={cn(getContainerClasses(variant), className)}\n      ref={ref}\n      style={{\n        [\"--vllnt-animated-text-duration\" as string]: `${duration}ms`,\n      }}\n      {...props}\n    >\n      {segmentFrames.map((segmentFrame) => (\n        <span\n          aria-hidden=\"true\"\n          className={getSegmentClasses(variant, segmentFrame.isRevealed)}\n          key={segmentFrame.key}\n          style={segmentFrame.style}\n        >\n          {segmentFrame.content}\n        </span>\n      ))}\n      {showCursor ? (\n        <AnimatedTextCursor\n          cursorChar={cursorChar}\n          cursorToneClass={cursorToneClass}\n        />\n      ) : null}\n    </p>\n  );\n};\n\nAnimatedText.displayName = \"AnimatedText\";\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component",
  "version": "0.3.0",
  "stability": "stable"
}
