A diagonal sheen that sweeps across a surface on hover or on an interval, built as a mask window over a stationary gradient.
npx shadcn@latest add @paragon/shine-sweep"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}
/>
</>
);
}