Animation
ShinyText
Text with a sweeping gradient shine that renders plain when motion is reduced.
npx bezel-add add shiny-textpackages/ui/src/animation/ShinyText.tsx · 135 lines
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { motion, useMotionValue, useAnimationFrame, useTransform } from "motion/react";
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}
export type ShinyTextProps = {
text: string;
disabled?: boolean;
speed?: number;
className?: string;
color?: string;
shineColor?: string;
spread?: number;
yoyo?: boolean;
pauseOnHover?: boolean;
direction?: "left" | "right";
delay?: number;
};
/** Gradient-clipped text with a sweeping editorial shine. */
export function ShinyText({
text,
disabled = false,
speed = 2,
className = "",
color = "hsl(45 4% 41% / 0.72)",
shineColor = "hsl(5 62% 35%)",
spread = 120,
yoyo = false,
pauseOnHover = false,
direction = "left",
delay = 0,
}: ShinyTextProps) {
const [isPaused, setIsPaused] = useState(false);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
const progress = useMotionValue(0);
const elapsedRef = useRef(0);
const lastTimeRef = useRef<number | null>(null);
const directionRef = useRef(direction === "left" ? 1 : -1);
const animationDuration = speed * 1000;
const delayDuration = delay * 1000;
const isDisabled = disabled || prefersReducedMotion;
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setPrefersReducedMotion(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
useAnimationFrame((time) => {
if (isDisabled || isPaused) {
lastTimeRef.current = null;
return;
}
if (lastTimeRef.current === null) {
lastTimeRef.current = time;
return;
}
const deltaTime = time - lastTimeRef.current;
lastTimeRef.current = time;
elapsedRef.current += deltaTime;
if (yoyo) {
const cycleDuration = animationDuration + delayDuration;
const fullCycle = cycleDuration * 2;
const cycleTime = elapsedRef.current % fullCycle;
if (cycleTime < animationDuration) {
const p = (cycleTime / animationDuration) * 100;
progress.set(directionRef.current === 1 ? p : 100 - p);
} else if (cycleTime < cycleDuration) {
progress.set(directionRef.current === 1 ? 100 : 0);
} else if (cycleTime < cycleDuration + animationDuration) {
const reverseTime = cycleTime - cycleDuration;
const p = 100 - (reverseTime / animationDuration) * 100;
progress.set(directionRef.current === 1 ? p : 100 - p);
} else {
progress.set(directionRef.current === 1 ? 0 : 100);
}
} else {
const cycleDuration = animationDuration + delayDuration;
const cycleTime = elapsedRef.current % cycleDuration;
if (cycleTime < animationDuration) {
const p = (cycleTime / animationDuration) * 100;
progress.set(directionRef.current === 1 ? p : 100 - p);
} else {
progress.set(directionRef.current === 1 ? 100 : 0);
}
}
});
useEffect(() => {
directionRef.current = direction === "left" ? 1 : -1;
elapsedRef.current = 0;
progress.set(0);
}, [direction, progress]);
const backgroundPosition = useTransform(progress, (p) => `${150 - p * 2}% center`);
const handleMouseEnter = useCallback(() => {
if (pauseOnHover) setIsPaused(true);
}, [pauseOnHover]);
const handleMouseLeave = useCallback(() => {
if (pauseOnHover) setIsPaused(false);
}, [pauseOnHover]);
if (isDisabled) {
return <span className={cn("inline-block", className)}>{text}</span>;
}
const gradientStyle = {
backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
backgroundSize: "200% auto",
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
} as const;
return (
<motion.span
className={cn("inline-block", className)}
style={{ ...gradientStyle, backgroundPosition }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{text}
</motion.span>
);
}