Effects & Borders

Shine Sweep

A diagonal sheen that sweeps across a surface on hover or on an interval, built as a mask window over a stationary gradient.

Install

npx shadcn@latest add @paragon/shine-sweep

shine-sweep.tsx

"use client";

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

export interface ShineSweepProps extends React.ComponentProps<"div"> {
  /** "hover" sweeps when the parent is hovered; "interval" sweeps on a timer. */
  mode?: "hover" | "interval";
  /** Seconds for one sweep to cross the surface. */
  duration?: number;
  /** Seconds between sweep starts (interval mode only). */
  interval?: number;
  /** Sheen angle in degrees. */
  angle?: number;
  /** Overall sheen opacity, 0–1. */
  strength?: number;
  /**
   * Sheen color. Defaults to the theme foreground so it reads in both
   * themes; pass e.g. "white" over colored or brand surfaces.
   */
  color?: string;
  /** Corner radius of the sheen in px. Match the parent's radius. */
  borderRadius?: number;
}

/**
 * A diagonal sheen that sweeps across the parent surface.
 *
 * Technique (border-beam engine idiom): a stationary diagonal gradient is
 * parked across the surface once; only a mask window — a registered
 * @property percentage driving mask-position — travels over it, so every
 * frame interpolates a single value. Hover mode is pure CSS, gated behind
 * (hover: hover) and (pointer: fine); interval mode pauses while offscreen.
 * Absolutely positioned — parent needs position: relative.
 */
export function ShineSweep({
  mode = "hover",
  duration = 0.8,
  interval = 5,
  angle = 115,
  strength = 1,
  color = "var(--color-foreground)",
  borderRadius = 12,
  className,
  style,
  ...props
}: ShineSweepProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const ref = React.useRef<HTMLDivElement>(null);
  const [inView, setInView] = React.useState(false);

  // Interval mode is an infinite loop — pause it while offscreen.
  React.useEffect(() => {
    if (mode !== "interval") return;
    const node = ref.current;
    if (!node) return;
    const observer = new IntersectionObserver((entries) =>
      setInView(entries.some((entry) => entry.isIntersecting)),
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [mode]);

  const tint = (pct: number) =>
    `color-mix(in oklab, ${color} ${pct}%, transparent)`;
  // Parked once — brightest mid-surface, so the sheen swells and fades
  // as the mask window travels across it.
  const field = `linear-gradient(${angle}deg, transparent, ${tint(8)} 30%, ${tint(18)} 50%, ${tint(8)} 70%, transparent)`;
  // The moving window: a narrow band in an oversized mask. Both rest
  // positions (100% and 0%) park the band just outside the surface.
  const band = `linear-gradient(${angle}deg, transparent 40%, #fff 50%, transparent 60%)`;

  const cycle = Math.max(interval, duration);
  const sweepEnd = +((duration / cycle) * 100).toFixed(2);

  return (
    <>
      <style href={`paragon-shine-sweep-${id}`} precedence="paragon">{`
        @property --shine-x-${id} {
          syntax: "<percentage>";
          initial-value: 100%;
          inherits: false;
        }
        ${
          mode === "hover"
            ? `@media (hover: hover) and (pointer: fine) {
          :where(:hover) > [data-shine="${id}"] {
            --shine-x-${id}: 0%;
            transition: --shine-x-${id} ${duration}s var(--ease-out);
          }
        }`
            : `@keyframes shine-sweep-${id} {
          0% { --shine-x-${id}: 100%; animation-timing-function: var(--ease-in-out); }
          ${sweepEnd}%, 100% { --shine-x-${id}: 0%; }
        }`
        }
        @media (prefers-reduced-motion: reduce) {
          [data-shine="${id}"] {
            animation: none !important;
            transition: none !important;
            --shine-x-${id}: 100% !important;
          }
        }
      `}</style>
      <div
        ref={ref}
        aria-hidden
        data-shine={id}
        className={cn("pointer-events-none absolute inset-0", className)}
        style={
          {
            borderRadius,
            background: field,
            opacity: strength,
            maskImage: band,
            WebkitMaskImage: band,
            maskSize: "250% 100%",
            WebkitMaskSize: "250% 100%",
            maskRepeat: "no-repeat",
            WebkitMaskRepeat: "no-repeat",
            maskPosition: `var(--shine-x-${id}) 0%`,
            WebkitMaskPosition: `var(--shine-x-${id}) 0%`,
            ...(mode === "interval"
              ? {
                  animationName: `shine-sweep-${id}`,
                  animationDuration: `${cycle}s`,
                  animationIterationCount: "infinite",
                  animationPlayState: inView ? "running" : "paused",
                }
              : null),
            ...style,
          } as React.CSSProperties
        }
        {...props}
      />
    </>
  );
}