Fazekit

Code

"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>
  );
}

More in Cards

View all →