Dirck Mulder
Text Animations||3 min read

Build a Shiny Text Effect in React

Add a shimmering light sweep animation to text using CSS gradients and keyframes, no JavaScript animation library required.

Some UI elements just need to gleam. A premium label, a "new" badge, a call to action you want people to notice. The shiny text effect does exactly one thing, and it does it well: it drags a highlight across your text like light catching a metallic surface.

The final result

ShinyText applies a moving gradient highlight across a string of text using Motion's useAnimationFrame and useMotionValue. The shimmer sweeps from one side to the other on a continuous loop, with options for yoyo, pause on hover, and direction control.

Setting up

This component uses Motion (the library formerly known as Framer Motion).

tsx
import {
  motion,
  useMotionValue,
  useAnimationFrame,
  useTransform,
} from 'motion/react';

Building the component

The key props are speed, color, shineColor, spread, and direction.

tsx
interface ShinyTextProps {
  text: string;
  disabled?: boolean;
  speed?: number;
  color?: string;
  shineColor?: string;
  spread?: number;
  yoyo?: boolean;
  pauseOnHover?: boolean;
  direction?: 'left' | 'right';
  delay?: number;
}

The gradient is defined as a CSS background image with the shine color sandwiched between the base color. background-clip: text combined with transparent text fill is what makes it show through the letterforms.

tsx
const gradientStyle: React.CSSProperties = {
  backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
  backgroundSize: '200% auto',
  WebkitBackgroundClip: 'text',
  backgroundClip: 'text',
  WebkitTextFillColor: 'transparent',
};

The animation is driven by useAnimationFrame, which runs every frame and updates a progress motion value. We track elapsed time manually to calculate where in the cycle we are.

tsx
useAnimationFrame(time => {
  if (disabled || isPaused) {
    lastTimeRef.current = null;
    return;
  }
  if (lastTimeRef.current === null) {
    lastTimeRef.current = time;
    return;
  }
  const deltaTime = time - lastTimeRef.current;
  lastTimeRef.current = time;
  elapsedRef.current += deltaTime;

  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);
  }
});

The progress value maps to a backgroundPosition using useTransform. Shifting the background position from 150% to -50% moves the shine band from right to left across the text.

tsx
const backgroundPosition = useTransform(
  progress,
  p => `${150 - p * 2}% center`
);

How to use it

tsx
<ShinyText
  text="Premium"
  color="#b5b5b5"
  shineColor="#ffffff"
  speed={2}
  spread={120}
  direction="left"
  pauseOnHover={true}
/>

Set yoyo to true if you want the shine to sweep back and forth rather than looping in one direction.

Key takeaways

  • The background-clip: text trick is the entire foundation of this effect. Without it, the gradient sits behind the text instead of through it.
  • Using useAnimationFrame instead of a CSS animation gives you programmatic control over playback, pause, and direction without CSS class juggling.
  • The spread prop controls the angle of the gradient, which lets you make the shine feel more like a direct light source or more like a diffuse sheen.