Text with a subtle sheen that travels across it on a linear loop, frozen offscreen and under reduced motion.
npx shadcn@latest add @paragon/shimmer-text"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ShimmerTextProps extends React.ComponentProps<"span"> {
/** Seconds per loop (sweep plus offscreen rest). */
duration?: number;
/** Half-width of the highlight band as a percentage of the text width. */
spread?: number;
/** Sheen color. Defaults to the theme foreground. */
shimmerColor?: string;
/** Render plain text with no sheen. */
static?: boolean;
}
/**
* Text with a subtle sheen that travels across it on a linear loop.
*
* Technique: the base text stays stationary in a muted color; an aria-hidden
* duplicate in the foreground color sits on top, masked to a narrow gradient
* window whose position — a registered @property percentage — is the only
* thing that animates. The band sweeps for 70% of the loop, then rests fully
* offscreen. Paused offscreen via IntersectionObserver; frozen (band parked
* offscreen, so plain muted text) under prefers-reduced-motion.
*/
export function ShimmerText({
duration = 3,
spread = 18,
shimmerColor,
static: isStatic = false,
className,
children,
...props
}: ShimmerTextProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const ref = React.useRef<HTMLSpanElement>(null);
const [inView, setInView] = React.useState(true);
React.useEffect(() => {
if (isStatic) return;
const node = ref.current;
if (!node || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(([entry]) => {
if (entry) setInView(entry.isIntersecting);
});
observer.observe(node);
return () => observer.disconnect();
}, [isStatic]);
const from = -spread;
const to = 100 + spread;
const mask = `linear-gradient(100deg, transparent calc(var(--shimmer-x-${id}) - ${spread}%), #fff var(--shimmer-x-${id}), transparent calc(var(--shimmer-x-${id}) + ${spread}%))`;
return (
<span
ref={ref}
className={cn("relative inline-block text-muted-foreground", className)}
{...props}
>
{children}
{!isStatic && (
<>
<style href={`paragon-shimmer-text-${id}`} precedence="paragon">{`
@property --shimmer-x-${id} {
syntax: "<percentage>";
initial-value: ${from}%;
inherits: false;
}
@keyframes shimmer-sweep-${id} {
0% { --shimmer-x-${id}: ${from}%; }
70% { --shimmer-x-${id}: ${to}%; }
100% { --shimmer-x-${id}: ${to}%; }
}
@media (prefers-reduced-motion: reduce) {
[data-shimmer="${id}"] { animation: none !important; }
}
`}</style>
<span
aria-hidden
data-shimmer={id}
className="pointer-events-none absolute inset-0"
style={
{
color: shimmerColor ?? "var(--color-foreground)",
maskImage: mask,
WebkitMaskImage: mask,
animation: `shimmer-sweep-${id} ${duration}s linear infinite`,
animationPlayState: inView ? "running" : "paused",
} as React.CSSProperties
}
>
{children}
</span>
</>
)}
</span>
);
}