"use client";
import * as React from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function subscribeHoverNone(callback: () => void) {
const mq = window.matchMedia("(hover: none)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function subscribeVisibility(callback: () => void) {
document.addEventListener("visibilitychange", callback);
return () => document.removeEventListener("visibilitychange", callback);
}
export type HoloFoil = "spectrum" | "ember" | "silver";
export type HoloPattern = "plain" | "stripes" | "sparkles";
export interface HoloSpring {
stiffness: number;
damping: number;
mass: number;
}
export interface HoloCardProps extends React.ComponentPropsWithoutRef<"div"> {
/** Card art and content. */
children?: React.ReactNode;
/** Accessible name of the card (it is a focusable group). */
label?: string;
/** Strength of the foil, 0–1. */
intensity?: number;
/** Colour of the holographic foil. */
foil?: HoloFoil;
/** Extra shine pattern on top of the foil. */
pattern?: HoloPattern;
/** Maximum tilt in degrees at the card's edges. */
tiltStrength?: number;
/** Spring that smooths tilt, light position and foil strength. */
spring?: HoloSpring;
/** Specular glare that follows the light. */
glare?: boolean;
/** Corner radius in px. */
rounded?: number;
/** CSS perspective in px. */
perspective?: number;
/** Keep a slow shimmer running while idle. It always runs on touch screens and while the card has focus. */
idleShimmer?: boolean;
/** Focus ring colour (any CSS colour). */
accent?: string;
/** Classes for the card surface (background, border). Replaces the default graphite surface. */
surfaceClassName?: string;
}
// Narrow repeating bands (several per card) read as foil rather than a single rainbow wash.
const FOILS: Record<HoloFoil, string> = {
spectrum:
"repeating-linear-gradient(115deg, hsl(2 100% 73%) 0%, hsl(40 100% 70%) 2.6%, hsl(93 90% 70%) 5.2%, hsl(176 90% 74%) 7.8%, hsl(214 100% 76%) 10.4%, hsl(338 90% 76%) 13%, hsl(2 100% 73%) 15.6%)",
ember:
"repeating-linear-gradient(115deg, #ff7448 0%, #ffc46b 3.2%, #b6f3d1 6.4%, #8fdcff 9.6%, #ffc46b 12.8%, #ff7448 16%)",
silver:
"repeating-linear-gradient(115deg, #71717a 0%, #f4f4f5 3%, #a1a1aa 6%, #ffffff 9%, #8a8a93 12%, #71717a 15%)",
};
// Fractal noise thresholded to sparse white specks — the glitter.
const GLITTER = `url("data:image/svg+xml,${encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='7' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 12 -7.1'/></filter><rect width='100%' height='100%' filter='url(#g)'/></svg>",
)}")`;
const STRIPES =
"repeating-linear-gradient(-58deg, rgb(255 255 255 / 0) 0px, rgb(255 255 255 / 0.7) 2px, rgb(255 255 255 / 0) 5px, rgb(255 255 255 / 0) 13px)";
/** Two mask layers that must both be opaque (intersect), with the prefixed Safari spelling. */
function masked(image: string, size: string, position: string): React.CSSProperties {
return {
maskImage: image,
WebkitMaskImage: image,
maskSize: size,
WebkitMaskSize: size,
maskPosition: position,
WebkitMaskPosition: position,
maskRepeat: "repeat, no-repeat",
WebkitMaskRepeat: "repeat, no-repeat",
maskComposite: "intersect",
WebkitMaskComposite: "source-in",
};
}
const DEFAULT_SURFACE =
"border border-white/10 bg-[#15181b] text-zinc-100 shadow-[0_1px_0_rgb(255_255_255/0.08)_inset,0_30px_60px_-28px_rgb(0_0_0/0.6)]";
/** Light position used for the static (reduced-motion) foil. */
const STATIC_LIGHT = { x: 32, y: 26, active: 0.42 };
/**
* A trading-card surface with a holographic foil. Moving the pointer tilts the card on springs and
* moves a light across it: a rainbow foil gradient, a glitter noise texture and an optional stripe
* pattern shift with the light through colour-dodge blending, and a specular glare follows it.
* Everything is driven by motion values and CSS variables — no re-renders while the pointer moves.
* Focus (and touch screens) get a slow automatic shimmer; reduced motion gets a static foil.
*/
export function HoloCard({
children,
label = "Holographic card",
intensity = 0.7,
foil = "spectrum",
pattern = "sparkles",
tiltStrength = 14,
spring = { stiffness: 170, damping: 18, mass: 0.6 },
glare = true,
rounded = 18,
perspective = 900,
idleShimmer = false,
accent = "#FF7448",
surfaceClassName = DEFAULT_SURFACE,
className = "",
style,
onPointerEnter,
onPointerMove,
onPointerLeave,
onFocus,
onBlur,
...props
}: HoloCardProps) {
const reduceMotion = usePrefersReducedMotion();
const coarse = React.useSyncExternalStore(subscribeHoverNone, () => window.matchMedia("(hover: none)").matches, () => false);
const documentHidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
const rootRef = React.useRef<HTMLDivElement>(null);
const [hovering, setHovering] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [inView, setInView] = React.useState(true);
// Targets (written by pointer events or the shimmer loop) and their springs.
const rxTarget = useMotionValue(0);
const ryTarget = useMotionValue(0);
const lxTarget = useMotionValue(50);
const lyTarget = useMotionValue(50);
const activeTarget = useMotionValue(0);
const liftTarget = useMotionValue(1);
const rotateX = useSpring(rxTarget, spring);
const rotateY = useSpring(ryTarget, spring);
const lightX = useSpring(lxTarget, spring);
const lightY = useSpring(lyTarget, spring);
const active = useSpring(activeTarget, { ...spring, damping: spring.damping * 1.6 });
const scale = useSpring(liftTarget, spring);
const shimmer = !reduceMotion && !hovering && inView && !documentHidden && (focused || coarse || idleShimmer);
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
io.observe(root);
return () => io.disconnect();
}, []);
// Reduced motion: park the light at a fixed angle with a calm foil.
React.useEffect(() => {
if (!reduceMotion) return;
for (const [target, value] of [
[rotateX, 0],
[rotateY, 0],
[scale, 1],
[lightX, STATIC_LIGHT.x],
[lightY, STATIC_LIGHT.y],
[active, STATIC_LIGHT.active],
] as const) {
target.jump(value);
}
}, [reduceMotion, rotateX, rotateY, scale, lightX, lightY, active]);
// Idle shimmer: a slow Lissajous path for the light, with a gentle tilt.
React.useEffect(() => {
if (!shimmer) {
if (!reduceMotion && !hovering) {
rxTarget.set(0);
ryTarget.set(0);
lxTarget.set(50);
lyTarget.set(50);
activeTarget.set(0);
}
return;
}
let frame = 0;
const start = performance.now();
const tick = (now: number) => {
const t = (now - start) / 1000;
const px = Math.sin(t * 0.55) * 0.42;
const py = Math.sin(t * 0.37 + 1.2) * 0.36;
ryTarget.set(px * tiltStrength * 0.7);
rxTarget.set(-py * tiltStrength * 0.7);
lxTarget.set(50 + px * 100);
lyTarget.set(50 + py * 100);
activeTarget.set(0.75);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [shimmer, reduceMotion, hovering, tiltStrength, rxTarget, ryTarget, lxTarget, lyTarget, activeTarget]);
const track = (event: React.PointerEvent<HTMLDivElement>) => {
// Touch input scrolls the page; the shimmer covers touch screens instead.
if (reduceMotion || event.pointerType === "touch") return;
const rect = event.currentTarget.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width - 0.5;
const py = (event.clientY - rect.top) / rect.height - 0.5;
ryTarget.set(px * 2 * tiltStrength);
rxTarget.set(-py * 2 * tiltStrength);
lxTarget.set((px + 0.5) * 100);
lyTarget.set((py + 0.5) * 100);
activeTarget.set(1);
liftTarget.set(1.035);
};
const k = Math.min(Math.max(intensity, 0), 1);
const light = "calc(var(--hc-x) * 1%) calc(var(--hc-y) * 1%)";
const glow = `radial-gradient(farthest-corner circle at ${light}, #000 0%, rgb(0 0 0 / 0.35) 45%, transparent 85%)`;
return (
<div
ref={rootRef}
role="group"
aria-label={label}
tabIndex={0}
{...props}
className={`relative inline-block rounded-[var(--hc-r)] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--hc-accent)] ${className}`}
style={{
perspective,
["--hc-accent" as string]: accent,
["--hc-r" as string]: `${rounded}px`,
...style,
}}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (event.pointerType !== "touch") setHovering(true);
track(event);
}}
onPointerMove={(event) => {
onPointerMove?.(event);
track(event);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setHovering(false);
rxTarget.set(0);
ryTarget.set(0);
lxTarget.set(50);
lyTarget.set(50);
activeTarget.set(0);
liftTarget.set(1);
}}
onFocus={(event) => {
onFocus?.(event);
if (event.target === event.currentTarget) setFocused(true);
}}
onBlur={(event) => {
onBlur?.(event);
if (event.target === event.currentTarget) setFocused(false);
}}
>
<motion.div
className={`relative isolate h-full w-full overflow-hidden ${surfaceClassName}`}
style={{
borderRadius: rounded,
rotateX,
rotateY,
scale,
["--hc-x" as string]: lightX,
["--hc-y" as string]: lightY,
["--hc-a" as string]: active,
}}
>
{children}
{/* Rainbow foil: a wide gradient slid across the card by the light position. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 mix-blend-color-dodge"
style={{
backgroundImage: FOILS[foil],
backgroundSize: "200% 200%",
backgroundPosition: light,
opacity: `calc((0.1 + var(--hc-a) * 0.42) * ${k})`,
filter: "saturate(0.75) brightness(0.62) contrast(1.15)",
maskImage: glow,
WebkitMaskImage: glow,
}}
/>
{/* Patterns are the foil seen through a mask, intersected with the glow around the light. */}
{pattern === "stripes" && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 mix-blend-color-dodge"
style={{
backgroundImage: FOILS[foil],
backgroundSize: "200% 200%",
backgroundPosition: light,
opacity: `calc((0.2 + var(--hc-a) * 0.8) * ${k})`,
...masked(`${STRIPES}, ${glow}`, "100% 100%, 100% 100%", `calc(var(--hc-x) * -0.4px) calc(var(--hc-y) * -0.4px), 0 0`),
}}
/>
)}
{pattern === "sparkles" && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 mix-blend-color-dodge"
style={{
backgroundImage: FOILS[foil],
backgroundSize: "200% 200%",
backgroundPosition: light,
opacity: `calc((0.35 + var(--hc-a) * 0.65) * ${k})`,
...masked(`${GLITTER}, ${glow}`, "180px 180px, 100% 100%", `calc(var(--hc-x) * 0.6px) calc(var(--hc-y) * 0.6px), 0 0`),
}}
/>
)}
{glare && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 mix-blend-overlay"
style={{
backgroundImage: `radial-gradient(farthest-corner circle at ${light}, rgb(255 255 255 / 0.85) 0%, rgb(255 255 255 / 0.22) 22%, rgb(0 0 0 / 0.4) 90%)`,
opacity: "calc(var(--hc-a) * 0.85)",
}}
/>
)}
{/* Fine inner edge so the card reads as a printed, laminated object. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08)]"
style={{ borderRadius: "inherit" }}
/>
</motion.div>
</div>
);
}