# Holographic card

> A dark card whose foil blooms in rainbow light where the pointer moves, over contour lines and a fractal triangle, as the card tilts.

A collectible-style card, near-black at rest with a thin triangle on it. Bring the pointer near and a rainbow foil lights up around it: fine contour lines ringing the card, a Sierpinski triangle inside the outline, and a warm glare where the light is strongest. The card tilts away under the light and settles flat again when the pointer leaves. It is drawn by a WebGL shader, with a CSS foil where WebGL is missing.

A React component by Bruno Delorence: https://brunodelorence.com/components/holographic-card. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Media.

## Anatomy

1. **Stage**: The pointer’s field: the light follows the pointer anywhere over it, so it reaches the card before the pointer does. Its size sets the card’s.
2. **Card**: A focusable group named by its title, subtitle and edition. It tilts away under the light, up to 4.8° and 4.4°, with perspective.
3. **Foil**: One WebGL2 shader on a canvas under everything: contour lines in a diagonal rainbow, lit in a soft circle around the light.
4. **Triangle**: A 1 px outline over the foil; inside it the shader draws a Sierpinski triangle whose kept pieces light up.
5. **Brand**: The wordmark at the top left, with a thin plus at the top right.
6. **Title**: The title low on the card, with a dimmer subtitle under it. Real text, so screen readers and search see it.
7. **Footer**: The edition and the badge, monospaced capitals at either end of the card’s foot.
8. **Caption**: The hint under the card, hidden from screen readers; an empty `caption` leaves it out.

## When to use

- A hero object that rewards a closer look: a limited edition, a launch, a membership tier, a collectible.
- A drop or a reveal page where one card is the whole stage and the reader is invited to play with the light.
- Showing off a brand’s craft: the foil, the contours and the fractal are computed live, so they stay crisp at any size.
- A dark, quiet surface whose interest appears only on interaction: at rest the card is nearly plain.

## When not to use

- Content that has to be read at rest. The foil appears only under the light; put key facts in plain text beside the card.
- A grid of many cards. Each one is a WebGL canvas with its own context; use static images or a plain card and keep the foil for one.
- A card with an action on it. It is a focusable group, not a button; use a card with a real button or link.
- A product card for real merchandise or a payment card. Use a photo or a flat card; the foil reads as decoration, not as the thing.
- A small slot in a dense layout. It needs a stage of about 440 × 620 px to breathe; below that, use a thumbnail.

## Installation

Install it with the shadcn CLI, into components/holographic-card of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/holographic-card.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { HolographicCard } from './holographic-card';

