A wave of light that travels across text, character by character — for loading and "thinking" states.
Applying new changes to the dashboard...
'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>
);
}