Gradient Sheen Text
Text Effects

Gradient Sheen Text

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.

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

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