{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A HolographicFoil component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useState,\n} from \"react\";\n\ntype BlendMode = NonNullable<CSSProperties[\"mixBlendMode\"]>;\n\nexport type HolographicFoilPattern = \"aurora\" | \"gold\" | \"oil\" | \"prism\";\n\nexport interface HolographicFoilProps {\n  /** Card content. It renders above the foil, so give text a solid backing. */\n  children: ReactNode;\n  /** Extra classes for the card surface. */\n  className?: string;\n  /**\n   * Lay the foil over the content instead of printing it underneath.\n   *\n   * The default suits a card you assemble here — bare stock and translucent\n   * artwork let the print show through from below. Turn this on when the child\n   * is one opaque image of a finished card face, which would otherwise hide the\n   * foil completely.\n   */\n  foilOver?: boolean;\n  /** Strength of the highlight that passes over the content, `0` to `1`. */\n  glare?: number;\n  /** Foil strength, `0` (barely there) to `1` (full holo). */\n  intensity?: number;\n  /** Which foil material the card is printed with. */\n  pattern?: HolographicFoilPattern;\n  /** Speed multiplier for the idle sheen sweep. `1` is the default cadence. */\n  sheenSpeed?: number;\n  /** Couple the foil to a subtle 3D card tilt. */\n  tilt?: boolean;\n  /** Opt into device tilt on touch devices. Permission is requested on demand. */\n  useDeviceOrientation?: boolean;\n}\n\ninterface FoilMaterial {\n  /** Banded gradient stack — bands with gaps, never a uniform wash. */\n  background: string;\n  blend: BlendMode;\n  /** Degrees of hue travel across the full tilt range. Narrow keeps identity. */\n  hueRange: number;\n  /** Per-material saturation multiplier. */\n  saturation: number;\n}\n\ntype OrientationAccess = \"denied\" | \"granted\" | \"idle\" | \"prompt\";\n\ninterface OrientationPermissionApi {\n  requestPermission?: () => Promise<\"denied\" | \"granted\">;\n}\n\nconst HOVER_QUERY = \"(hover: hover) and (pointer: fine)\";\nconst MAX_TILT_DEG = 9;\nconst PERSPECTIVE_PX = 900;\nconst FOIL_DRIFT = 14;\nconst GLARE_DRIFT = 42;\nconst SHEEN_BASE_DURATION_S = 6;\nconst ORIENTATION_RANGE_DEG = 32;\nconst AXIS_MIN = -0.5;\nconst AXIS_MAX = 0.5;\nconst MIN_SHEEN_SPEED = 0.05;\nconst SPRING_CONFIG = { damping: 28, mass: 0.6, stiffness: 240 } as const;\nconst LINEAR_EASE: [number, number, number, number] = [0.25, 0.25, 0.75, 0.75];\n\nconst FOIL_MIN_OPACITY = 0.05;\nconst FOIL_MAX_OPACITY = 0.85;\nconst FOIL_MIN_SATURATION = 0.5;\nconst FOIL_SATURATION_GAIN = 0.85;\nconst FOIL_MIN_CONTRAST = 0.95;\nconst FOIL_CONTRAST_GAIN = 0.3;\n/** The sheen never runs at full strength: it crosses text. */\nconst SHEEN_OPACITY_RATIO = 0.34;\nconst GLARE_OPACITY_RATIO = 0.18;\nconst SHEEN_FROM = \"-120%\";\nconst SHEEN_TO = \"220%\";\n\n/**\n * Concentrates the foil into a diagonal band. Because the layer translates with\n * the tilt, the band travels across the card instead of tinting all of it.\n */\nconst FOIL_MASK =\n  \"linear-gradient(104deg, transparent 0%, oklch(0 0 0 / 0.35) 14%, oklch(0 0 0) 30%, oklch(0 0 0) 70%, oklch(0 0 0 / 0.35) 86%, transparent 100%)\";\n\nconst SHEEN_GRADIENT =\n  \"linear-gradient(102deg, transparent 0%, oklch(0.86 0.07 220 / 0.55) 34%, oklch(1 0 0 / 0.85) 50%, oklch(0.82 0.08 330 / 0.5) 66%, transparent 100%)\";\n/**\n * The sheen gradient is tilted, so its `transparent` stops run diagonally while\n * the band it paints is a rectangle. Near the top the band's own left edge lands\n * mid-gradient, and the box boundary shows up as a straight vertical cut\n * travelling across the card. This fades the band's vertical edges so only the\n * gradient decides where the sheen ends.\n */\nconst SHEEN_EDGE_FADE =\n  \"linear-gradient(to right, transparent 0%, oklch(0 0 0) 22%, oklch(0 0 0) 78%, transparent 100%)\";\nconst GLARE_GRADIENT =\n  \"radial-gradient(closest-side, oklch(1 0 0 / 0.75), oklch(1 0 0 / 0.12) 55%, transparent 78%)\";\n\nconst MATERIALS: Record<HolographicFoilPattern, FoilMaterial> = {\n  // Cool foil: blue → violet → cyan only.\n  aurora: {\n    background: [\n      \"repeating-linear-gradient(103deg, transparent 0 5%, oklch(0.74 0.12 232 / 0.85) 7.5%, oklch(0.66 0.13 288 / 0.8) 10.5%, oklch(0.8 0.1 205 / 0.85) 13.5%, transparent 16% 26%)\",\n      \"linear-gradient(160deg, oklch(0.45 0.09 262 / 0.5), transparent 65%)\",\n    ].join(\", \"),\n    blend: \"screen\",\n    hueRange: 20,\n    saturation: 1,\n  },\n  // Warm foil: gold → amber → magenta only.\n  gold: {\n    background: [\n      \"repeating-linear-gradient(97deg, transparent 0 5%, oklch(0.86 0.12 96 / 0.85) 7.5%, oklch(0.78 0.14 62 / 0.8) 10.5%, oklch(0.7 0.15 355 / 0.78) 14%, transparent 17% 27%)\",\n      \"linear-gradient(150deg, oklch(0.5 0.1 55 / 0.45), transparent 68%)\",\n    ].join(\", \"),\n    blend: \"screen\",\n    hueRange: 24,\n    saturation: 1,\n  },\n  // Oil slick: wide hue spread held at very low chroma, so it reads as a film.\n  oil: {\n    background: [\n      \"repeating-linear-gradient(110deg, transparent 0 6%, oklch(0.7 0.06 190 / 0.7) 8.5%, oklch(0.62 0.06 300 / 0.65) 12%, oklch(0.72 0.05 140 / 0.6) 15.5%, transparent 18.5% 30%)\",\n      \"linear-gradient(135deg, oklch(0.4 0.04 250 / 0.4), transparent 70%)\",\n    ].join(\", \"),\n    blend: \"screen\",\n    hueRange: 30,\n    saturation: 0.7,\n  },\n  // The full holo, still capped: cyan → violet → magenta with one gold band.\n  prism: {\n    background: [\n      \"repeating-linear-gradient(100deg, transparent 0 4%, oklch(0.8 0.14 200 / 0.9) 6%, oklch(0.7 0.16 268 / 0.88) 9%, oklch(0.72 0.17 330 / 0.86) 12%, oklch(0.84 0.12 60 / 0.7) 15%, transparent 17.5% 25%)\",\n      \"radial-gradient(120% 90% at 30% 15%, oklch(0.6 0.12 250 / 0.5), transparent 65%)\",\n    ].join(\", \"),\n    blend: \"screen\",\n    hueRange: 34,\n    saturation: 1.1,\n  },\n};\n\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n\nconst clampAxis = (value: number) =>\n  Math.max(AXIS_MIN, Math.min(AXIS_MAX, value));\n\nconst readOrientationApi = (): OrientationPermissionApi | undefined => {\n  if (typeof window === \"undefined\" || !(\"DeviceOrientationEvent\" in window)) {\n    return;\n  }\n  return window.DeviceOrientationEvent as unknown as OrientationPermissionApi;\n};\n\nconst HolographicFoil = ({\n  children,\n  className,\n  foilOver = false,\n  glare = 0.5,\n  intensity = 0.6,\n  pattern = \"prism\",\n  sheenSpeed = 1,\n  tilt = true,\n  useDeviceOrientation = false,\n}: HolographicFoilProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n  const [orientationAccess, setOrientationAccess] =\n    useState<OrientationAccess>(\"idle\");\n\n  const material = MATERIALS[pattern];\n  const foilLevel = clamp01(intensity);\n  const glareLevel = clamp01(glare);\n\n  const pointerX = useMotionValue(0);\n  const pointerY = useMotionValue(0);\n  const springX = useSpring(pointerX, SPRING_CONFIG);\n  const springY = useSpring(pointerY, SPRING_CONFIG);\n\n  const rotateX = useTransform(\n    springY,\n    [AXIS_MIN, AXIS_MAX],\n    [MAX_TILT_DEG, -MAX_TILT_DEG]\n  );\n  const rotateY = useTransform(\n    springX,\n    [AXIS_MIN, AXIS_MAX],\n    [-MAX_TILT_DEG, MAX_TILT_DEG]\n  );\n  const hue = useTransform(\n    springX,\n    [AXIS_MIN, AXIS_MAX],\n    [-material.hueRange, material.hueRange]\n  );\n  const foilX = useTransform(\n    springX,\n    [AXIS_MIN, AXIS_MAX],\n    [`${-FOIL_DRIFT}%`, `${FOIL_DRIFT}%`]\n  );\n  const foilY = useTransform(\n    springY,\n    [AXIS_MIN, AXIS_MAX],\n    [`${-FOIL_DRIFT}%`, `${FOIL_DRIFT}%`]\n  );\n  const glareX = useTransform(\n    springX,\n    [AXIS_MIN, AXIS_MAX],\n    [`${-GLARE_DRIFT}%`, `${GLARE_DRIFT}%`]\n  );\n  const glareY = useTransform(\n    springY,\n    [AXIS_MIN, AXIS_MAX],\n    [`${-GLARE_DRIFT}%`, `${GLARE_DRIFT}%`]\n  );\n  const sheenX = useTransform(springX, [AXIS_MIN, AXIS_MAX], [\"-18%\", \"18%\"]);\n\n  const saturation =\n    (FOIL_MIN_SATURATION + foilLevel * FOIL_SATURATION_GAIN) *\n    material.saturation;\n  const contrast = FOIL_MIN_CONTRAST + foilLevel * FOIL_CONTRAST_GAIN;\n  const foilFilter = useMotionTemplate`hue-rotate(${hue}deg) saturate(${saturation}) contrast(${contrast})`;\n  const foilOpacity =\n    FOIL_MIN_OPACITY + foilLevel * (FOIL_MAX_OPACITY - FOIL_MIN_OPACITY);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) {\n      return;\n    }\n    const query = window.matchMedia(HOVER_QUERY);\n    setIsHoverDevice(query.matches);\n    const handleChange = (event: MediaQueryListEvent) => {\n      setIsHoverDevice(event.matches);\n    };\n    query.addEventListener(\"change\", handleChange);\n    return () => {\n      query.removeEventListener(\"change\", handleChange);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!useDeviceOrientation || shouldReduceMotion) {\n      setOrientationAccess(\"idle\");\n      return;\n    }\n    const api = readOrientationApi();\n    if (!api) {\n      setOrientationAccess(\"denied\");\n      return;\n    }\n    setOrientationAccess(\n      typeof api.requestPermission === \"function\" ? \"prompt\" : \"granted\"\n    );\n  }, [shouldReduceMotion, useDeviceOrientation]);\n\n  const orientationActive =\n    useDeviceOrientation &&\n    !shouldReduceMotion &&\n    orientationAccess === \"granted\";\n\n  useEffect(() => {\n    if (!orientationActive || typeof window === \"undefined\") {\n      return;\n    }\n    const handleOrientation = (event: DeviceOrientationEvent) => {\n      const gamma = event.gamma ?? 0;\n      const beta = event.beta ?? 0;\n      pointerX.set(clampAxis(gamma / ORIENTATION_RANGE_DEG / 2));\n      pointerY.set(clampAxis(beta / ORIENTATION_RANGE_DEG / 2));\n    };\n    window.addEventListener(\"deviceorientation\", handleOrientation);\n    return () => {\n      window.removeEventListener(\"deviceorientation\", handleOrientation);\n    };\n  }, [orientationActive, pointerX, pointerY]);\n\n  const requestOrientation = useCallback(async () => {\n    const api = readOrientationApi();\n    if (typeof api?.requestPermission !== \"function\") {\n      setOrientationAccess(\"denied\");\n      return;\n    }\n    try {\n      const result = await api.requestPermission();\n      setOrientationAccess(result === \"granted\" ? \"granted\" : \"denied\");\n    } catch {\n      setOrientationAccess(\"denied\");\n    }\n  }, []);\n\n  const tracksPointer = !shouldReduceMotion && isHoverDevice;\n\n  const handlePointerMove = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      const rect = event.currentTarget.getBoundingClientRect();\n      pointerX.set(clampAxis((event.clientX - rect.left) / rect.width - 0.5));\n      pointerY.set(clampAxis((event.clientY - rect.top) / rect.height - 0.5));\n    },\n    [pointerX, pointerY]\n  );\n\n  const handlePointerLeave = useCallback(() => {\n    pointerX.set(0);\n    pointerY.set(0);\n  }, [pointerX, pointerY]);\n\n  const isLive = tracksPointer || orientationActive;\n\n  /**\n   * The print itself. Placed either under or over the content depending on\n   * `foilOver`, which is the only thing that differs between the two modes.\n   */\n  const foilLayer = (\n    <motion.div\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute\"\n      style={{\n        backgroundImage: material.background,\n        filter: isLive ? foilFilter : undefined,\n        inset: `-${FOIL_DRIFT * 2}%`,\n        maskImage: FOIL_MASK,\n        mixBlendMode: material.blend,\n        opacity: foilOpacity,\n        WebkitMaskImage: FOIL_MASK,\n        willChange: \"transform\",\n        x: isLive ? foilX : 0,\n        y: isLive ? foilY : 0,\n      }}\n    />\n  );\n\n  const foilUnder = foilOver ? null : foilLayer;\n  const foilAbove = foilOver ? foilLayer : null;\n  const sheenDuration =\n    SHEEN_BASE_DURATION_S / Math.max(MIN_SHEEN_SPEED, sheenSpeed);\n  const showPermissionButton =\n    useDeviceOrientation &&\n    !shouldReduceMotion &&\n    orientationAccess === \"prompt\";\n\n  return (\n    // The tilt lives on the same element as the border, the stock colour and\n    // the clip, so the card turns as one rigid object. With the transform on an\n    // inner wrapper, this outer box stayed square while its contents leaned —\n    // a static window with a card moving behind it, which is very obvious once\n    // the child is a full-bleed image rather than a padded layout.\n    <motion.div\n      className={cn(\n        \"relative isolate overflow-hidden rounded-2xl border border-white/10 bg-[oklch(0.17_0.015_274)] shadow-lg\",\n        className\n      )}\n      onPointerLeave={tracksPointer ? handlePointerLeave : undefined}\n      onPointerMove={tracksPointer ? handlePointerMove : undefined}\n      style={\n        tilt && isLive\n          ? {\n              rotateX,\n              rotateY,\n              transformPerspective: PERSPECTIVE_PX,\n              transformStyle: \"preserve-3d\",\n            }\n          : undefined\n      }\n    >\n      <div className=\"relative h-full\">\n        {/* Layer order is DOM order, deliberately: no `z-index` anywhere, so no\n            child creates a stacking context and content can blend with the foil\n            (an artwork can; a text scrim stays opaque). Which side of the\n            content the print lands on is `foilOver`. */}\n        {foilUnder}\n\n        <div className=\"relative h-full\">{children}</div>\n\n        {foilAbove}\n\n        {/* Restrained highlight pass over the content. */}\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\"\n        >\n          <motion.div\n            className=\"absolute inset-0\"\n            style={{ x: isLive ? sheenX : 0 }}\n          >\n            <motion.div\n              animate={isLive ? { x: [SHEEN_FROM, SHEEN_TO] } : undefined}\n              className=\"absolute inset-y-0 left-0 w-2/3\"\n              style={{\n                backgroundImage: SHEEN_GRADIENT,\n                maskImage: SHEEN_EDGE_FADE,\n                mixBlendMode: \"soft-light\",\n                opacity: glareLevel * SHEEN_OPACITY_RATIO,\n                WebkitMaskImage: SHEEN_EDGE_FADE,\n                willChange: \"transform\",\n              }}\n              transition={\n                isLive\n                  ? {\n                      duration: sheenDuration,\n                      ease: LINEAR_EASE,\n                      repeat: Number.POSITIVE_INFINITY,\n                      repeatType: \"loop\",\n                    }\n                  : { duration: 0 }\n              }\n            />\n          </motion.div>\n        </div>\n\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute\"\n          style={{\n            backgroundImage: GLARE_GRADIENT,\n            inset: `-${GLARE_DRIFT}%`,\n            mixBlendMode: \"soft-light\",\n            opacity: glareLevel * GLARE_OPACITY_RATIO,\n            willChange: \"transform\",\n            x: isLive ? glareX : 0,\n            y: isLive ? glareY : 0,\n          }}\n        />\n      </div>\n\n      {showPermissionButton ? (\n        <button\n          // Styled against the card's own dark stock rather than the theme:\n          // the foil surface stays dark in light mode too, so theme tokens\n          // would render this invisible.\n          className=\"absolute right-2 bottom-2 cursor-pointer rounded-full border border-white/20 bg-[oklch(0.17_0.015_274)]/85 px-3 py-1 font-medium text-white text-xs outline-none transition-colors duration-150 ease-out hover:border-white/35 hover:bg-[oklch(0.17_0.015_274)] focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[oklch(0.17_0.015_274)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100\"\n          onClick={requestOrientation}\n          type=\"button\"\n        >\n          Enable tilt\n        </button>\n      ) : null}\n    </motion.div>\n  );\n};\n\nexport default HolographicFoil;\n","path":"index.tsx","target":"components/smoothui/holographic-foil/index.tsx","type":"registry:ui"}],"name":"holographic-foil","registryDependencies":[],"title":"Holographic Foil","type":"registry:ui"}