"use client"; import { motion, useReducedMotion } from "motion/react"; import { type ElementType, type ReactNode, useEffect, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; const SCRAMBLE_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/"; /** Scramble tick rate. Fast enough to read as noise, cheap enough for a plain interval. */ const SCRAMBLE_FRAME_MS = 40; /** * The shimmer highlight sweeps across an oversized gradient, on loop. With a * 200% background-size, a background-position swing of exactly 200 points * moves the paint by exactly one tile width (the pixel shift is (container - * image) * ΔP/100 = -container * 2, i.e. one image-width, since image = * 2×container) — so the pattern lines back up perfectly and the loop * restart is invisible instead of jumping. */ const SHIMMER_SWEEP: Keyframe[] = [{ backgroundPosition: "200% 0" }, { backgroundPosition: "0% 0" }]; function prefersReducedMotion() { return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } export interface TextAnimationProps { /** Which animation runs. */ variant: "scramble" | "reveal" | "shimmer"; /** Text to animate. Reveal accepts an array to render each entry as its own line. Required for scramble and reveal. */ text?: string | string[]; /** Content to animate. Shimmer takes children instead of `text`, so it can wrap rich markup. */ children?: ReactNode; className?: string; /** Scramble: max duration in milliseconds, default 900. Shimmer: seconds per sweep, default 2.5. */ duration?: number; /** Scramble only. Characters sampled while unresolved positions are scrambling. */ glyphs?: string; /** Reveal only. Element the lines render inside. Default span. */ as?: ElementType; /** Reveal only. Splits each line into words or characters. Default word. */ split?: "word" | "char"; /** Reveal only. Delay between each word or character, in seconds. Default 0.09. */ stagger?: number; /** Reveal only. Delay before the first element, in seconds. Default 0. */ delay?: number; /** Reveal only. Starting blur, in pixels. Default 12. */ blur?: number; /** Reveal only. Starting vertical offset. Default "40%". */ yOffset?: string | number; /** Reveal only. Switches from the default tween to a spring with these physical params. */ spring?: { stiffness?: number; damping?: number; mass?: number }; /** Reveal only. With whileInView, only plays the first time it enters view. Default true. */ once?: boolean; /** Reveal only. Reveals when scrolled into view instead of on mount. Default false. */ whileInView?: boolean; } function ScrambleText({ text, duration = 900, glyphs = SCRAMBLE_GLYPHS, className }: TextAnimationProps) { const [display, setDisplay] = useState(text as string); const frame = useRef(0); useEffect(() => { const value = text as string; if (prefersReducedMotion()) { setDisplay(value); return; } const totalFrames = Math.max(1, Math.round(duration / SCRAMBLE_FRAME_MS)); frame.current = 0; const id = window.setInterval(() => { frame.current += 1; const progress = frame.current / totalFrames; setDisplay( value .split("") .map((char, index) => { if (char === " ") return char; // Resolves left to right, each position settling a bit before the next. const resolvesAt = (index + 1) / value.length; return progress >= resolvesAt ? char : glyphs[Math.floor(Math.random() * glyphs.length)]; }) .join(""), ); if (frame.current >= totalFrames) window.clearInterval(id); }, SCRAMBLE_FRAME_MS); return () => window.clearInterval(id); }, [text, duration, glyphs]); return ( {text} ); } function ShimmerText({ duration = 2.5, className, children }: TextAnimationProps) { const ref = useRef(null); useEffect(() => { const element = ref.current; if (!element || typeof element.animate !== "function") return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const animation = element.animate(SHIMMER_SWEEP, { duration: duration * 1000, iterations: Number.POSITIVE_INFINITY, easing: "linear", }); let onScreen = true; const sync = () => { if (reducedMotion.matches || !onScreen) animation.pause(); else animation.play(); }; const observer = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; sync(); }); observer.observe(element); reducedMotion.addEventListener("change", sync); sync(); return () => { observer.disconnect(); reducedMotion.removeEventListener("change", sync); animation.cancel(); }; }, [duration]); return ( {children} ); } function splitLine(line: string, split: "word" | "char") { return split === "char" ? Array.from(line) : line.split(" "); } function RevealText({ text, as: As = "span", className, split = "word", stagger = 0.09, delay = 0, blur = 12, yOffset = "40%", spring, once = true, whileInView = false, }: TextAnimationProps) { const reduce = useReducedMotion(); const lines = Array.isArray(text) ? text : [text as string]; // A tween reads as a clear per-word cascade at this stagger interval; a spring long // enough to feel springy overlaps neighboring words too much and reads as one fade. const transition = spring ? { type: "spring" as const, ...spring } : { duration: 0.4, ease: EASE_OUT }; let index = -1; return ( {lines.map((line, lineIndex) => ( // biome-ignore lint/suspicious/noArrayIndexKey: lines are a static prop, never reordered. {splitLine(line, split).map((token) => { index += 1; const tokenDelay = delay + index * stagger; return ( // A wrapping overflow-hidden wound clip the blur halo into a hard-edged // rectangle per word, which is what made this read as one blurred block // instead of each word — so the blur is left free to bleed past the glyph. {token === "" ? " " : token} ); })} ))} ); } /** * One component for animated text, switched with `variant`: `scramble` * resolves random glyphs into the final characters, `reveal` slides words or * characters up out of a blur, and `shimmer` sweeps a highlight band across a * loop for a loading or emphasis state. Reduced motion shows the final text * still, with no animation. */ export function TextAnimation(props: TextAnimationProps) { if (props.variant === "scramble") return ; if (props.variant === "shimmer") return ; return ; }