Text Effects

Gradient Sheen Text

A specular highlight sweeps across the text like light on brushed metal, on hover or a loop.

Install

npx shadcn@latest add @paragon/gradient-sheen-text

gradient-sheen-text.tsx

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