{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A RollingText text animation component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\nexport type RollingTextDirection = \"up\" | \"down\";\nexport type RollingTextTrigger = \"mount\" | \"inView\" | \"hover\" | \"manual\";\n\nexport interface RollingTextProps {\n  className?: string;\n  /** Direction the glyph column rolls in before landing. */\n  direction?: RollingTextDirection;\n  /** Roll duration per character, in milliseconds. */\n  duration?: number;\n  /**\n   * Ramp alphabet cycled through before landing on the target character.\n   * Defaults to the unique characters found in `text`.\n   */\n  glyphs?: string;\n  /** Plays the roll when `trigger` is \"manual\" and this flips to `true`. */\n  playing?: boolean;\n  /** Number of intermediate glyphs shown before the target lands. */\n  rampLength?: number;\n  /** Per-character stagger, in milliseconds. */\n  stagger?: number;\n  text: string;\n  trigger?: RollingTextTrigger;\n}\n\ninterface RollingCharacter {\n  isWhitespace: boolean;\n  items: string[];\n  key: string;\n  targetIndex: number;\n}\n\nconst DEFAULT_DURATION = 500;\nconst DEFAULT_STAGGER = 40;\nconst DEFAULT_RAMP_LENGTH = 4;\nconst MS = 1000;\nconst PERCENT = 100;\nconst DEFAULT_GLYPH_POOL =\n  \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\";\nconst MOVEMENT_EASE = [0.645, 0.045, 0.355, 1] as const;\n\nconst segmentGraphemes = (value: string): string[] => {\n  if (typeof Intl !== \"undefined\" && \"Segmenter\" in Intl) {\n    const segmenter = new Intl.Segmenter(undefined, {\n      granularity: \"grapheme\",\n    });\n    return Array.from(segmenter.segment(value), (entry) => entry.segment);\n  }\n  return [...value];\n};\n\nconst buildGlyphPool = (text: string, glyphs?: string): string[] => {\n  if (glyphs && glyphs.length > 0) {\n    return segmentGraphemes(glyphs);\n  }\n  const unique = Array.from(\n    new Set(segmentGraphemes(text).filter((char) => char.trim().length > 0))\n  );\n  return unique.length > 1 ? unique : segmentGraphemes(DEFAULT_GLYPH_POOL);\n};\n\nconst buildRamp = (\n  target: string,\n  pool: string[],\n  rampLength: number,\n  direction: RollingTextDirection\n): string[] => {\n  const randomGlyphs = Array.from(\n    { length: rampLength },\n    () => pool[Math.floor(Math.random() * pool.length)] ?? target\n  );\n  const ramp = [...randomGlyphs, target];\n  return direction === \"down\" ? ramp.reverse() : ramp;\n};\n\nconst buildCharacters = (\n  text: string,\n  pool: string[],\n  rampLength: number,\n  direction: RollingTextDirection\n): RollingCharacter[] => {\n  const occurrences = new Map<string, number>();\n  return segmentGraphemes(text).map((char) => {\n    const count = occurrences.get(char) ?? 0;\n    occurrences.set(char, count + 1);\n    const isWhitespace = char.trim().length === 0;\n    const items = isWhitespace\n      ? [char]\n      : buildRamp(char, pool, rampLength, direction);\n    return {\n      isWhitespace,\n      items,\n      key: `${char}-${count}`,\n      targetIndex: direction === \"down\" ? 0 : items.length - 1,\n    };\n  });\n};\n\n/**\n * RollingText — odometer-style vertical roll. Each character cycles\n * through a short ramp of glyphs, like a split-flap display, before\n * landing on its target.\n */\nexport default function RollingText({\n  text,\n  className,\n  direction = \"up\",\n  duration = DEFAULT_DURATION,\n  glyphs,\n  playing = false,\n  rampLength = DEFAULT_RAMP_LENGTH,\n  stagger = DEFAULT_STAGGER,\n  trigger = \"mount\",\n}: RollingTextProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true });\n  const shouldReduceMotion = useReducedMotion();\n  const [playCount, setPlayCount] = useState(() =>\n    trigger === \"mount\" ? 1 : 0\n  );\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n  const wasPlayingRef = useRef(playing);\n\n  useEffect(() => {\n    const hoverQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverDevice(hoverQuery.matches);\n    const handleHoverChange = (event: MediaQueryListEvent) => {\n      setIsHoverDevice(event.matches);\n    };\n    hoverQuery.addEventListener(\"change\", handleHoverChange);\n    return () => hoverQuery.removeEventListener(\"change\", handleHoverChange);\n  }, []);\n\n  useEffect(() => {\n    if (trigger === \"inView\" && inView && !shouldReduceMotion) {\n      setPlayCount((count) => count + 1);\n    }\n  }, [trigger, inView, shouldReduceMotion]);\n\n  useEffect(() => {\n    if (\n      trigger === \"manual\" &&\n      playing &&\n      !wasPlayingRef.current &&\n      !shouldReduceMotion\n    ) {\n      setPlayCount((count) => count + 1);\n    }\n    wasPlayingRef.current = playing;\n  }, [trigger, playing, shouldReduceMotion]);\n\n  const pool = useMemo(() => buildGlyphPool(text, glyphs), [text, glyphs]);\n  const characters = useMemo(\n    () => buildCharacters(text, pool, rampLength, direction),\n    [text, pool, rampLength, direction, playCount]\n  );\n\n  const handlePointerEnter = () => {\n    if (trigger === \"hover\" && isHoverDevice && !shouldReduceMotion) {\n      setPlayCount((count) => count + 1);\n    }\n  };\n\n  if (shouldReduceMotion) {\n    return <span className={className}>{text}</span>;\n  }\n\n  return (\n    <span\n      className={className}\n      onPointerEnter={handlePointerEnter}\n      ref={containerRef}\n    >\n      {/* Every glyph below is aria-hidden, so the readable copy lives here. An\n          aria-label on this plain span would be dropped by assistive tech. */}\n      <span className=\"sr-only\">{text}</span>\n      {characters.map((character, characterIndex) =>\n        character.isWhitespace ? (\n          <span\n            aria-hidden=\"true\"\n            key={character.key}\n            style={{ display: \"inline-block\", whiteSpace: \"pre\" }}\n          >\n            {character.items[0]}\n          </span>\n        ) : (\n          <span\n            aria-hidden=\"true\"\n            key={character.key}\n            style={{\n              display: \"inline-block\",\n              height: \"1em\",\n              lineHeight: \"1em\",\n              overflow: \"hidden\",\n              verticalAlign: \"bottom\",\n            }}\n          >\n            <motion.span\n              animate={{\n                y: `${-(character.targetIndex / character.items.length) * PERCENT}%`,\n              }}\n              initial={{\n                y:\n                  direction === \"down\"\n                    ? `${-((character.items.length - 1) / character.items.length) * PERCENT}%`\n                    : \"0%\",\n              }}\n              key={playCount}\n              style={{ display: \"flex\", flexDirection: \"column\" }}\n              transition={{\n                delay: (characterIndex * stagger) / MS,\n                duration: duration / MS,\n                ease: MOVEMENT_EASE,\n              }}\n            >\n              {character.items.map((glyph, glyphIndex) => (\n                <span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: ramp glyphs have no stable id\n                  key={glyphIndex}\n                  style={{\n                    display: \"block\",\n                    height: \"1em\",\n                    lineHeight: \"1em\",\n                  }}\n                >\n                  {glyph}\n                </span>\n              ))}\n            </motion.span>\n          </span>\n        )\n      )}\n    </span>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/rolling-text/index.tsx","type":"registry:ui"}],"name":"rolling-text","registryDependencies":[],"title":"Rolling Text","type":"registry:ui"}