Text Effects

Shimmer Text

Text with a subtle sheen that travels across it on a linear loop, frozen offscreen and under reduced motion.

Install

npx shadcn@latest add @paragon/shimmer-text

shimmer-text.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface ShimmerTextProps extends React.ComponentProps<"span"> {
  /** Seconds per loop (sweep plus offscreen rest). */
  duration?: number;
  /** Half-width of the highlight band as a percentage of the text width. */
  spread?: number;
  /** Sheen color. Defaults to the theme foreground. */
  shimmerColor?: string;
  /** Render plain text with no sheen. */
  static?: boolean;
}

/**
 * Text with a subtle sheen that travels across it on a linear loop.
 *
 * Technique: the base text stays stationary in a muted color; an aria-hidden
 * duplicate in the foreground color sits on top, masked to a narrow gradient
 * window whose position — a registered @property percentage — is the only
 * thing that animates. The band sweeps for 70% of the loop, then rests fully
 * offscreen. Paused offscreen via IntersectionObserver; frozen (band parked
 * offscreen, so plain muted text) under prefers-reduced-motion.
 */
export function ShimmerText({
  duration = 3,
  spread = 18,
  shimmerColor,
  static: isStatic = false,
  className,
  children,
  ...props
}: ShimmerTextProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const ref = React.useRef<HTMLSpanElement>(null);
  const [inView, setInView] = React.useState(true);

  React.useEffect(() => {
    if (isStatic) return;
    const node = ref.current;
    if (!node || typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry) setInView(entry.isIntersecting);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [isStatic]);

  const from = -spread;
  const to = 100 + spread;
  const mask = `linear-gradient(100deg, transparent calc(var(--shimmer-x-${id}) - ${spread}%), #fff var(--shimmer-x-${id}), transparent calc(var(--shimmer-x-${id}) + ${spread}%))`;

  return (
    <span
      ref={ref}
      className={cn("relative inline-block text-muted-foreground", className)}
      {...props}
    >
      {children}
      {!isStatic && (
        <>
          <style href={`paragon-shimmer-text-${id}`} precedence="paragon">{`
            @property --shimmer-x-${id} {
              syntax: "<percentage>";
              initial-value: ${from}%;
              inherits: false;
            }
            @keyframes shimmer-sweep-${id} {
              0% { --shimmer-x-${id}: ${from}%; }
              70% { --shimmer-x-${id}: ${to}%; }
              100% { --shimmer-x-${id}: ${to}%; }
            }
            @media (prefers-reduced-motion: reduce) {
              [data-shimmer="${id}"] { animation: none !important; }
            }
          `}</style>
          <span
            aria-hidden
            data-shimmer={id}
            className="pointer-events-none absolute inset-0"
            style={
              {
                color: shimmerColor ?? "var(--color-foreground)",
                maskImage: mask,
                WebkitMaskImage: mask,
                animation: `shimmer-sweep-${id} ${duration}s linear infinite`,
                animationPlayState: inView ? "running" : "paused",
              } as React.CSSProperties
            }
          >
            {children}
          </span>
        </>
      )}
    </span>
  );
}