export function EditionDrop() {
  return (
    <HolographicCard
      brand="prism"
      title="Holographic"
      subtitle="Light, computed."
      edition="Edition 001"
      badge="Shader"
      autoplay={false}
    />
  );
}
```

## States

- **At rest**: No light: a near-black card with its outline. `autoplay={false}` keeps the demo away.
- **Lit in the middle**: The light at the card’s centre: the triangle’s fractal lights up and the card lies flat.
- **Lit high**: High on the card the core turns into a warm white glare, and the card tilts back and to the side.
- **Lit low**: Low on the left the foil turns green and blue, and the card tilts forward.
- **Own words**: `brand`, `title`, `subtitle`, `edition` and `badge` set every word on the card.
- **No hint**: `caption=""` leaves the hint under the card out.
- **Light stage**: `colorScheme="light"`: the stage turns pale; the card stays dark, as an object would.
- **Dark stage**: `colorScheme="dark"`, or a dark page.

## How it works

- One WebGL2 fragment shader draws the whole foil on a canvas inside the card, in the card’s own units (442 × 628), so it looks the same at any size. The words, the triangle’s outline and the marks are HTML and SVG on top, and stay sharp.
- The contour lines are drawn every 8.5 units along a height field: the smooth minimum of the distances to two points, one right of the triangle and one under it, bent by a few slow sine waves. So they ring those points, merge, and run in wavy bands to the edges. Their width comes from the field’s screen-space derivative, so they stay about one CSS pixel at any scale.
- The colour is a rainbow laid diagonally across the card, from green at the lower left through blue, violet and pink to gold at the upper right, and it slides as the light moves, as a foil’s colours do when it tilts. The light is a soft round falloff about 360 units wide with a bright core; high on the card the core turns into a warm white glare.
- Inside the triangle, each pixel walks the Sierpinski subdivision from its barycentric coordinates: the kept triangles light up, the holes stay dark with a soft shade under their top edge, and levels smaller than about 2.5 device pixels are drawn as their average, so the fractal never shimmers.
- The light follows the pointer anywhere over the stage with a 140 ms lag, comes on in about 0.3 s and goes off in under 0.1 s when the pointer leaves. The card tilts away under it, up to 4.8° about its upright axis and 4.4° about its level one, with perspective.
- Frames run only while something moves and the stage is on screen in a visible tab. The canvas draws at most 2 device pixels per CSS pixel, 1.25 on a device with four cores or 4 GB of memory or less.
- Without WebGL2, or if the context is lost, a CSS foil stands in: the same rainbow seen through rings, under a round mask that follows the light through custom properties. Under reduced motion nothing tilts or eases; a still sheen rests on the card and the pointer moves the light directly.
- The card is sized in em of a font size the stage works out from its own width and height, so it scales down to fit a phone or a short stage. The stage and the hint are light-dark() tokens; the card stays dark in both schemes.

## API reference

- **brand** (`string`, default `'prism'`): The wordmark at the top left of the card.
- **title** (`string`, default `'Holographic'`): The card’s title, low on the card.
- **subtitle** (`string`, default `'Light, computed.'`): The line under the title.
- **edition** (`string`, default `'Edition 001'`): The small print at the bottom left, set in capitals.
- **badge** (`string`, default `'Shader'`): The small print at the bottom right, set in capitals.
- **caption** (`string`, default `'Move to reveal'`): The hint under the card; an empty string leaves it out.
- **deviceTilt** (`boolean`, default `true`): On a phone, the first touch asks to follow the phone’s tilt with the light, where the browser allows it.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer sweeps across the card, around it and away. Any real input hands control to the reader.
- **defaultLight** (`{ x: number; y: number }`): Opens with the light held at this point, in card units (442 × 628, from the top left), the card tilted toward it, and the demo off. The reader’s input still moves it.
- **colorScheme** (`'light' | 'dark' | 'system'`): The stage’s colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme. The card stays dark either way.
- **className** (`string`): Extra class for the root.

## Accessibility

- The card is a focusable group named by its title, subtitle and edition, described by a hidden hint. With focus, the arrow keys move the light (Shift moves it further), Home centres it and Escape turns it off; blur turns it off too. The focus ring is drawn around the card.
- The canvas, the CSS foil, the triangle, the marks, the hint under the card and the demo pointer are decorative and hidden from screen readers; the words on the card are real text.
- On touch screens, a sideways drag moves the light while an up-and-down one still scrolls the page. Following the phone’s tilt starts only after a touch, and only if the browser grants it.
- Under reduced motion the card never tilts, the light does not ease, the demo never plays, and a still sheen shows what the foil looks like.

## Source

### holographic-card/holographic-card.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useId,
  useRef,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { CARD, createFoil, type Foil } from './renderer';
import { useAutoplay } from './use-autoplay';
import styles from './holographic-card.module.css';

/*
 * Holographic card: a dark card with a triangle on it. Move over it and a rainbow foil blooms
 * where the light falls: contour lines ringing the card, a Sierpinski triangle inside the
 * triangle, and the card tilts a little toward the light.
 *
 * - The foil is a WebGL2 shader (renderer.ts) on a canvas inside the card; the words, the outline
 *   of the triangle and the marks are HTML and SVG on top, so they stay sharp and readable.
 * - The light follows the pointer anywhere over the stage, so it reaches the card's edge before
 *   the pointer does. It eases after the pointer (about 60 ms), comes on in about 0.3 s and goes
 *   off in under 0.1 s when the pointer leaves.
 * - The card tilts away under the light: up to 4.8 degrees about its upright axis and 4.4 about
 *   its level one, at the card's edges, easing in about 160 ms.
 * - On a phone, a sideways drag moves the light (an up-and-down one still scrolls), and the first
 *   touch asks to follow the phone's tilt, where the browser allows it.
 * - Frames run only while something moves and the card is on screen in a visible tab. The canvas
 *   draws at most 2 device px per CSS px, 1.25 on a modest device.
 * - Without WebGL2, a CSS foil stands in: a rainbow seen through rings, under a round mask that
 *   follows the light. Under reduced motion the card neither tilts nor animates; a still sheen
 *   rests on it, and the pointer moves the light without easing.
 * - While on screen and untouched, a demo pointer sweeps across the card, around it and off.
 */

/** The card's middle, in card units. */
const MIDDLE = { x: CARD.width / 2, y: CARD.height / 2 };
/** Time constants, in seconds: the light's position, its coming on and off, and the tilt. */
const FOLLOW = 0.14;
const RISE = 0.3;
const FALL = 0.08;
const TILT = 0.12;
/** The tilt at the card's edges, in degrees, about its upright and its level axes. */
const TILT_Y = 4.8;
const TILT_X = 4.4;
/** Where the reduced-motion sheen rests, and how bright. */
const STILL = { x: 330, y: 120, presence: 0.7 };
/** One keypress moves the light this far, in card units (Shift: three times). */
const STEP = 24;

/**
 * The demo's flight, in card units, timed in seconds: in from the left, across, round the right
 * side, back along the bottom, up the left edge, over the top and away.
 */
const PATH: [number, number, number][] = [
  [0, -335, 157],
  [0.5, -150, 205],
  [1.0, -4, 241],
  [1.1, 43, 248],
  [1.75, 446, 273],
  [2.05, 515, 323],
  [2.4, 518, 416],
  [2.75, 471, 466],
  [3.3, 151, 476],
  [3.55, -4, 466],
  [3.75, -38, 385],
  [3.95, -29, 260],
  [4.15, -4, 154],
  [4.35, 34, 73],
  [4.5, 96, 54],
  [4.75, 221, 91],
  [5.0, 314, 166],
  [5.25, 520, 210],
];
/** The demo's light goes off here; the loop starts again at LOOP. */
const GONE = 5.15;
const LOOP = 7;

/** Where the demo pointer is at `time` seconds into the loop: a Catmull-Rom curve. */
function flight(time: number) {
  let i = 0;
  while (i < PATH.length - 2 && PATH[i + 1][0] <= time) i++;
  const [t1, x1, y1] = PATH[i];
  const [t2, x2, y2] = PATH[i + 1];
  const [, x0, y0] = PATH[Math.max(0, i - 1)];
  const [, x3, y3] = PATH[Math.min(PATH.length - 1, i + 2)];
  const u = Math.min(1, Math.max(0, (time - t1) / (t2 - t1)));
  const spline = (a: number, b: number, c: number, d: number) =>
    0.5 *
    (2 * b +
      (-a + c) * u +
      (2 * a - 5 * b + 4 * c - d) * u * u +
      (-a + 3 * b - 3 * c + d) * u * u * u);
  return { x: spline(x0, x1, x2, x3), y: spline(y0, y1, y2, y3) };
}

const clamp = (value: number, min = -1, max = 1) => Math.min(max, Math.max(min, value));
/** Eases `from` toward `to` over `dt` seconds, with time constant `tau`. */
const ease = (from: number, to: number, dt: number, tau: number) =>
  from + (to - from) * (1 - Math.exp(-dt / tau));

export function HolographicCard({
  brand = 'prism',
  title = 'Holographic',
  subtitle = 'Light, computed.',
  edition = 'Edition 001',
  badge = 'Shader',
  caption = 'Move to reveal',
  deviceTilt = true,
  autoplay = true,
  defaultLight,
  colorScheme,
  className = '',
}: {
  /** The wordmark at the top left. */
  brand?: string;
  /** The card's title, low on the card. */
  title?: string;
  /** The line under the title. */
  subtitle?: string;
  /** The small print at the bottom left. */
  edition?: string;
  /** The small print at the bottom right. */
  badge?: string;
  /** The hint under the card; an empty string leaves it out. */
  caption?: string;
  /** On a phone, the first touch asks to follow the phone's tilt with the light. */
  deviceTilt?: boolean;
  /** While on screen and untouched, a demo pointer sweeps across the card. */
  autoplay?: boolean;
  /**
   * Opens with the light held at this point, in card units (442 × 628, from the top left), the
   * card tilted toward it and settled, and the demo off. The reader's input still moves it.
   */
  defaultLight?: { x: number; y: number };
  /** Light or dark stage, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(4000);
  const hintId = useId();
  const card = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** Where the light should be and whether it is on; the loop eases toward it. */
  const target = useRef(
    defaultLight ? { x: defaultLight.x, y: defaultLight.y, on: true } : { ...MIDDLE, on: false },
  );
  /** Wakes the loop after a change of target. */
  const wake = useRef<() => void>(() => {});
  const demo = useRef(false);
  /** Who set the light last: the demo turns it off when it stops, never the reader's. */
  const owner = useRef<'demo' | 'reader'>('reader');
  demo.current = autoplay && !defaultLight && playing;

  useEffect(() => {
    const stage = root.current;
    const element = card.current;
    const surface = canvas.current;
    if (!stage || !element || !surface) return;

    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    let reduced = motion.matches;
    const nav = navigator as Navigator & { deviceMemory?: number };
    const modest = (nav.hardwareConcurrency ?? 8) <= 4 || (nav.deviceMemory ?? 8) <= 4;
    const ratio = () => Math.min(window.devicePixelRatio || 1, modest ? 1.25 : 2);

    let foil: Foil | null = createFoil(surface);
    stage.dataset.renderer = foil ? 'webgl' : 'css';
    const lost = (event: Event) => {
      event.preventDefault();
      foil = null;
      stage.dataset.renderer = 'css';
      paint();
    };
    surface.addEventListener('webglcontextlost', lost);

    const now = { x: MIDDLE.x, y: MIDDLE.y, presence: 0, tiltX: 0, tiltY: 0 };
    // A light held from the start is drawn settled: no rise, no tilt to ease into.
    if (target.current.on) {
      const { x, y } = target.current;
      now.x = x;
      now.y = y;
      now.presence = 1;
      if (!reduced) {
        now.tiltY = clamp((x - MIDDLE.x) / MIDDLE.x) * TILT_Y;
        now.tiltX = -clamp((y - MIDDLE.y) / MIDDLE.y) * TILT_X;
      }
    }
    let visible = false;
    let frame = 0;
    let last = 0;
    let demoStart = -1;

    /** Draws the current state: the canvas, the CSS foil's variables and the tilt. */
    const paint = () => {
      const still = reduced && !target.current.on;
      const light = still ? STILL : now;
      const presence = still ? STILL.presence : now.presence;
      const across = clamp((light.x - MIDDLE.x) / MIDDLE.x);
      const down = clamp((light.y - MIDDLE.y) / MIDDLE.y);
      const hue = 332.4 - 13.5 * across + 44.9 * down;
      // The glare is strongest with the light high on the card, as from a lamp overhead.
      const glare = clamp((-down - 0.1) / 0.5, 0, 1);
      foil?.draw({ x: light.x, y: light.y, presence, hue, glare });
      element.style.setProperty('--light-x', `${((light.x / CARD.width) * 100).toFixed(2)}%`);
      element.style.setProperty('--light-y', `${((light.y / CARD.height) * 100).toFixed(2)}%`);
      element.style.setProperty('--light-on', presence.toFixed(3));
      element.style.setProperty('--foil-hue', `${hue.toFixed(1)}deg`);
      element.style.transform =
        now.tiltX || now.tiltY
          ? `rotateX(${now.tiltX.toFixed(3)}deg) rotateY(${now.tiltY.toFixed(3)}deg)`
          : '';
    };

    const tick = (time: number) => {
      frame = 0;
      const dt = last ? Math.min(0.05, (time - last) / 1000) : 1 / 60;
      last = time;
      let moving = false;

      if (demo.current && !reduced) {
        if (demoStart < 0) demoStart = time;
        const t = ((time - demoStart) / 1000) % LOOP;
        const at = flight(Math.min(t, PATH[PATH.length - 1][0]));
        target.current = { x: at.x, y: at.y, on: t < GONE };
        owner.current = 'demo';
        placePointer(at, t < GONE);
        moving = true;
      } else if (demoStart >= 0) {
        demoStart = -1;
        placePointer(null, false);
        if (owner.current === 'demo') target.current = { ...target.current, on: false };
      }

      const goal = target.current;
      if (reduced) {
        now.x = goal.x;
        now.y = goal.y;
        now.presence = goal.on ? 1 : 0;
        now.tiltX = now.tiltY = 0;
      } else {
        // A light that comes on starts where the pointer is, instead of flying there.
        if (goal.on && now.presence < 0.02) {
          now.x = goal.x;
          now.y = goal.y;
        }
        now.x = ease(now.x, goal.x, dt, FOLLOW);
        now.y = ease(now.y, goal.y, dt, FOLLOW);
        now.presence = ease(now.presence, goal.on ? 1 : 0, dt, goal.on ? RISE : FALL);
        const across = goal.on ? clamp((now.x - MIDDLE.x) / MIDDLE.x) : 0;
        const down = goal.on ? clamp((now.y - MIDDLE.y) / MIDDLE.y) : 0;
        now.tiltY = ease(now.tiltY, across * TILT_Y, dt, TILT);
        now.tiltX = ease(now.tiltX, -down * TILT_X, dt, TILT);
        const settled =
          Math.abs(now.x - goal.x) < 0.05 &&
          Math.abs(now.y - goal.y) < 0.05 &&
          Math.abs(now.presence - (goal.on ? 1 : 0)) < 0.002 &&
          Math.abs(now.tiltY - across * TILT_Y) < 0.002 &&
          Math.abs(now.tiltX - -down * TILT_X) < 0.002;
        if (!settled) moving = true;
        else if (!goal.on) now.presence = now.tiltX = now.tiltY = 0;
      }
      paint();
      if (moving && visible) frame = requestAnimationFrame(tick);
      else last = 0;
    };

    /** Moves the demo's pointer over the stage, in card units; null hides it. */
    const placePointer = (at: { x: number; y: number } | null, shown: boolean) => {
      const arrow = pointer.current;
      if (!arrow) return;
      if (!at || !shown) {
        arrow.style.opacity = '0';
        return;
      }
      const scale = element.offsetWidth / CARD.width;
      const x = stage.clientWidth / 2 + (at.x - MIDDLE.x) * scale;
      const y = stage.clientHeight / 2 + (at.y - MIDDLE.y) * scale;
      arrow.style.opacity = '1';
      arrow.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px)`;
    };

    const start = () => {
      if (!frame && visible) frame = requestAnimationFrame(tick);
    };
    wake.current = start;

    const resize = () => {
      foil?.resize(ratio());
      paint();
    };
    const sizes = new ResizeObserver(resize);
    sizes.observe(surface);
    resize();

    const observer = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting && !document.hidden;
      if (visible) start();
    });
    observer.observe(stage);
    const onVisibility = () => {
      visible = !document.hidden && visible;
      if (!document.hidden) observer.takeRecords();
      if (document.hidden) {
        cancelAnimationFrame(frame);
        frame = 0;
      } else {
        // The observer reports again only on a change: look once more.
        const box = stage.getBoundingClientRect();
        visible = box.bottom > 0 && box.top < innerHeight;
        start();
      }
    };
    document.addEventListener('visibilitychange', onVisibility);
    const onMotion = () => {
      reduced = motion.matches;
      paint();
      start();
    };
    motion.addEventListener('change', onMotion);

    return () => {
      cancelAnimationFrame(frame);
      sizes.disconnect();
      observer.disconnect();
      document.removeEventListener('visibilitychange', onVisibility);
      motion.removeEventListener('change', onMotion);
      surface.removeEventListener('webglcontextlost', lost);
      foil?.dispose();
      wake.current = () => {};
    };
  }, [root]);

  // The demo starts and stops with the autoplay's state.
  useEffect(() => {
    wake.current();
  }, [playing, autoplay]);

  /** Card units under a point of the page. */
  const toCard = (clientX: number, clientY: number) => {
    const stage = root.current;
    const element = card.current;
    if (!stage || !element) return MIDDLE;
    const box = stage.getBoundingClientRect();
    const scale = element.offsetWidth / CARD.width || 1;
    return {
      x: MIDDLE.x + (clientX - box.left - box.width / 2) / scale,
      y: MIDDLE.y + (clientY - box.top - box.height / 2) / scale,
    };
  };

  const aim = (x: number, y: number, on = true) => {
    target.current = { x, y, on };
    owner.current = 'reader';
    // The reader takes over at once, before the autoplay's state catches up.
    demo.current = false;
    wake.current();
  };

  const onPointerMove = (event: ReactPointerEvent) => {
    take();
    const at = toCard(event.clientX, event.clientY);
    aim(at.x, at.y);
  };
  const onPointerLeave = (event: ReactPointerEvent) => {
    if (event.pointerType === 'touch') return;
    aim(target.current.x, target.current.y, false);
  };
  const onPointerUp = (event: ReactPointerEvent) => {
    if (event.pointerType === 'touch' && !tilting.current)
      aim(target.current.x, target.current.y, false);
  };

  /** Following the phone's tilt, once the reader allowed it with a touch. */
  const tilting = useRef(false);
  const asked = useRef(false);
  const onPointerDown = (event: ReactPointerEvent) => {
    take();
    const at = toCard(event.clientX, event.clientY);
    aim(at.x, at.y);
    if (event.pointerType !== 'touch' || !deviceTilt || asked.current) return;
    asked.current = true;
    const Orientation = window.DeviceOrientationEvent as
      (typeof DeviceOrientationEvent & { requestPermission?: () => Promise<string> }) | undefined;
    if (!Orientation) return;
    const listen = () => {
      let rest: number | null = null;
      const onTilt = (tilt: DeviceOrientationEvent) => {
        if (tilt.beta == null || tilt.gamma == null || !root.current?.isConnected) return;
        rest ??= tilt.beta;
        tilting.current = true;
        take();
        aim(
          MIDDLE.x + clamp(tilt.gamma / 25) * MIDDLE.x,
          MIDDLE.y + clamp((tilt.beta - rest) / 25) * MIDDLE.y,
        );
      };
      window.addEventListener('deviceorientation', onTilt);
      cleanupTilt.current = () => window.removeEventListener('deviceorientation', onTilt);
    };
    if (Orientation.requestPermission)
      Orientation.requestPermission()
        .then((answer) => answer === 'granted' && listen())
        .catch(() => {});
    else listen();
  };
  const cleanupTilt = useRef<() => void>(() => {});
  useEffect(() => () => cleanupTilt.current(), []);

  const onKeyDown = (event: ReactKeyboardEvent) => {
    const step = event.shiftKey ? STEP * 3 : STEP;
    const { x, y } = target.current.on ? target.current : MIDDLE;
    const moves: Record<string, [number, number]> = {
      ArrowLeft: [-step, 0],
      ArrowRight: [step, 0],
      ArrowUp: [0, -step],
      ArrowDown: [0, step],
    };
    if (event.key in moves) {
      event.preventDefault();
      take();
      const [dx, dy] = moves[event.key];
      aim(clamp(x + dx, 0, CARD.width), clamp(y + dy, 0, CARD.height));
    } else if (event.key === 'Home') {
      event.preventDefault();
      aim(MIDDLE.x, MIDDLE.y);
    } else if (event.key === 'Escape') aim(x, y, false);
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-part="stage"
      onPointerMove={onPointerMove}
      onPointerDown={onPointerDown}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
      onPointerLeave={onPointerLeave}
    >
      <div className={styles.scene}>
        <div
          ref={card}
          className={styles.card}
          data-part="card"
          role="group"
          aria-roledescription="holographic card"
          aria-label={`${title}. ${subtitle} ${edition}.`}
          aria-describedby={hintId}
          tabIndex={0}
          onFocus={() =>
            aim(
              target.current.on ? target.current.x : MIDDLE.x,
              target.current.on ? target.current.y : MIDDLE.y,
            )
          }
          onBlur={() => aim(target.current.x, target.current.y, false)}
          onKeyDown={onKeyDown}
        >
          <canvas ref={canvas} className={styles.foil} data-part="foil" aria-hidden="true" />
          <span className={styles.fallback} data-part="fallback" aria-hidden="true" />
          <svg className={styles.art} viewBox="0 0 442 628" aria-hidden="true" data-part="triangle">
            <path d="M221 162.75 L351.05 388 L90.95 388 Z" className={styles.outline} />
            <path
              d="M46 185h10M51 180v10M385 185h10M390 180v10M46 440h10M51 435v10M385 440h10M390 435v10"
              className={styles.marks}
            />
          </svg>
          <div className={styles.top}>
            <span className={styles.brand} data-part="brand">
              {brand}
            </span>
            <svg className={styles.plus} viewBox="0 0 18 18" aria-hidden="true" data-part="plus">
              <path d="M9 0v18M0 9h18" />
            </svg>
          </div>
          <div className={styles.titles}>
            <p className={styles.title} data-part="title">
              {title}
            </p>
            <p className={styles.subtitle} data-part="subtitle">
              {subtitle}
            </p>
          </div>
          <div className={styles.footer} data-part="footer">
            <span>{edition}</span>
            <span>{badge}</span>
          </div>
        </div>
        {caption ? (
          <p className={styles.caption} data-part="caption" aria-hidden="true">
            <span>{caption}</span>
          </p>
        ) : null}
      </div>
      <p id={hintId} className={styles.hidden}>
        Move the pointer over the card, or use the arrow keys, to light it.
      </p>
      <span ref={pointer} className={styles.demoPointer} aria-hidden="true">
        <svg viewBox="0 0 14 20">
          <path d="M1 1v15.5l3.9-3.7 2.6 6 2.4-1-2.6-5.9h5.4z" />
        </svg>
      </span>
    </div>
  );
}
```

### holographic-card/holographic-card.module.css

```css
/* Holographic card (holographic-card.tsx): a near-black stage with the card in its middle and a
   quiet hint under it; by day the stage turns pale and the card stays dark, as an object would.

   Sizes. The card is drawn at 442 × 628 px at its own size: every length inside it is in em of a
   16 px font size, which the stage sets from its own size (a container query), so the whole card
   scales down to fit a smaller or shorter stage and keeps its proportions.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets.

   Memory. The stage is one layout and size boundary; the card tilts by a transform on its own
   layer, the light is a canvas the shader redraws, and the demo pointer moves by a transform. */

