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