{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chromatic-text-reveal","type":"registry:component","title":"Text Animation Dia Text Animation","description":"A Dia-inspired text effect with a fixed sentence prefix and a cycling final word revealed by a colorful sweep.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/chromatic-text-reveal.tsx","type":"registry:component","target":"@components/motion/chromatic-text-reveal.tsx","content":"\"use client\";\n// beui.dev/components/motion/text-animation\n\nimport {\n  type MotionStyle,\n  motion,\n  type UseInViewOptions,\n  useInView,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst CHROMATIC_PALETTE = [\n  \"#60a5fa\",\n  \"#818cf8\",\n  \"#c084fc\",\n  \"#fb7185\",\n  \"#fbbf24\",\n];\n\nconst TRAIL_HALF_WIDTH = 14;\nconst REVEAL_START = `-${TRAIL_HALF_WIDTH}%`;\nconst REVEAL_FINISH = `${100 + TRAIL_HALF_WIDTH}%`;\n\nexport type ChromaticTextRevealProps = {\n  /** Sentence fragment that remains fixed while the final word changes. */\n  prefix: string;\n  /** Words revealed one after another after the fixed prefix. */\n  words: string[];\n  /** Colors used along the moving chromatic edge. */\n  colors?: string[];\n  /** Final text color after the sweep passes. */\n  foregroundColor?: string;\n  /** Sweep duration in seconds. */\n  duration?: number;\n  /** Delay before the first sweep, in seconds. */\n  delay?: number;\n  /** Rest after a word finishes revealing, in seconds. */\n  pauseDuration?: number;\n  /** Returns to the first word after the final word. */\n  loop?: boolean;\n  /** Starts when the text enters the viewport. */\n  startOnView?: boolean;\n  /** Only starts on the first viewport entry. */\n  once?: boolean;\n  /** IntersectionObserver root margin used by the viewport trigger. */\n  inViewMargin?: UseInViewOptions[\"margin\"];\n  className?: string;\n};\n\nfunction composeChromaticGradient(colors: string[], foregroundColor: string) {\n  const palette = colors.length > 0 ? colors : CHROMATIC_PALETTE;\n  const colorStops = palette.map((color, index) => {\n    const offset =\n      palette.length === 1\n        ? 0\n        : -TRAIL_HALF_WIDTH +\n          (index / (palette.length - 1)) * TRAIL_HALF_WIDTH * 2;\n    const operator = offset < 0 ? \"-\" : \"+\";\n    const distance = Number(Math.abs(offset).toFixed(2));\n    return `${color} calc(var(--chromatic-sweep) ${operator} ${distance}%)`;\n  });\n\n  return `linear-gradient(90deg, ${foregroundColor} 0%, ${foregroundColor} calc(var(--chromatic-sweep) - ${TRAIL_HALF_WIDTH}%), ${colorStops.join(\", \")}, transparent calc(var(--chromatic-sweep) + ${TRAIL_HALF_WIDTH}%), transparent 100%)`;\n}\n\nexport function ChromaticTextReveal({\n  prefix,\n  words,\n  colors = CHROMATIC_PALETTE,\n  foregroundColor = \"var(--foreground)\",\n  duration = 1.2,\n  delay = 0,\n  pauseDuration = 0.8,\n  loop = true,\n  startOnView = true,\n  once = true,\n  inViewMargin,\n  className,\n}: ChromaticTextRevealProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const timerRef = useRef<number | null>(null);\n  const [wordIndex, setWordIndex] = useState(0);\n  const reduceMotion = useReducedMotion();\n  const isInView = useInView(ref, {\n    once,\n    margin: inViewMargin,\n    amount: 0.4,\n  });\n  const shouldReveal = !startOnView || isInView || reduceMotion;\n  const backgroundImage = composeChromaticGradient(colors, foregroundColor);\n  const hasWords = words.length > 0;\n  const activeIndex = hasWords ? wordIndex % words.length : 0;\n  const activeWord = words[activeIndex] ?? \"\";\n  const sizingWords = Array.from(new Set(words));\n\n  const clearPendingWord = useCallback(() => {\n    if (timerRef.current !== null) {\n      window.clearTimeout(timerRef.current);\n      timerRef.current = null;\n    }\n  }, []);\n\n  const scheduleNextWord = useCallback(() => {\n    clearPendingWord();\n    const isLastWord = activeIndex === words.length - 1;\n    if (\n      reduceMotion ||\n      !shouldReveal ||\n      words.length < 2 ||\n      (isLastWord && !loop)\n    ) {\n      return;\n    }\n\n    timerRef.current = window.setTimeout(() => {\n      setWordIndex((index) => (index + 1) % words.length);\n    }, pauseDuration * 1000);\n  }, [\n    activeIndex,\n    clearPendingWord,\n    loop,\n    pauseDuration,\n    reduceMotion,\n    shouldReveal,\n    words.length,\n  ]);\n\n  useEffect(() => clearPendingWord, [clearPendingWord]);\n\n  return (\n    <span ref={ref} className={cn(\"inline-flex items-baseline\", className)}>\n      <span className=\"whitespace-nowrap\">\n        {prefix}\n        {hasWords ? \"\\u00A0\" : null}\n      </span>\n      {hasWords ? (\n        <span className=\"relative inline-grid\">\n          {sizingWords.map((word) => (\n            <span\n              key={word}\n              aria-hidden\n              className=\"invisible col-start-1 row-start-1 whitespace-nowrap\"\n            >\n              {word}\n            </span>\n          ))}\n          {/* Moving a clipped text gradient defines this effect. Paint\n              containment bounds that deliberate repaint to the active word. */}\n          <motion.span\n            key={`${activeWord}-${activeIndex}`}\n            aria-hidden\n            initial={\n              reduceMotion\n                ? false\n                : {\n                    opacity: 0.56,\n                    filter: \"blur(6px)\",\n                    transform: \"translateY(5px)\",\n                  }\n            }\n            animate={{\n              \"--chromatic-sweep\": shouldReveal\n                ? REVEAL_FINISH\n                : REVEAL_START,\n              opacity: 1,\n              filter: \"blur(0px)\",\n              transform: \"translateY(0px)\",\n            }}\n            transition={{\n              \"--chromatic-sweep\": reduceMotion\n                ? { duration: 0 }\n                : { duration, delay, ease: EASE_IN_OUT },\n              opacity: reduceMotion\n                ? { duration: 0 }\n                : { duration: 0.28, ease: EASE_OUT },\n              filter: reduceMotion\n                ? { duration: 0 }\n                : { duration: 0.36, ease: EASE_OUT },\n              transform: reduceMotion\n                ? { duration: 0 }\n                : { duration: 0.36, ease: EASE_OUT },\n            }}\n            onAnimationComplete={scheduleNextWord}\n            className=\"absolute start-0 top-0 whitespace-nowrap bg-clip-text text-transparent [background-image:var(--chromatic-gradient)] [contain:paint]\"\n            style={{\n              \"--chromatic-sweep\": reduceMotion\n                ? REVEAL_FINISH\n                : REVEAL_START,\n              \"--chromatic-gradient\": backgroundImage,\n              backgroundSize: \"100% 100%\",\n              backgroundRepeat: \"no-repeat\",\n            } as MotionStyle}\n          >\n            {activeWord}\n          </motion.span>\n          <span className=\"sr-only\">{activeWord}</span>\n        </span>\n      ) : null}\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}