A restrained holographic foil whose spectral sheen shifts with pointer position; fine-pointer gated with a soft idle drift fallback.
npx shadcn@latest add @paragon/holo-foil"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface HoloFoilProps extends React.ComponentProps<"div"> {
/** Corner radius in px. Match the parent's radius. */
borderRadius?: number;
/** Peak sheen opacity at the pointer, 0–1. */
strength?: number;
/**
* Foil hues in the diffraction band. Restrained multi-stops by default —
* NEVER garish. Passed through a repeating linear-gradient.
*/
colors?: string[];
/** Idle drift period in seconds when the pointer is away. */
duration?: number;
}
const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);
/**
* A restrained holographic foil that shifts with pointer position — the
* spectral shimmer of a trading card or security hologram, tuned to
* enterprise calm.
*
* Technique: a fine repeating diffraction band (a few muted hues) is parked
* over the surface once; a soft radial "torch" mask driven by two registered
* @property percentages (pointer x/y) reveals only the patch under the cursor,
* and the band's position shifts with the pointer so the hues appear to bend
* as you move — for the cost of interpolating a few values. Gated behind
* (hover: hover) and (pointer: fine): on touch/coarse pointers it falls back
* to a whisper-soft idle drift. Reduced motion parks a static, centered sheen.
* Absolutely positioned — parent needs position: relative.
*/
export function HoloFoil({
borderRadius = 12,
strength = 0.5,
colors = ["#4D80E6", "#2dd4bf", "#a5b4fc", "#f0abfc", "#38bdf8"],
duration = 7,
className,
style,
ref: forwardedRef,
...props
}: HoloFoilProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const localRef = React.useRef<HTMLDivElement | null>(null);
const [inView, setInView] = React.useState(true);
const [fine, setFine] = React.useState(false);
React.useEffect(() => {
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setFine(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
React.useEffect(() => {
const node = localRef.current;
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
setInView(entry?.isIntersecting ?? true);
});
observer.observe(node);
if (!fine) return () => observer.disconnect();
// Write pointer position straight to the element (no React re-render, no
// parent-variable recalc storm). rAF-throttled.
let frame = 0;
let px = 50;
let py = 50;
const write = () => {
frame = 0;
node.style.setProperty(`--holo-x-${id}`, `${px}%`);
node.style.setProperty(`--holo-y-${id}`, `${py}%`);
};
const onMove = (e: PointerEvent) => {
const rect = node.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
px = clamp01((e.clientX - rect.left) / rect.width) * 100;
py = clamp01((e.clientY - rect.top) / rect.height) * 100;
node.dataset.active = "true";
if (!frame) frame = requestAnimationFrame(write);
};
const onLeave = () => {
delete node.dataset.active;
};
const parent = node.parentElement ?? node;
parent.addEventListener("pointermove", onMove);
parent.addEventListener("pointerleave", onLeave);
return () => {
observer.disconnect();
parent.removeEventListener("pointermove", onMove);
parent.removeEventListener("pointerleave", onLeave);
if (frame) cancelAnimationFrame(frame);
};
}, [id, fine]);
const level = clamp01(strength);
const band = colors.length > 1 ? colors : [colors[0] ?? "#4D80E6", "#38bdf8"];
// Fine diffraction lines — kept subtle via oklab mixing to transparent.
const stripe = band
.map(
(c, i) =>
`color-mix(in oklab, ${c} 55%, transparent) ${((i / band.length) * 100).toFixed(1)}%`,
)
.join(", ");
return (
<>
<style href={`paragon-holo-foil-${id}`} precedence="paragon">{`
@property --holo-x-${id} { syntax: "<percentage>"; initial-value: 50%; inherits: true; }
@property --holo-y-${id} { syntax: "<percentage>"; initial-value: 50%; inherits: true; }
[data-holo="${id}"] {
transition: opacity var(--duration-base) var(--ease-out);
}
[data-holo="${id}"] > span {
transition: --holo-x-${id} 220ms var(--ease-out), --holo-y-${id} 220ms var(--ease-out);
}
@keyframes holo-idle-${id} {
0%, 100% { --holo-x-${id}: 32%; --holo-y-${id}: 38%; }
50% { --holo-x-${id}: 68%; --holo-y-${id}: 62%; }
}
[data-holo="${id}"][data-paused] > span { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
[data-holo="${id}"] > span {
animation: none !important;
transition: none !important;
--holo-x-${id}: 50% !important;
--holo-y-${id}: 50% !important;
}
}
`}</style>
<div
aria-hidden
data-holo={id}
data-paused={!inView || undefined}
ref={(node) => {
localRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
className={cn(
"pointer-events-none absolute inset-0 opacity-100",
className,
)}
style={{ borderRadius, mixBlendMode: "screen", ...style }}
{...props}
>
<span
style={
{
position: "absolute",
inset: 0,
borderRadius,
opacity: level,
// Diffraction band, its phase nudged by the pointer so hues bend.
backgroundImage: `repeating-linear-gradient(115deg, ${stripe}, color-mix(in oklab, ${band[0]} 55%, transparent) 100%)`,
backgroundSize: "220% 220%",
backgroundPosition: `var(--holo-x-${id}) var(--holo-y-${id})`,
// Torch mask: reveal only the patch under the pointer.
maskImage: `radial-gradient(120px 120px at var(--holo-x-${id}) var(--holo-y-${id}), black, transparent 72%)`,
WebkitMaskImage: `radial-gradient(120px 120px at var(--holo-x-${id}) var(--holo-y-${id}), black, transparent 72%)`,
// Idle drift only on coarse pointers (no cursor to follow).
animation: fine
? undefined
: `holo-idle-${id} ${duration}s var(--ease-in-out) infinite`,
willChange: "background-position, mask-position",
} as React.CSSProperties
}
/>
</div>
</>
);
}