{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-animation","type":"registry:component","title":"Text Animation","description":"One component, three ways to animate text: a scramble that resolves into place, a word or character reveal out of a blur, and a loading shimmer sweep.","author":"Ryan","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/text-animation.tsx","type":"registry:component","target":"@components/motion/text-animation.tsx","content":"\"use client\";\n// easeui.dev/components/motion/text-animation\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { type ElementType, type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst SCRAMBLE_GLYPHS = \"ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/\";\n/** Scramble tick rate. Fast enough to read as noise, cheap enough for a plain interval. */\nconst SCRAMBLE_FRAME_MS = 40;\n/**\n * The shimmer highlight sweeps across an oversized gradient, on loop. With a\n * 200% background-size, a background-position swing of exactly 200 points\n * moves the paint by exactly one tile width (the pixel shift is (container -\n * image) * ΔP/100 = -container * 2, i.e. one image-width, since image =\n * 2×container) — so the pattern lines back up perfectly and the loop\n * restart is invisible instead of jumping.\n */\nconst SHIMMER_SWEEP: Keyframe[] = [{ backgroundPosition: \"200% 0\" }, { backgroundPosition: \"0% 0\" }];\n\nfunction prefersReducedMotion() {\n  return typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\nexport interface TextAnimationProps {\n  /** Which animation runs. */\n  variant: \"scramble\" | \"reveal\" | \"shimmer\";\n  /** Text to animate. Reveal accepts an array to render each entry as its own line. Required for scramble and reveal. */\n  text?: string | string[];\n  /** Content to animate. Shimmer takes children instead of `text`, so it can wrap rich markup. */\n  children?: ReactNode;\n  className?: string;\n  /** Scramble: max duration in milliseconds, default 900. Shimmer: seconds per sweep, default 2.5. */\n  duration?: number;\n  /** Scramble only. Characters sampled while unresolved positions are scrambling. */\n  glyphs?: string;\n  /** Reveal only. Element the lines render inside. Default span. */\n  as?: ElementType;\n  /** Reveal only. Splits each line into words or characters. Default word. */\n  split?: \"word\" | \"char\";\n  /** Reveal only. Delay between each word or character, in seconds. Default 0.09. */\n  stagger?: number;\n  /** Reveal only. Delay before the first element, in seconds. Default 0. */\n  delay?: number;\n  /** Reveal only. Starting blur, in pixels. Default 12. */\n  blur?: number;\n  /** Reveal only. Starting vertical offset. Default \"40%\". */\n  yOffset?: string | number;\n  /** Reveal only. Switches from the default tween to a spring with these physical params. */\n  spring?: { stiffness?: number; damping?: number; mass?: number };\n  /** Reveal only. With whileInView, only plays the first time it enters view. Default true. */\n  once?: boolean;\n  /** Reveal only. Reveals when scrolled into view instead of on mount. Default false. */\n  whileInView?: boolean;\n}\n\nfunction ScrambleText({ text, duration = 900, glyphs = SCRAMBLE_GLYPHS, className }: TextAnimationProps) {\n  const [display, setDisplay] = useState(text as string);\n  const frame = useRef(0);\n\n  useEffect(() => {\n    const value = text as string;\n    if (prefersReducedMotion()) {\n      setDisplay(value);\n      return;\n    }\n\n    const totalFrames = Math.max(1, Math.round(duration / SCRAMBLE_FRAME_MS));\n    frame.current = 0;\n\n    const id = window.setInterval(() => {\n      frame.current += 1;\n      const progress = frame.current / totalFrames;\n\n      setDisplay(\n        value\n          .split(\"\")\n          .map((char, index) => {\n            if (char === \" \") return char;\n            // Resolves left to right, each position settling a bit before the next.\n            const resolvesAt = (index + 1) / value.length;\n            return progress >= resolvesAt ? char : glyphs[Math.floor(Math.random() * glyphs.length)];\n          })\n          .join(\"\"),\n      );\n\n      if (frame.current >= totalFrames) window.clearInterval(id);\n    }, SCRAMBLE_FRAME_MS);\n\n    return () => window.clearInterval(id);\n  }, [text, duration, glyphs]);\n\n  return (\n    <span className={cn(\"inline-block\", className)}>\n      <span aria-hidden=\"true\">{display}</span>\n      <span className=\"sr-only\">{text}</span>\n    </span>\n  );\n}\n\nfunction ShimmerText({ duration = 2.5, className, children }: TextAnimationProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const element = ref.current;\n    if (!element || typeof element.animate !== \"function\") return;\n\n    const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const animation = element.animate(SHIMMER_SWEEP, {\n      duration: duration * 1000,\n      iterations: Number.POSITIVE_INFINITY,\n      easing: \"linear\",\n    });\n    let onScreen = true;\n\n    const sync = () => {\n      if (reducedMotion.matches || !onScreen) animation.pause();\n      else animation.play();\n    };\n\n    const observer = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting;\n      sync();\n    });\n    observer.observe(element);\n    reducedMotion.addEventListener(\"change\", sync);\n    sync();\n\n    return () => {\n      observer.disconnect();\n      reducedMotion.removeEventListener(\"change\", sync);\n      animation.cancel();\n    };\n  }, [duration]);\n\n  return (\n    <span\n      ref={ref}\n      className={cn(\"inline-block bg-clip-text text-transparent [-webkit-background-clip:text]\", className)}\n      style={{\n        backgroundImage:\n          \"linear-gradient(90deg, var(--muted-foreground) 40%, var(--foreground) 50%, var(--muted-foreground) 60%)\",\n        backgroundSize: \"200% 100%\",\n      }}\n    >\n      {children}\n    </span>\n  );\n}\n\nfunction splitLine(line: string, split: \"word\" | \"char\") {\n  return split === \"char\" ? Array.from(line) : line.split(\" \");\n}\n\nfunction RevealText({\n  text,\n  as: As = \"span\",\n  className,\n  split = \"word\",\n  stagger = 0.09,\n  delay = 0,\n  blur = 12,\n  yOffset = \"40%\",\n  spring,\n  once = true,\n  whileInView = false,\n}: TextAnimationProps) {\n  const reduce = useReducedMotion();\n  const lines = Array.isArray(text) ? text : [text as string];\n  // A tween reads as a clear per-word cascade at this stagger interval; a spring long\n  // enough to feel springy overlaps neighboring words too much and reads as one fade.\n  const transition = spring ? { type: \"spring\" as const, ...spring } : { duration: 0.4, ease: EASE_OUT };\n\n  let index = -1;\n\n  return (\n    <As className={cn(\"block\", className)}>\n      {lines.map((line, lineIndex) => (\n        // biome-ignore lint/suspicious/noArrayIndexKey: lines are a static prop, never reordered.\n        <span key={lineIndex} className={cn(\"block\", split === \"word\" && \"flex flex-wrap gap-x-[0.25em]\")}>\n          {splitLine(line, split).map((token) => {\n            index += 1;\n            const tokenDelay = delay + index * stagger;\n            return (\n              // A wrapping overflow-hidden wound clip the blur halo into a hard-edged\n              // rectangle per word, which is what made this read as one blurred block\n              // instead of each word — so the blur is left free to bleed past the glyph.\n              <motion.span\n                key={index}\n                className=\"inline-block\"\n                initial={reduce ? false : { opacity: 0, y: yOffset, filter: `blur(${blur}px)` }}\n                animate={whileInView ? undefined : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                whileInView={whileInView ? { opacity: 1, y: 0, filter: \"blur(0px)\" } : undefined}\n                viewport={whileInView ? { once } : undefined}\n                transition={{ ...transition, delay: tokenDelay }}\n              >\n                {token === \"\" ? \" \" : token}\n              </motion.span>\n            );\n          })}\n        </span>\n      ))}\n    </As>\n  );\n}\n\n/**\n * One component for animated text, switched with `variant`: `scramble`\n * resolves random glyphs into the final characters, `reveal` slides words or\n * characters up out of a blur, and `shimmer` sweeps a highlight band across a\n * loop for a loading or emphasis state. Reduced motion shows the final text\n * still, with no animation.\n */\nexport function TextAnimation(props: TextAnimationProps) {\n  if (props.variant === \"scramble\") return <ScrambleText {...props} />;\n  if (props.variant === \"shimmer\") return <ShimmerText {...props} />;\n  return <RevealText {...props} />;\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\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.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\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 closely 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"}]}