Text painted in a restrained multi-stop gradient with a brighter specular highlight that sweeps across it — looping with a rest beat, or on hover.
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";
/**
* Restrained multi-stop base gradients — low-chroma, elegant, legible in both
* themes. No garish flat purples. `from`/`via`/`to` render as a 3-stop linear
* gradient clipped to the glyphs; the text is fully readable at rest.
*/
const GRADIENT_PRESETS = {
indigo: { from: "#7482cf", via: "#5aa3b6", to: "#4cae8c" }, // muted indigo → teal
rose: { from: "#c47d90", via: "#cd8a6d", to: "#c99a52" }, // warm rose → amber
slate: { from: "#7b8698", via: "#6f97bd", to: "#63a7d6" }, // slate → sky
plum: { from: "#8a6f9c", via: "#9d7f86", to: "#a98a72" }, // deep plum → clay
} satisfies Record<string, { from: string; via: string; to: string }>;
export interface GradientSheenTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The text to render. */
children: React.ReactNode;
/**
* Named base gradient preset, or supply `from`/`via`/`to` to override.
* Presets: `indigo` (default), `rose`, `slate`, `plum`.
*/
gradient?: keyof typeof GRADIENT_PRESETS;
/** Override the gradient start color. */
from?: string;
/** Override the gradient middle color. */
via?: string;
/** Override the gradient end color. */
to?: string;
/** Specular sheen band color — a brighter highlight than the base. */
sheen?: string;
/** Seconds for one sweep-plus-rest cycle. */
speed?: number;
/** Gradient + sheen angle in degrees. */
angle?: number;
/**
* `loop` — sweep continuously with a rest beat offscreen (default).
* `hover` — sweep once each time the text is hovered.
*/
trigger?: "loop" | "hover";
/** Render the static gradient text with no sheen. */
static?: boolean;
}
/**
* GradientSheenText — the text is painted in a restrained multi-stop gradient
* (indigo→teal by default) via `background-clip: text`, so it reads clearly at
* rest in both themes. A brighter specular band is stacked as a second
* background layer and its position is animated across the glyphs: a smooth
* highlight pass, not a flash. Between passes the band parks fully offscreen,
* so the loop is seamless with a beat of stillness.
*
* Only the sheen layer's `background-position` moves — the base gradient stays
* put — so `loop` is one keyframe (paused offscreen via IntersectionObserver)
* and `hover` is one interruptible `background-position` transition. Reduced
* motion (or `static`) renders the static gradient text.
*/
export function GradientSheenText({
children,
gradient = "indigo",
from,
via,
to,
sheen = "#f5f7ff",
speed = 3.2,
angle = 100,
trigger = "loop",
static: isStatic = false,
className,
style,
...props
}: GradientSheenTextProps) {
const id = React.useId().replace(/[:]/g, "");
const reducedMotion = useReducedMotion() ?? false;
const hostRef = React.useRef<HTMLSpanElement>(null);
const [loopVisible, setLoopVisible] = React.useState(true);
const [swept, setSwept] = React.useState(false);
const animated = !isStatic && !reducedMotion;
const isLoop = trigger === "loop";
const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.indigo;
const c0 = from ?? preset.from;
const c1 = via ?? preset.via;
const c2 = to ?? preset.to;
// 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]);
// Layer 1 (top): the sheen band — a narrow bright streak, transparent either
// side so the base shows through. Oversized (300%) so the band is a thin
// highlight relative to the text and travels a long distance offscreen.
const sheenLayer = `linear-gradient(${angle}deg,
transparent 42%,
color-mix(in oklab, ${sheen} 55%, transparent) 48%,
${sheen} 50%,
color-mix(in oklab, ${sheen} 55%, transparent) 52%,
transparent 58%)`;
// Layer 2 (bottom): the restrained multi-stop base gradient.
const baseLayer = `linear-gradient(${angle}deg, ${c0} 0%, ${c1} 50%, ${c2} 100%)`;
const clip: React.CSSProperties = {
backgroundImage: `${sheenLayer}, ${baseLayer}`,
backgroundSize: "300% 100%, 100% 100%",
backgroundRepeat: "no-repeat, no-repeat",
// Only the sheen layer (first value) moves; the base stays at 0.
backgroundPosition: "150% 0, 0 0",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
WebkitTextFillColor: "transparent",
};
// Static base gradient — no sheen layer at all, so nothing can flash.
if (isStatic || reducedMotion) {
return (
<span
data-slot="gradient-sheen-text"
className={cn("inline-block", className)}
style={{
backgroundImage: baseLayer,
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
WebkitTextFillColor: "transparent",
...style,
}}
{...props}
>
{children}
</span>
);
}
// The band sweeps in the first ~55% of the cycle, then rests parked at the
// far side for the remainder — a beat of stillness between passes. Both
// endpoints hide the band offscreen, so the loop restart is invisible.
const keyframes = `@keyframes sheen-sweep-${id} {
0% { background-position: 150% 0, 0 0; }
55%, 100% { background-position: -50% 0, 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.sheen-${id} { animation: none !important; background-position: 150% 0, 0 0 !important; }
}`;
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={{
...clip,
animationName: `sheen-sweep-${id}`,
animationDuration: `${Math.max(1.2, speed)}s`,
animationTimingFunction: "var(--ease-in-out)",
animationIterationCount: "infinite",
animationPlayState: loopVisible ? "running" : "paused",
...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={{
...clip,
backgroundPosition: swept ? "-50% 0, 0 0" : "150% 0, 0 0",
transitionProperty: "background-position",
transitionDuration: `${Math.max(0.3, speed * 0.4)}s`,
transitionTimingFunction: "var(--ease-in-out)",
...style,
}}
{...props}
>
{children}
</span>
);
}