{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-text",
  "type": "registry:component",
  "title": "Animated Text",
  "description": "Staggered text reveal for headings, pull quotes, and short supporting copy.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "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(0);\n\n  React.useEffect(() => {\n    if (!active) {\n      setProgress(length);\n      return;\n    }\n\n    setProgress(0);\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 = React.forwardRef<\n  HTMLParagraphElement,\n  AnimatedTextProps\n>(\n  (\n    {\n      className,\n      cursor = true,\n      cursorChar = \"█\",\n      direction = \"start\",\n      duration = 600,\n      randomCharacters,\n      randomCharactersPreset = \"matrix\",\n      randomness = 0,\n      splitBy = \"word\",\n      stagger = 70,\n      text,\n      variant = \"terminal\",\n      ...props\n    },\n    ref,\n  ) => {\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);\n\nAnimatedText.displayName = \"AnimatedText\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
