Character shimmer
A colour sweep run per-character on a loop, pulled from a dashboard hero
npx shadcn@latest add https://ethankd.com/r/shimmering-text.jsonReading the fleet, live
Exporting your report
Syncing 12 devices
"use client";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { useCallback } from "react";
/**
* Per-character colour sweep, run on a loop with a pause between passes.
* Ported from a dashboard hero label — the shimmer is Motion driving each
* character's own `color` keyframes rather than a background-clip gradient,
* so it degrades cleanly (no shimmer, just the resting colour) when
* `prefers-reduced-motion` is set.
*/
export function ShimmeringText({
text,
duration = 1,
className = "",
}: {
text: string;
duration?: number;
className?: string;
}) {
const reducedMotion = useReducedMotion();
const createCharVariants = useCallback(
(charIndex: number): Variants => ({
running: {
color: ["#8a8f93", "#f2f2f0", "#8a8f93"],
transition: {
duration,
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
repeatDelay: text.length * 0.05,
delay: (charIndex * duration) / text.length,
ease: "easeInOut",
},
},
stopped: { color: "#8a8f93", transition: { duration: duration * 0.5, ease: "easeOut" } },
}),
[duration, text.length],
);
return (
<motion.span className={`inline-flex select-none items-center leading-none ${className}`}>
{text.split("").map((char, index) => (
<motion.span
animate={reducedMotion ? "stopped" : "running"}
aria-hidden
className="inline-block whitespace-pre leading-none"
initial="stopped"
// biome-ignore lint/suspicious/noArrayIndexKey: static label text, order never changes
key={index}
variants={createCharVariants(index)}
>
{char}
</motion.span>
))}
<span className="sr-only">{text}</span>
</motion.span>
);
}
Playground
Reading the fleet, live
<ShimmeringText text="Reading the fleet, live" duration={1.4} />