Text filled with a gradient that sweeps slowly across it via background-position, pausing when offscreen.
npx shadcn@latest add @paragon/gradient-text"use client";
import * as React from "react";
import { useInView } from "motion/react";
import { cn } from "@/lib/utils";
export interface GradientTextProps extends React.ComponentProps<"span"> {
/** Seconds for one full sweep across the text. */
duration?: number;
/** Gradient colors, left to right. The loop is closed automatically. */
colors?: string[];
/** Render the gradient without motion. */
static?: boolean;
}
const DEFAULT_COLORS = ["#3b82f6", "#8b5cf6", "#d946ef"];
/**
* Text filled with a gradient that sweeps slowly across it.
*
* Technique: the gradient is painted at 200% width and clipped to the glyphs
* via background-clip: text. Only the background-position — a registered
* @property percentage — animates, linear (the constant-motion exception).
* The gradient is periodic (it ends on the color it starts with), so shifting
* by exactly one tile loops seamlessly. The sweep pauses offscreen via
* useInView and freezes to a static gradient under prefers-reduced-motion.
*/
export function GradientText({
duration = 8,
colors = DEFAULT_COLORS,
static: isStatic = false,
className,
style,
children,
...props
}: GradientTextProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { amount: 0.1 });
const palette = colors.length > 0 ? colors : DEFAULT_COLORS;
const stops = [...palette, palette[0]].join(", ");
return (
<>
{!isStatic && (
<style href="paragon-gradient-text" precedence="paragon">{`
@property --pg-gt-pos {
syntax: "<percentage>";
initial-value: 0%;
inherits: false;
}
@keyframes pg-gt-sweep {
from { --pg-gt-pos: 0%; }
to { --pg-gt-pos: 200%; }
}
@media (prefers-reduced-motion: reduce) {
[data-gradient-text] { animation: none !important; }
}
`}</style>
)}
<span
ref={ref}
data-gradient-text=""
className={cn("bg-clip-text text-transparent", className)}
style={
{
backgroundImage: `linear-gradient(90deg, ${stops})`,
backgroundSize: "200% 100%",
backgroundRepeat: "repeat",
backgroundPosition: isStatic
? "0% 50%"
: `var(--pg-gt-pos) 50%`,
WebkitBackgroundClip: "text",
...(isStatic
? null
: {
animation: `pg-gt-sweep ${duration}s linear infinite`,
animationPlayState: inView ? "running" : "paused",
}),
...style,
} as React.CSSProperties
}
{...props}
>
{children}
</span>
</>
);
}