Text Effects

Gradient Text

Text filled with a gradient that sweeps slowly across it via background-position, pausing when offscreen.

Install

npx shadcn@latest add @paragon/gradient-text

gradient-text.tsx

"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>
    </>
  );
}