Holo Foil
Effects & Borders

Holo Foil

A restrained holographic foil whose spectral sheen shifts with pointer position; fine-pointer gated with a soft idle drift fallback.

Install

npx shadcn@latest add @paragon/holo-foil

holo-foil.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface HoloFoilProps extends React.ComponentProps<"div"> {
  /** Corner radius in px. Match the parent's radius. */
  borderRadius?: number;
  /** Peak sheen opacity at the pointer, 0–1. */
  strength?: number;
  /**
   * Foil hues in the diffraction band. Restrained multi-stops by default —
   * NEVER garish. Passed through a repeating linear-gradient.
   */
  colors?: string[];
  /** Idle drift period in seconds when the pointer is away. */
  duration?: number;
}

const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);

/**
 * A restrained holographic foil that shifts with pointer position — the
 * spectral shimmer of a trading card or security hologram, tuned to
 * enterprise calm.
 *
 * Technique: a fine repeating diffraction band (a few muted hues) is parked
 * over the surface once; a soft radial "torch" mask driven by two registered
 * @property percentages (pointer x/y) reveals only the patch under the cursor,
 * and the band's position shifts with the pointer so the hues appear to bend
 * as you move — for the cost of interpolating a few values. Gated behind
 * (hover: hover) and (pointer: fine): on touch/coarse pointers it falls back
 * to a whisper-soft idle drift. Reduced motion parks a static, centered sheen.
 * Absolutely positioned — parent needs position: relative.
 */
export function HoloFoil({
  borderRadius = 12,
  strength = 0.5,
  colors = ["#4D80E6", "#2dd4bf", "#a5b4fc", "#f0abfc", "#38bdf8"],
  duration = 7,
  className,
  style,
  ref: forwardedRef,
  ...props
}: HoloFoilProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const localRef = React.useRef<HTMLDivElement | null>(null);
  const [inView, setInView] = React.useState(true);
  const [fine, setFine] = React.useState(false);

  React.useEffect(() => {
    const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
    const update = () => setFine(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  React.useEffect(() => {
    const node = localRef.current;
    if (!node) return;
    const observer = new IntersectionObserver(([entry]) => {
      setInView(entry?.isIntersecting ?? true);
    });
    observer.observe(node);

    if (!fine) return () => observer.disconnect();

    // Write pointer position straight to the element (no React re-render, no
    // parent-variable recalc storm). rAF-throttled.
    let frame = 0;
    let px = 50;
    let py = 50;
    const write = () => {
      frame = 0;
      node.style.setProperty(`--holo-x-${id}`, `${px}%`);
      node.style.setProperty(`--holo-y-${id}`, `${py}%`);
    };
    const onMove = (e: PointerEvent) => {
      const rect = node.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      px = clamp01((e.clientX - rect.left) / rect.width) * 100;
      py = clamp01((e.clientY - rect.top) / rect.height) * 100;
      node.dataset.active = "true";
      if (!frame) frame = requestAnimationFrame(write);
    };
    const onLeave = () => {
      delete node.dataset.active;
    };
    const parent = node.parentElement ?? node;
    parent.addEventListener("pointermove", onMove);
    parent.addEventListener("pointerleave", onLeave);
    return () => {
      observer.disconnect();
      parent.removeEventListener("pointermove", onMove);
      parent.removeEventListener("pointerleave", onLeave);
      if (frame) cancelAnimationFrame(frame);
    };
  }, [id, fine]);

  const level = clamp01(strength);
  const band = colors.length > 1 ? colors : [colors[0] ?? "#4D80E6", "#38bdf8"];
  // Fine diffraction lines — kept subtle via oklab mixing to transparent.
  const stripe = band
    .map(
      (c, i) =>
        `color-mix(in oklab, ${c} 55%, transparent) ${((i / band.length) * 100).toFixed(1)}%`,
    )
    .join(", ");

  return (
    <>
      <style href={`paragon-holo-foil-${id}`} precedence="paragon">{`
        @property --holo-x-${id} { syntax: "<percentage>"; initial-value: 50%; inherits: true; }
        @property --holo-y-${id} { syntax: "<percentage>"; initial-value: 50%; inherits: true; }
        [data-holo="${id}"] {
          transition: opacity var(--duration-base) var(--ease-out);
        }
        [data-holo="${id}"] > span {
          transition: --holo-x-${id} 220ms var(--ease-out), --holo-y-${id} 220ms var(--ease-out);
        }
        @keyframes holo-idle-${id} {
          0%, 100% { --holo-x-${id}: 32%; --holo-y-${id}: 38%; }
          50% { --holo-x-${id}: 68%; --holo-y-${id}: 62%; }
        }
        [data-holo="${id}"][data-paused] > span { animation-play-state: paused; }
        @media (prefers-reduced-motion: reduce) {
          [data-holo="${id}"] > span {
            animation: none !important;
            transition: none !important;
            --holo-x-${id}: 50% !important;
            --holo-y-${id}: 50% !important;
          }
        }
      `}</style>
      <div
        aria-hidden
        data-holo={id}
        data-paused={!inView || undefined}
        ref={(node) => {
          localRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        className={cn(
          "pointer-events-none absolute inset-0 opacity-100",
          className,
        )}
        style={{ borderRadius, mixBlendMode: "screen", ...style }}
        {...props}
      >
        <span
          style={
            {
              position: "absolute",
              inset: 0,
              borderRadius,
              opacity: level,
              // Diffraction band, its phase nudged by the pointer so hues bend.
              backgroundImage: `repeating-linear-gradient(115deg, ${stripe}, color-mix(in oklab, ${band[0]} 55%, transparent) 100%)`,
              backgroundSize: "220% 220%",
              backgroundPosition: `var(--holo-x-${id}) var(--holo-y-${id})`,
              // Torch mask: reveal only the patch under the pointer.
              maskImage: `radial-gradient(120px 120px at var(--holo-x-${id}) var(--holo-y-${id}), black, transparent 72%)`,
              WebkitMaskImage: `radial-gradient(120px 120px at var(--holo-x-${id}) var(--holo-y-${id}), black, transparent 72%)`,
              // Idle drift only on coarse pointers (no cursor to follow).
              animation: fine
                ? undefined
                : `holo-idle-${id} ${duration}s var(--ease-in-out) infinite`,
              willChange: "background-position, mask-position",
            } as React.CSSProperties
          }
        />
      </div>
    </>
  );
}