A specular highlight sweeps across the text like light on brushed metal, on hover or a loop.
npx shadcn@latest add @paragon/gradient-sheen-text"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface GradientSheenTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The text to render. */
children: React.ReactNode;
/** Sheen highlight color. */
color?: string;
/** Sweep duration in seconds. */
speed?: number;
/** Sheen band angle in degrees. */
angle?: number;
/** Sheen band width as a fraction of the text (0.05–0.5). */
width?: number;
/**
* `hover` — sweep once per hover (default).
* `loop` — sweep forever on an interval.
*/
trigger?: "hover" | "loop";
/** Render the text with no sheen. */
static?: boolean;
}
/**
* GradientSheenText — a specular highlight sweeps across the text like light
* catching brushed metal. The text is painted with a moving linear-gradient
* (base foreground, then a bright sheen band, then base again) clipped to the
* glyphs via `background-clip: text`. A single custom property drives the band
* position, so the sweep is one interruptible CSS transition (hover) or a
* paused-offscreen keyframe (loop) — never `transition: all`.
*
* Reduced motion (or `static`) renders the plain text with no sweep.
*/
export function GradientSheenText({
children,
color = "#ffffff",
speed = 1.1,
angle = 20,
width = 0.16,
trigger = "hover",
static: isStatic = false,
className,
style,
...props
}: GradientSheenTextProps) {
const id = React.useId().replace(/[:]/g, "");
const reducedMotion = useReducedMotion() ?? false;
const [swept, setSwept] = React.useState(false);
const hostRef = React.useRef<HTMLSpanElement>(null);
const [loopVisible, setLoopVisible] = React.useState(true);
const animated = !isStatic && !reducedMotion;
const isLoop = trigger === "loop";
// Pause the looping keyframe while offscreen.
React.useEffect(() => {
const el = hostRef.current;
if (!el || !animated || !isLoop) return;
const io = new IntersectionObserver(
([e]) => setLoopVisible(e.isIntersecting),
{ threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, [animated, isLoop]);
const w = Math.max(0.04, Math.min(0.5, width));
const c1 = `${(0.5 - w / 2) * 100}%`;
const c2 = `${(0.5 + w / 2) * 100}%`;
// Position 0 → band offscreen left, 1 → band offscreen right.
const bg = `linear-gradient(${angle}deg,
currentColor 0%,
currentColor ${c1},
${color} 50%,
currentColor ${c2},
currentColor 100%)`;
const keyframes = `@keyframes sheen-sweep-${id} {
0% { background-position: 200% 0; }
100% { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
.sheen-${id} { animation: none !important; background-position: 50% 0 !important; }
}`;
const sharedStyle: React.CSSProperties = {
backgroundImage: bg,
backgroundSize: "300% 100%",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
WebkitTextFillColor: "transparent",
};
if (isStatic || reducedMotion) {
return (
<span
data-slot="gradient-sheen-text"
className={cn("inline-block", className)}
style={style}
{...props}
>
{children}
</span>
);
}
if (isLoop) {
return (
<>
<style href={`paragon-gradient-sheen-text-${id}`} precedence="paragon">
{keyframes}
</style>
<span
ref={hostRef}
data-slot="gradient-sheen-text"
className={cn(`sheen-${id} inline-block`, className)}
style={{
...sharedStyle,
animationName: `sheen-sweep-${id}`,
animationDuration: `${Math.max(0.3, 3 / speed)}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationPlayState: loopVisible ? "running" : "paused",
backgroundPosition: "200% 0",
...style,
}}
{...props}
>
{children}
</span>
</>
);
}
// Hover: retarget a single background-position transition (interruptible).
return (
<span
ref={hostRef}
data-slot="gradient-sheen-text"
className={cn("inline-block", className)}
onPointerEnter={() => setSwept(true)}
onPointerLeave={() => setSwept(false)}
style={{
...sharedStyle,
backgroundPosition: swept ? "-100% 0" : "200% 0",
transitionProperty: "background-position",
transitionDuration: `${Math.max(0.2, 1.2 / speed)}s`,
transitionTimingFunction: "var(--ease-in-out)",
...style,
}}
{...props}
>
{children}
</span>
);
}