.root {
  /* The stage, its glow behind the card, and the hint's words. */
  --stage: #06090a;
  --stage-glow: #0b0e10;
  --hint: rgb(255 255 255 / 0.3);
  /* The card's shadow on the stage. */
  --card-shadow: rgb(0 0 0 / 0.85);
  /* The same on both schemes: the card itself, which stays dark. */
  --card-top: #111315;
  --card-bottom: #0f1112;
  --card-edge: rgb(255 255 255 / 0.13);
  --ink: rgb(255 255 255 / 0.78);
  --ink-2: rgb(255 255 255 / 0.42);
  --ink-3: rgb(255 255 255 / 0.55);
  --outline: rgb(255 255 255 / 0.22);
  --mark: rgb(255 255 255 / 0.05);
  --focus: #387bff;
  --pointer: #111;
  --pointer-edge: #fff;
  position: relative;
  container-type: size;
  contain: size layout;
  width: 100%;
  aspect-ratio: 4 / 3;
  min-height: 620px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: radial-gradient(closest-side, var(--stage-glow), var(--stage)) var(--stage);
  touch-action: pan-y pinch-zoom;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#eceef0, #06090a);
    --stage-glow: light-dark(#f6f7f8, #0b0e10);
    --hint: light-dark(rgb(0 0 0 / 0.4), rgb(255 255 255 / 0.3));
    --card-shadow: light-dark(rgb(10 14 20 / 0.32), rgb(0 0 0 / 0.85));
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The card's size: 16 px type at full size, less when the stage is shorter than 858 px (the card,
   the room around it and the hint) or narrower than the card and two 16 px gutters. */
.scene {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: min(16px, 100cqh / 53.625, (100cqw - 32px) / 27.625);
  perspective: 62.5em;
}

.card {
  position: relative;
  width: 27.625em;
  height: 39.25em;
  border-radius: 1.375em;
  background: linear-gradient(var(--card-top), var(--card-bottom));
  box-shadow:
    inset 0 0 0 1px var(--card-edge),
    0 0.625em 1.875em var(--card-shadow);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  transform-style: flat;
  will-change: transform;
  outline: none;
  isolation: isolate;
}
.card:focus-visible {
  box-shadow:
    inset 0 0 0 1px var(--card-edge),
    0 0 0 2px var(--stage),
    0 0 0 4px var(--focus),
    0 0.625em 1.875em var(--card-shadow);
}

/* The foil, under everything else on the card. */
.foil,
.fallback {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  pointer-events: none;
}
.foil {
  display: block;
}
.root[data-renderer='css'] .foil {
  display: none;
}

/* Without WebGL: a rainbow seen through rings, under a round light. */
.fallback {
  display: none;
  opacity: var(--light-on, 0);
  background: linear-gradient(
    140deg,
    hsl(calc(var(--foil-hue, 285deg) + 300deg) 62% 55%),
    hsl(calc(var(--foil-hue, 285deg) + 380deg) 62% 58%),
    hsl(calc(var(--foil-hue, 285deg) + 460deg) 62% 62%),
    hsl(calc(var(--foil-hue, 285deg) + 540deg) 62% 62%)
  );
  -webkit-mask:
    repeating-radial-gradient(
      circle at 66.7% 43.3%,
      #000 0 0.07em,
      rgb(0 0 0 / 0.25) 0.1em 0.45em,
      #000 0.53em
    ),
    radial-gradient(circle 22em at var(--light-x, 50%) var(--light-y, 50%), #000, transparent);
  -webkit-mask-composite: source-in;
  mask:
    repeating-radial-gradient(
      circle at 66.7% 43.3%,
      #000 0 0.07em,
      rgb(0 0 0 / 0.25) 0.1em 0.45em,
      #000 0.53em
    ),
    radial-gradient(circle 22em at var(--light-x, 50%) var(--light-y, 50%), #000, transparent);
  mask-composite: intersect;
}
.root[data-renderer='css'] .fallback {
  display: block;
}

.art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: none;
  stroke-width: 1px;
  pointer-events: none;
}
.outline {
  stroke: var(--outline);
  stroke-linejoin: miter;
  vector-effect: non-scaling-stroke;
}
.root[data-renderer='css'] .outline {
  fill: var(--card-top);
}
.marks {
  stroke: var(--mark);
  vector-effect: non-scaling-stroke;
}

.top {
  position: absolute;
  inset: 2.125em 2.390625em auto 2.1875em;
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
}
.brand {
  font-size: 1.625em;
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.015em;
}
.plus {
  width: 1.125em;
  height: 1.125em;
  margin-top: 0.296875em;
  fill: none;
  stroke: var(--ink-2);
  stroke-width: 1px;
  vector-effect: non-scaling-stroke;
  overflow: visible;
}
.plus path {
  vector-effect: non-scaling-stroke;
}

.titles {
  position: absolute;
  top: 29.921875em;
  left: 2.09375em;
  right: 2.09375em;
}
.title {
  margin: 0;
  font-size: 1.6875em;
  font-weight: 400;
  line-height: 1;
  letter-spacing: -0.015em;
}
.subtitle {
  margin: 0.64em 0 0 0.08em;
  font-size: 0.78125em;
  font-weight: 400;
  line-height: 1;
  color: var(--ink-2);
}

.footer {
  position: absolute;
  left: 3.4em;
  right: 3.45em;
  top: 57.65em;
  display: flex;
  justify-content: space-between;
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, Menlo, monospace;
  font-size: 0.625em;
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
}

/* The hint under the card, 84 px under it at full size. */
.caption {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(50% + 24.23em);
  margin: 0;
  text-align: center;
  pointer-events: none;
}
.caption span {
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, Menlo, monospace;
  font-size: 0.625em;
  line-height: 1;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--hint);
}

/* The demo's pointer: an arrow like the system's, its tip on the point. */
.demoPointer {
  position: absolute;
  left: 0;
  top: 0;
  width: 14px;
  height: 20px;
  margin: -1px 0 0 -1px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s;
  will-change: transform;
}
.demoPointer svg {
  display: block;
  width: 14px;
  height: 20px;
  overflow: visible;
}
.demoPointer path {
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.2px;
  stroke-linejoin: round;
}

@media (prefers-reduced-motion: reduce) {
  .card {
    will-change: auto;
  }
  .demoPointer {
    display: none;
  }
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

### holographic-card/renderer.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The foil of the holographic card: one WebGL2 fragment shader over the whole card, in the card's
 * own design units (442 × 628, y down), whatever size it is drawn at.
 *
 * - The card's ground: near-black, a shade darker toward the bottom. With no light, the shader
 *   draws only this, so the card at rest is plain.
 * - Contour lines: thin lines every 8.5 units along a height field, the smooth minimum of the
 *   distances to two points (one right of the triangle, one under it), bent by a few slow waves.
 *   So the lines ring those two points, merge, and run in long wavy bands to the edges.
 * - The colour is a rainbow laid diagonally across the card (green at the lower left, through
 *   blue, violet and pink, to gold at the upper right) that slides a little as the light moves
 *   across, as a foil's colours do when it tilts.
 * - The light: a soft round falloff about 360 units wide around the pointer, brightest in a small
 *   core, which turns the lines on in their colour and washes the ground between them. Far from it
 *   the lines stay barely visible while the light is on at all.
 * - The triangle: a 1-unit outline, an 8-unit band around it with no lines, and inside, a
 *   Sierpinski triangle walked level by level from the point's barycentric coordinates. The kept
 *   triangles light up in the foil's colour; the holes stay dark, with a soft shade under their top
 *   edge. Levels smaller than about 2.5 device px are drawn as their average, so they never shimmer.
 */

export const CARD = { width: 442, height: 628 } as const;

const VERTEX = `#version 300 es
in vec2 aPos;
out vec2 vUv;
void main() {
  vUv = aPos * 0.5 + 0.5;
  gl_Position = vec4(aPos, 0.0, 1.0);
}`;

const FRAGMENT = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;

uniform vec2 uSize;
uniform vec2 uLight;
uniform float uPresence;
uniform float uHue;
uniform float uGlare;
uniform float uDpr;

const vec3 GROUND_TOP = vec3(17.0, 19.0, 21.0) / 255.0;
const vec3 GROUND_BOTTOM = vec3(15.0, 17.0, 18.0) / 255.0;
// The triangle: its centroid, height and side, in card units.
const vec2 CENTROID = vec2(221.0, 312.917);
const float HEIGHT = 225.25;
const float SIDE = 260.1;
const float BAND = 8.0;
const float PERIOD = 8.5;

vec3 hsv(float h, float s, float v) {
  vec3 k = mod(vec3(5.0, 3.0, 1.0) + h / 60.0, 6.0);
  return v - v * s * clamp(min(k, 4.0 - k), 0.0, 1.0);
}

// Signed distance to the equilateral triangle, p from its centroid with y up.
float triangle(vec2 p) {
  const float k = 1.7320508;
  float r = SIDE * 0.5;
  p.x = abs(p.x) - r;
  p.y = p.y + r / k;
  if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0;
  p.x -= clamp(p.x, -2.0 * r, 0.0);
  return -length(p) * sign(p.y);
}

float field(vec2 q) {
  vec2 w = vec2(
    sin(q.y * 0.03 + 0.9) * 15.0 + sin(q.y * 0.008 + q.x * 0.012 + 2.0) * 6.0,
    sin(q.x * 0.024 + 1.7) * 6.0 + sin(q.x * 0.011 - q.y * 0.009) * 4.0
  );
  vec2 p = q + w;
  float a = length((p - vec2(295.0, 270.0)) * vec2(1.0, 0.9));
  float b = length((p - vec2(225.0, 398.0)) * vec2(1.0, 1.5));
  float k = 60.0;
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}

float hash(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}

float plusMark(vec2 q, vec2 at) {
  vec2 d = abs(q - at);
  float arm = 5.0, half_ = 0.5;
  float h = step(d.x, arm) * (1.0 - smoothstep(half_, half_ + 1.0 / uDpr, d.y));
  float v = step(d.y, arm) * (1.0 - smoothstep(half_, half_ + 1.0 / uDpr, d.x));
  return max(h, v);
}

void main() {
  vec2 q = vec2(vUv.x, 1.0 - vUv.y) * uSize;
  vec3 color = mix(GROUND_TOP, GROUND_BOTTOM, q.y / uSize.y);

  float d = length(q - uLight);
  float light = uPresence * (0.45 * exp(-pow(d / 60.0, 2.0)) + 0.55 * (1.0 - smoothstep(30.0, 360.0, d)));

  float hue = uHue + 0.75 * dot(q, vec2(0.669, -0.743));
  vec3 foil = hsv(hue, 0.58, 1.0);
  foil = mix(foil, vec3(1.0, 0.9, 0.78), (0.15 + 0.6 * uGlare) * exp(-pow(d / 95.0, 2.0)));

  vec2 p = q - CENTROID;
  p.y = -p.y;
  float sd = triangle(p);
  float px = 1.0 / uDpr;

  // The lines, outside the band.
  float t = field(q) / PERIOD;
  float fw = max(fwidth(t), 1e-4);
  float dd = abs(fract(t + 0.5) - 0.5) / fw;
  float core = clamp(0.5 * 1.15 * uDpr + 0.5 - dd, 0.0, 1.0);
  float glow = exp(-pow(dd / (2.4 * uDpr), 2.0));
  vec3 lines = foil * (core * (0.025 * uPresence + 0.78 * light) + glow * 0.16 * light + 0.32 * light);
  float grain = step(0.997, hash(floor(q * uDpr))) * 0.35 * light;
  lines += foil * grain;

  // The band and its edges.
  vec3 band = foil * 0.05 * light;
  float rim = 1.0 - smoothstep(0.0, 1.2 * px + 0.5, abs(sd - BAND));
  band += vec3(0.75) * rim * 0.22 * light;
  float stroke = 1.0 - smoothstep(0.0, 1.0 * px + 0.5, abs(sd));

  // The fractal.
  vec2 bc = vec2(0.0, (p.y + HEIGHT / 3.0) / HEIGHT);
  bc.x = (p.x + SIDE * 0.5) / SIDE - bc.y * 0.5;
  float level = HEIGHT;
  float kept = 1.0;
  float shade = 0.0;
  for (int i = 0; i < 9; i++) {
    if (level * uDpr < 5.0) { kept = 0.62; break; }
    bc *= 2.0;
    level *= 0.5;
    if (bc.x >= 1.0) bc.x -= 1.0;
    else if (bc.y >= 1.0) bc.y -= 1.0;
    else if (bc.x + bc.y >= 1.0) {
      vec3 edges = vec3(1.0 - bc.x, 1.0 - bc.y, bc.x + bc.y - 1.0) * level;
      float nearest = min(edges.x, min(edges.y, edges.z));
      float inside = smoothstep(0.0, 1.1 * px, nearest);
      kept = 1.0 - inside;
      shade = inside * (0.1 + 0.05 * smoothstep(0.0, 0.3 * level, min(edges.x, edges.z))
        - 0.07 * (1.0 - smoothstep(0.0, 0.12 * level, edges.y)));
      break;
    }
  }
  vec3 fractal = foil * light * (kept * 1.0 + shade);

  float outer = smoothstep(BAND - 0.5 * px, BAND + 0.5 * px, sd);
  float inner = smoothstep(-0.5 * px, 0.5 * px, sd);
  color += mix(mix(fractal, band, inner), lines, outer);
  color += mix(foil, vec3(1.0), 0.4) * stroke * 0.45 * light;

  float marks = plusMark(q, vec2(51.0, 185.0)) + plusMark(q, vec2(390.0, 185.0))
    + plusMark(q, vec2(51.0, 440.0)) + plusMark(q, vec2(390.0, 440.0));
  color += vec3(0.85) * min(marks, 1.0) * 0.32 * light;

  outColor = vec4(color, 1.0);
}`;

export type FoilFrame = {
  /** The light, in card units. */
  x: number;
  y: number;
  /** How much the light is on, 0 to 1. */
  presence: number;
  /** Where the rainbow starts, in degrees of hue. */
  hue: number;
  /** How much the light's core turns to a warm white glare, 0 to 1. */
  glare: number;
};

export type Foil = {
  /** Sizes the drawing buffer to the canvas's box at `ratio` device px per CSS px. */
  resize: (ratio: number) => void;
  draw: (frame: FoilFrame) => void;
  dispose: () => void;
};

/** A foil on `canvas`, or null where WebGL2 is missing or the shader fails. */
export function createFoil(canvas: HTMLCanvasElement): Foil | null {
  const gl = canvas.getContext('webgl2', {
    antialias: false,
    alpha: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
    powerPreference: 'low-power',
  });
  if (!gl) return null;

  const compile = (type: number, source: string) => {
    const shader = gl.createShader(type);
    if (!shader) return null;
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
      gl.deleteShader(shader);
      return null;
    }
    return shader;
  };
  const vertex = compile(gl.VERTEX_SHADER, VERTEX);
  const fragment = compile(gl.FRAGMENT_SHADER, FRAGMENT);
  const program = gl.createProgram();
  if (!vertex || !fragment || !program) return null;
  gl.attachShader(program, vertex);
  gl.attachShader(program, fragment);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
  gl.useProgram(program);

  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  const position = gl.getAttribLocation(program, 'aPos');
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);

  const at = (name: string) => gl.getUniformLocation(program, name);
  const uSize = at('uSize');
  const uLight = at('uLight');
  const uPresence = at('uPresence');
  const uHue = at('uHue');
  const uGlare = at('uGlare');
  const uDpr = at('uDpr');
  gl.uniform2f(uSize, CARD.width, CARD.height);

  let unitsToDevice = 1;

  return {
    resize(ratio) {
      const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
      const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
      }
      gl.viewport(0, 0, width, height);
      unitsToDevice = width / CARD.width;
    },
    draw({ x, y, presence, hue, glare }) {
      gl.uniform2f(uLight, x, y);
      gl.uniform1f(uPresence, presence);
      gl.uniform1f(uHue, hue);
      gl.uniform1f(uGlare, glare);
      gl.uniform1f(uDpr, unitsToDevice);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    },
    dispose() {
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
      gl.deleteShader(vertex);
      gl.deleteShader(fragment);
    },
  };
}
```

### holographic-card/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```