"use client";
import * as React from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react";
import { Smartphone } from "lucide-react";
import { cn } from "@/lib/utils";
export interface HolographicCardProps {
title?: string;
subtitle?: string;
holder?: string;
number?: string;
tier?: string;
/** Max tilt in degrees. */
intensity?: number;
/** Use the device gyroscope when available. */
gyroscope?: boolean;
/** Replace the default face content. */
children?: React.ReactNode;
className?: string;
}
type DOEWithPermission = typeof DeviceOrientationEvent & { requestPermission?: () => Promise<"granted" | "denied"> };
export function HolographicCard({
title = "Orbit Pass",
subtitle = "Founders edition",
holder = "Ava Laurent",
number = "No. 0427 / 1000",
tier = "Aurora",
intensity = 14,
gyroscope = true,
children,
className,
}: HolographicCardProps) {
const reduce = useReducedMotion() ?? false;
const ref = React.useRef<HTMLDivElement>(null);
const px = useMotionValue(0.5);
const py = useMotionValue(0.5);
const hover = useMotionValue(0);
const spring = { stiffness: 170, damping: 20, mass: 0.6 };
const sx = useSpring(px, spring);
const sy = useSpring(py, spring);
const sh = useSpring(hover, { stiffness: 120, damping: 20 });
const tilt = reduce ? intensity * 0.35 : intensity;
const rotateY = useTransform(sx, [0, 1], [-tilt, tilt]);
const rotateX = useTransform(sy, [0, 1], [tilt, -tilt]);
const [interacting, setInteracting] = React.useState(false);
const [inView, setInView] = React.useState(true);
const [needsPermission, setNeedsPermission] = React.useState(false);
const [gyroOn, setGyroOn] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, []);
// Idle drift so the foil shimmers before anyone touches it.
React.useEffect(() => {
if (interacting || gyroOn || !inView || reduce) return;
let raf = 0;
const t0 = performance.now();
const loop = (now: number) => {
const t = (now - t0) / 1000;
px.set(0.5 + Math.sin(t * 0.7) * 0.28);
py.set(0.5 + Math.sin(t * 1.1 + 1) * 0.2);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [interacting, gyroOn, inView, reduce, px, py]);
// Gyroscope.
const listenGyro = React.useCallback(() => {
const onOrient = (e: DeviceOrientationEvent) => {
if (e.gamma == null || e.beta == null) return;
setGyroOn(true);
hover.set(1);
px.set(Math.min(1, Math.max(0, 0.5 + e.gamma / 60)));
py.set(Math.min(1, Math.max(0, 0.5 + (e.beta - 45) / 60)));
};
window.addEventListener("deviceorientation", onOrient);
return () => window.removeEventListener("deviceorientation", onOrient);
}, [hover, px, py]);
React.useEffect(() => {
if (!gyroscope || typeof window === "undefined" || !("DeviceOrientationEvent" in window)) return;
if (!window.matchMedia("(pointer: coarse)").matches) return;
const DOE = DeviceOrientationEvent as DOEWithPermission;
if (typeof DOE.requestPermission === "function") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- iOS needs a user gesture first
setNeedsPermission(true);
return;
}
return listenGyro();
}, [gyroscope, listenGyro]);
const cleanupGyro = React.useRef<(() => void) | null>(null);
React.useEffect(() => () => cleanupGyro.current?.(), []);
const requestGyro = async () => {
const DOE = DeviceOrientationEvent as DOEWithPermission;
try {
const res = await DOE.requestPermission?.();
if (res === "granted") {
cleanupGyro.current = listenGyro();
setNeedsPermission(false);
}
} catch {
setNeedsPermission(false);
}
};
const onMove = (e: React.PointerEvent) => {
if (e.pointerType === "touch" && gyroOn) return;
const r = e.currentTarget.getBoundingClientRect();
px.set((e.clientX - r.left) / r.width);
py.set((e.clientY - r.top) / r.height);
};
return (
<div className={cn("flex flex-col items-center gap-4", className)}>
<div className="[perspective:1100px]">
<motion.div
ref={ref}
tabIndex={0}
role="group"
aria-roledescription="holographic card"
aria-label={`${title}, ${tier} tier, ${holder}, ${number}`}
onPointerEnter={() => (setInteracting(true), hover.set(1))}
onPointerMove={onMove}
onPointerLeave={() => {
setInteracting(false);
hover.set(0);
px.set(0.5);
py.set(0.5);
}}
onFocus={() => hover.set(1)}
onBlur={() => !interacting && hover.set(0)}
onKeyDown={(e) => {
const step = 0.12;
const k = e.key;
if (!k.startsWith("Arrow")) return;
e.preventDefault();
setInteracting(true);
if (k === "ArrowLeft") px.set(Math.max(0, px.get() - step));
if (k === "ArrowRight") px.set(Math.min(1, px.get() + step));
if (k === "ArrowUp") py.set(Math.max(0, py.get() - step));
if (k === "ArrowDown") py.set(Math.min(1, py.get() + step));
}}
style={{ rotateX, rotateY, transformStyle: "preserve-3d" }}
className="relative aspect-[5/7] w-[260px] max-w-[78vw] cursor-grab touch-pan-y rounded-[22px] shadow-[0_30px_60px_-20px_rgba(20,10,60,0.55),0_10px_25px_-10px_rgba(0,0,0,0.35)] outline-none select-none focus-visible:ring-4 focus-visible:ring-ring/50"
>
<Face sx={sx} sy={sy} sh={sh}>
{children ?? <DefaultFace title={title} subtitle={subtitle} holder={holder} number={number} tier={tier} />}
</Face>
</motion.div>
</div>
{needsPermission && (
<button
type="button"
onClick={requestGyro}
className="inline-flex items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<Smartphone className="size-3.5" /> Enable tilt
</button>
)}
</div>
);
}
function Face({ sx, sy, sh, children }: { sx: MotionValue<number>; sy: MotionValue<number>; sh: MotionValue<number>; children: React.ReactNode }) {
const foilPos = useTransform([sx, sy], ([x, y]: number[]) => `${x * 100}% ${y * 100}%`);
const foilPos2 = useTransform([sx, sy], ([x, y]: number[]) => `${100 - x * 100}% ${y * 60 + 20}%`);
const glare = useTransform(
[sx, sy],
([x, y]: number[]) => `radial-gradient(farthest-corner circle at ${x * 100}% ${y * 100}%, rgba(255,255,255,0.75) 0%, rgba(255,255,255,0.22) 18%, rgba(255,255,255,0) 55%)`,
);
const foilOpacity = useTransform(sh, [0, 1], [0.55, 0.9]);
const glareOpacity = useTransform(sh, [0, 1], [0.35, 0.8]);
const shadowX = useTransform(sx, [0, 1], [8, -8]);
const shadowY = useTransform(sy, [0, 1], [8, -8]);
const contentX = useTransform(sx, [0, 1], [-5, 5]);
const contentY = useTransform(sy, [0, 1], [-5, 5]);
return (
<div className="absolute inset-0 overflow-hidden rounded-[22px] bg-[radial-gradient(120%_80%_at_30%_0%,#3b2a7a_0%,#1a1440_45%,#0b0a1f_100%)]">
{/* Holo foil band */}
<motion.div
aria-hidden
className="absolute inset-0 mix-blend-color-dodge"
style={{
opacity: foilOpacity,
backgroundPosition: foilPos,
backgroundSize: "300% 300%",
backgroundImage:
"repeating-linear-gradient(115deg, #ff6fae 0%, #ffcf5c 6%, #6dffc2 12%, #58c8ff 18%, #a98bff 24%, #ff6fae 30%)",
maskImage: "linear-gradient(160deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.35) 45%, rgba(0,0,0,0.9) 100%)",
}}
/>
{/* Diamond sparkle texture */}
<motion.div
aria-hidden
className="absolute inset-0 mix-blend-overlay"
style={{
opacity: foilOpacity,
backgroundPosition: foilPos2,
backgroundSize: "18px 18px, 250% 250%",
backgroundImage:
"repeating-linear-gradient(45deg, rgba(255,255,255,0.14) 0 1px, transparent 1px 9px), linear-gradient(125deg, transparent 20%, rgba(255,255,255,0.7) 45%, transparent 60%)",
}}
/>
{/* Content with depth */}
<motion.div className="absolute inset-0" style={{ x: shadowX, y: shadowY }} aria-hidden>
<div className="absolute inset-3 rounded-[16px] border border-white/10" />
</motion.div>
<motion.div className="absolute inset-0" style={{ x: contentX, y: contentY }}>
{children}
</motion.div>
{/* Glare */}
<motion.div aria-hidden className="pointer-events-none absolute inset-0 mix-blend-overlay" style={{ backgroundImage: glare, opacity: glareOpacity }} />
<div aria-hidden className="pointer-events-none absolute inset-0 rounded-[22px] ring-1 ring-white/25 ring-inset" />
</div>
);
}
function DefaultFace({ title, subtitle, holder, number, tier }: { title: string; subtitle: string; holder: string; number: string; tier: string }) {
const uid = React.useId().replace(/:/g, "");
return (
<div className="flex h-full flex-col p-6 text-white">
<div className="flex items-center justify-between text-[10px] font-semibold tracking-[0.2em] text-white/70 uppercase">
<span>{tier}</span>
<span className="rounded-full border border-white/25 px-2 py-0.5 tracking-[0.15em]">Holo</span>
</div>
<div className="relative mx-auto mt-6 grid size-32 place-items-center">
<svg viewBox="0 0 120 120" className="absolute inset-0 size-full" aria-hidden>
<defs>
<linearGradient id={`${uid}-ring`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#fff" stopOpacity="0.9" />
<stop offset="1" stopColor="#fff" stopOpacity="0.15" />
</linearGradient>
<radialGradient id={`${uid}-core`} cx="0.35" cy="0.3" r="0.8">
<stop offset="0" stopColor="#fff" />
<stop offset="0.35" stopColor="#c4b5fd" />
<stop offset="1" stopColor="#4c1d95" />
</radialGradient>
</defs>
<ellipse cx="60" cy="60" rx="54" ry="20" fill="none" stroke={`url(#${uid}-ring)`} strokeWidth="1.2" transform="rotate(-24 60 60)" />
<ellipse cx="60" cy="60" rx="54" ry="20" fill="none" stroke={`url(#${uid}-ring)`} strokeWidth="1.2" transform="rotate(36 60 60)" />
<circle cx="60" cy="60" r="26" fill={`url(#${uid}-core)`} />
<circle cx="104" cy="44" r="4" fill="#fff" />
<circle cx="22" cy="86" r="2.5" fill="#fff" fillOpacity="0.8" />
</svg>
</div>
<div className="mt-auto">
<p className="text-[11px] font-medium tracking-[0.18em] text-white/60 uppercase">{subtitle}</p>
<h3 className="mt-1 text-2xl font-semibold tracking-tight">{title}</h3>
<div className="mt-4 flex items-end justify-between gap-2 border-t border-white/15 pt-3 text-xs">
<span>
<span className="block text-[10px] tracking-wider text-white/50 uppercase">Holder</span>
<span className="font-medium">{holder}</span>
</span>
<span className="font-mono text-[11px] text-white/70">{number}</span>
</div>
</div>
</div>
);
}