Vault

Text Shimmer

A wave of light that travels across text, character by character — for loading and "thinking" states.

Applying new changes to the dashboard...

Customize

Code

'use client';
import { type JSX } from 'react';
import { motion, Transition } from 'framer-motion';
import { cn } from 'mxcn';

export type TextShimmerWaveProps = {
  children: string;
  as?: React.ElementType;
  className?: string;
  duration?: number;
  zDistance?: number;
  xDistance?: number;
  yDistance?: number;
  spread?: number;
  scaleDistance?: number;
  rotateYDistance?: number;
  transition?: Transition;
  /**
   * Single brand color. The shimmer automatically derives a matching
   * peak/gradient color from it (lighter for dark bases, darker for light ones).
   */
  color?: string;
};

function parseHex(hex: string): [number, number, number] {
  const raw = hex.replace('#', '').trim();
  const full =
    raw.length === 3
      ? raw
          .split('')
          .map((c) => c + c)
          .join('')
      : raw.padEnd(6, '0').slice(0, 6);
  return [
    parseInt(full.slice(0, 2), 16) || 0,
    parseInt(full.slice(2, 4), 16) || 0,
    parseInt(full.slice(4, 6), 16) || 0,
  ];
}

function toHex([r, g, b]: [number, number, number]): string {
  return (
    '#' +
    [r, g, b]
      .map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, '0'))
      .join('')
  );
}

function mix(
  a: [number, number, number],
  b: [number, number, number],
  t: number
): [number, number, number] {
  return [
    a[0] + (b[0] - a[0]) * t,
    a[1] + (b[1] - a[1]) * t,
    a[2] + (b[2] - a[2]) * t,
  ];
}

function relativeLuminance([r, g, b]: [number, number, number]): number {
  const lin = [r, g, b].map((c) => {
    const s = c / 255;
    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}

/** Derive resting + peak shimmer colors from a single hex. */
export function deriveShimmerColors(color: string): {
  base: string;
  peak: string;
} {
  const rgb = parseHex(color);
  const lum = relativeLuminance(rgb);

  // Light bases need a darker flash; dark/mid bases get a light sweep.
  if (lum > 0.55) {
    return { base: toHex(rgb), peak: toHex(mix(rgb, [0, 0, 0], 0.55)) };
  }
  return { base: toHex(rgb), peak: toHex(mix(rgb, [255, 255, 255], 0.55)) };
}

export function TextShimmerWave({
  children,
  as: Component = 'p',
  className,
  duration = 1,
  zDistance = 10,
  xDistance = 2,
  yDistance = -2,
  spread = 1,
  scaleDistance = 1.1,
  rotateYDistance = 10,
  transition,
  color = '#FC4FFE',
}: TextShimmerWaveProps) {
  const MotionComponent = motion.create(
    Component as keyof JSX.IntrinsicElements
  );

  const { base, peak } = deriveShimmerColors(color);

  return (
    <MotionComponent
      className={cn('relative inline-block [perspective:500px]', className)}
      style={
        {
          color: base,
          '--base-color': base,
          '--base-gradient-color': peak,
        } as React.CSSProperties
      }
    >
      {children.split('').map((char, i) => {
        const delay = (i * duration * (1 / spread)) / children.length;

        return (
          <motion.span
            key={i}
            className={cn(
              'inline-block whitespace-pre [transform-style:preserve-3d]'
            )}
            initial={{
              translateZ: 0,
              scale: 1,
              rotateY: 0,
              color: 'var(--base-color)',
            }}
            animate={{
              translateZ: [0, zDistance, 0],
              translateX: [0, xDistance, 0],
              translateY: [0, yDistance, 0],
              scale: [1, scaleDistance, 1],
              rotateY: [0, rotateYDistance, 0],
              color: [
                'var(--base-color)',
                'var(--base-gradient-color)',
                'var(--base-color)',
              ],
            }}
            transition={{
              duration: duration,
              repeat: Infinity,
              repeatDelay: (children.length * 0.05) / spread,
              delay,
              ease: 'easeInOut',
              ...transition,
            }}
          >
            {char}
          </motion.span>
        );
      })}
    </MotionComponent>
  );
}