# Holo card

> A holographic Charizard card: drag to tilt it in 3D, with the character on its own layer and a reflection that slides the other way.

A collectible card with its printed $329 sticker, on a plain stage. Drag anywhere on the stage: the card turns around the drag’s axis, Charizard moves on a layer of its own over the foil, and a blurred white reflection travels the opposite way. Let go and every layer springs back to rest.

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

Category: Motion.

## Anatomy

1. **Stage**: Takes the drag anywhere on it, not only on the card. A sideways drag tilts; a drag that starts up or down scrolls the page (`touch-action: pan-y`).
2. **Card**: Turns in 3D by up to `maxTilt` degrees around the axis across the drag: the side dragged towards sinks. It takes the focus, and held arrow keys push it.
3. **Shadow**: Cast on the stage by the card’s place, which never turns, so it is painted once. By day it lifts the card off the grey; the black night stage needs none.
4. **Print**: The printed card, cut to the card’s rounded corners. Its art window is where the character stands.
5. **Light**: A white bar blurred into a soft glow, sliding against the drag by two thirds of it, under the character.
6. **Character**: A cutout over the art window that drifts a thirtieth of the drag the same way, so it seems to float in front of the card.

## When to use

- A hero object worth handling: a collectible, a membership card, a ticket or a reward the reader should want to pick up.
- Product pages where the object is the product, and turning it in the light says more than a second photo.
- A moment of delight on a page seen once or rarely: an unlock, a drop, a reveal.
- Art cut in two layers, a print and a figure, so the figure can float in front of the card.

## When not to use

- Cards that carry actions or text to read. Tilting text makes it harder to read; use a plain card.
- Grids of many items. Each card wants its own stage and a hand on it; use a static thumbnail and open one card at a time.
- Anything the reader has to understand without moving it: under reduced motion the card stays flat, so say it all in the art and the label.
- A carousel or a swipeable list: sideways drags tilt the card here, so they can’t also change the slide.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/holo-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.

Copy these media files to the same paths under your public folder:

- https://brunodelorence.com/experiments/holo-card/cardbg.webp
- https://brunodelorence.com/experiments/holo-card/charizard.webp

## Usage

```tsx
import { HoloCard } from './holo-card';

// Copy cardbg.webp and charizard.webp to public/experiments/holo-card.
// Disable the automatic demonstration so the reader controls the card.
export function CardViewer() {
  return <HoloCard autoplay={false} maxTilt={10} />;
}
```

## States

- **At rest**: Flat, the light across the middle, the stage in the page’s scheme. Under reduced motion it always stays like this.
- **Dragged right**: The right side sinks, the character drifts right and the light slides left.
- **Dragged up and left**: The turn’s axis is always at right angles to the drag, so diagonals tilt a corner.
- **Steep**: With `maxTilt={30}`, the most it allows: the card turns three times as far.
- **No turn**: With `maxTilt={0}` the card stays flat, but the character and the light still move.
- **Light**: With `colorScheme="light"`: a soft grey stage the card casts its shadow on.
- **Dark**: With `colorScheme="dark"` the stage is black and casts no shadow; the card is the same.

## How it works

- Everything follows one value: the pointer’s offset from where it pressed, in the card’s own pixels, so it stays right inside a scaled container such as a gallery tile. A spring carries it, stiffer while held (420 / 34) than on release (180 / 20), and the frame loop runs only while something moves.
- The card turns with rotate3d around the axis perpendicular to the drag, and reaches maxTilt once the drag covers a tenth of the card’s width, so the side you drag towards sinks.
- The character is an image layer of its own and moves by the drag ÷ 30. The light is a blurred white bar clipped to the card’s face and laid under the character, so it passes behind it, moving by −drag ÷ 1.5.
- The whole stage takes the drag (touch-action: pan-y; a touch takes hold after 4 px), and a held arrow key pushes 38% of the card’s width. The demo drives the same code with a ghost pointer.
- The card is sized in container units, min(316px, 78cqi, 66cqh), on a 500 px stage: a 316 × 417 card. Only the card, the character and the light are moving layers, about 4 MB of GPU memory at 3× density.
- The stage, the card’s shadow and the focus ring are light-dark() tokens on the root, so it follows the page’s color-scheme, or the colorScheme prop. By day the card lies on a soft grey stage and casts a still shadow under the turning card; at night the stage is black. The card, its character and its light are the same on both.

## API reference

- **assetPath** (`string`, default `'/experiments/holo-card'`): Public folder holding cardbg.webp and charizard.webp; a trailing slash is fine.
- **maxTilt** (`number`, default `10`): The most the card turns, in degrees. Clamped between 0 and 30.
- **defaultTilt** (`{ x: number; y: number }`): Opens held at a drag, in shares of the card’s width (0.1 turns it all the way), until the reader drags or uses the keys. Turns the demo off.
- **autoplay** (`boolean`, default `true`): Play the ghost-pointer demo while the card is on screen and untouched; false removes the demo and its pointer.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, the stage, for example to change its height.

## Accessibility

- The card is a focusable image named for what it shows (“Charizard card with a $329.00 sticker, tilts in 3D”); a hint linked by aria-describedby explains dragging and the arrow keys. Keyboard focus shows a high-contrast ring, dark by day and white at night.
- Held arrow keys tilt the card and combine for diagonals; it springs back on key-up or when focus leaves. Arrows pressed with Alt, Ctrl or Cmd are left to the browser.
- A vertical swipe still scrolls the page. The reader’s first press or key stops the demo until 6 s without input.
- Under reduced motion nothing moves: no tilt, parallax or demo, and the arrow keys scroll as usual. Without JavaScript the whole card is rendered by the server.

## Source

### holo-card/holo-card.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './holo-card.module.css';

/*
 * Holo card: a printed trading card on a plain stage. Drag anywhere on the stage and the
 * card turns in 3D around the axis across the drag, while the character, a cutout of its own over
 * the printed art, drifts a little the same way, so it seems to float in front of the card, and a
 * soft white light slides the other way across the card. Let go and it all springs back.
 *
 * - Everything follows one value, the drag: how far the pointer is from where it pressed, in the
 *   card's own px. A spring integrated frame by frame carries it: stiff while held, so the card
 *   keeps up with the pointer, and looser once let go, so it settles with a small dip past flat.
 * - The card turns by up to `maxTilt` degrees, all of it once the drag is a tenth of the card's
 *   width, around the axis at right angles to the drag: the side dragged towards sinks. The
 *   character moves by a thirtieth of the drag, and the light by two thirds of it, backwards.
 * - Only the card, the character and the light move, each a layer of a fixed size moved by its
 *   transform, so nothing is painted again while they move. Each frame writes three transforms,
 *   and nothing runs at rest.
 * - The card takes the keyboard focus, and the arrow keys push it while they are held.
 * - Light or dark: the stage, the card's shadow on it and the focus ring are light-dark() tokens
 *   in the CSS, so the scheme is the page's color-scheme, or the one the colorScheme prop sets: a
 *   soft grey stage the card casts a shadow on, or a black one. The card is the same on both.
 */

/** The springs, as [stiffness, damping] per second: while held, and on the way back. */
const HOLD = [420, 34] as const;
const RELEASE = [180, 20] as const;
/**
 * How far a touch moves before the card holds on to it, in px. A swipe that starts up or down
 * scrolls the page instead (touch-action: pan-y).
 */
const DRAG_START = 4;
/** The drag, as a share of the card's width, that turns the card all the way. */
const FULL_TURN = 0.1;
/** How far the character and the light move for each px of drag; the light goes against it. */
const FIGURE_SHIFT = 1 / 30;
const GLARE_SHIFT = -1 / 1.5;
/** How far a held arrow key pushes the card, as a share of its width. */
const KEY_PUSH = 0.38;
/** The most `maxTilt` may ask for, in degrees. */
const TILT_CAP = 30;

/** The drag and its speed, in the card's px, and where it goes; `held` while a hand holds it. */
type Motion = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  toX: number;
  toY: number;
  held: boolean;
  time: number;
  /** The card's width, in its own px, as last measured. */
  width: number;
};

type Press = {
  pointerId: number;
  startX: number;
  startY: number;
  /** Screen px for each of the card's px: the stage may sit in a scaled container. */
  scale: number;
  /** Past DRAG_START, or a mouse: the card holds on to the pointer. */
  tilting: boolean;
  /** The demo's press. */
  demo: boolean;
};

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));

/** The demo's strokes: points on the card, as shares of its width and height. */
const STROKES: readonly Point[][] = [
  [
    { x: 0.25, y: 0.35 },
    { x: 0.75, y: 0.3 },
    { x: 0.7, y: 0.7 },
    { x: 0.3, y: 0.65 },
  ],
  [
    { x: 0.5, y: 0.25 },
    { x: 0.5, y: 0.75 },
  ],
];

export function HoloCard({
  maxTilt = 10,
  assetPath = '/experiments/holo-card',
  autoplay = true,
  colorScheme,
  defaultTilt,
  className = '',
}: {
  /** The most the card turns, in degrees, from 0 to 30. */
  maxTilt?: number;
  /** The public folder that holds cardbg.webp, the printed card, and charizard.webp, its cutout. */
  assetPath?: string;
  /** While on screen and untouched, a demo pointer drags the card now and then. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Opens held at a drag, as shares of the card's width (0.1 turns it all the way; positive x is
   * to the right, positive y down), and stays there until the reader drags or uses the keys.
   * Turns the demo off.
   */
  defaultTilt?: { x: number; y: number };
  /** Extra class for the root, the stage. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const hintId = useId();
  const holder = useRef<HTMLDivElement>(null);
  const card = useRef<HTMLDivElement>(null);
  const figure = useRef<HTMLImageElement>(null);
  const glare = useRef<HTMLSpanElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const press = useRef<Press | null>(null);
  const keys = useRef(new Set<string>());
  const frame = useRef(0);
  const motion = useRef<Motion>({
    x: 0,
    y: 0,
    vx: 0,
    vy: 0,
    toX: 0,
    toY: 0,
    held: false,
    time: 0,
    width: 0,
  });
  /** The drag defaultTilt holds, as shares of the card's width, until the reader takes it. */
  const resting = useRef(defaultTilt);
  const limit = useRef(clamp(maxTilt, 0, TILT_CAP));
  useEffect(() => {
    limit.current = clamp(maxTilt, 0, TILT_CAP);
  }, [maxTilt]);

  /** Writes the card's turn, and where the character and the light are, from the drag. */
  const draw = () => {
    const { x, y, width } = motion.current;
    const length = Math.hypot(x, y);
    // At rest, the stylesheet's places: the same as the server's first frame.
    const still = length < 0.001;
    if (card.current) {
      const turn = clamp(length / Math.max(1, width * FULL_TURN), 0, 1) * limit.current;
      card.current.style.transform = still
        ? ''
        : `rotate3d(${(-y / length).toFixed(4)}, ${(x / length).toFixed(4)}, 0, ${turn.toFixed(3)}deg)`;
    }
    if (figure.current) {
      figure.current.style.transform = still
        ? ''
        : `translate(${(x * FIGURE_SHIFT).toFixed(3)}px, ${(y * FIGURE_SHIFT).toFixed(3)}px)`;
    }
    if (glare.current) {
      glare.current.style.transform = still
        ? ''
        : `translate(${(x * GLARE_SHIFT).toFixed(2)}px, ${(y * GLARE_SHIFT).toFixed(2)}px)`;
    }
  };

  /** Runs the spring frame by frame until the card is at rest. */
  const animate = () => {
    if (frame.current) return;
    const m = motion.current;
    m.time = performance.now();
    const tick = (now: number) => {
      // Two half steps per frame, never more than two frames' worth after a stall.
      const dt = Math.min(0.034, Math.max(0, (now - m.time) / 1000)) / 2;
      m.time = now;
      const [k, c] = m.held ? HOLD : RELEASE;
      for (let i = 0; i < 2; i += 1) {
        m.vx += (k * (m.toX - m.x) - c * m.vx) * dt;
        m.vy += (k * (m.toY - m.y) - c * m.vy) * dt;
        m.x += m.vx * dt;
        m.y += m.vy * dt;
      }
      const rest =
        Math.abs(m.toX - m.x) < 0.01 &&
        Math.abs(m.toY - m.y) < 0.01 &&
        Math.abs(m.vx) < 0.05 &&
        Math.abs(m.vy) < 0.05;
      if (rest) Object.assign(m, { x: m.toX, y: m.toY, vx: 0, vy: 0 });
      draw();
      frame.current = rest ? 0 : requestAnimationFrame(tick);
    };
    frame.current = requestAnimationFrame(tick);
  };

  /** Sends the drag towards `toX`, `toY`, in the card's px. Under reduced motion, nothing moves. */
  const aim = (toX: number, toY: number, held: boolean) => {
    const m = motion.current;
    resting.current = undefined;
    if (reducedMotion()) {
      cancelAnimationFrame(frame.current);
      frame.current = 0;
      Object.assign(m, { x: 0, y: 0, vx: 0, vy: 0, toX: 0, toY: 0, held: false });
      draw();
      return;
    }
    m.toX = toX;
    m.toY = toY;
    m.held = held;
    animate();
  };

  /** Reads the card's width, in its own px; returns how many screen px make one of them. */
  const measure = () => {
    const box = holder.current;
    if (!box) return 1;
    motion.current.width = box.offsetWidth;
    return box.getBoundingClientRect().width / (box.offsetWidth || 1) || 1;
  };

  /** A point on the screen as a drag: its offset from where `current` pressed, in the card's px. */
  const follow = (current: Press, clientX: number, clientY: number) =>
    aim(
      (clientX - current.startX) / current.scale,
      (clientY - current.startY) / current.scale,
      true,
    );

  const letGo = () => {
    press.current = null;
    aim(0, 0, false);
  };

  // The demo's hand: the same drag as a pointer's, without taking the reader's turn.
  const demoHand = {
    begin: (point: Point) => {
      if (press.current || !holder.current) return;
      press.current = {
        pointerId: -1,
        startX: point.x,
        startY: point.y,
        scale: measure(),
        tilting: true,
        demo: true,
      };
      aim(0, 0, true);
    },
    move: (point: Point) => {
      if (press.current?.demo) follow(press.current, point.x, point.y);
    },
    end: () => {
      if (press.current?.demo) letGo();
    },
  };

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || reducedMotion()) return;
    const mouse = event.pointerType === 'mouse';
    press.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      scale: measure(),
      tilting: mouse,
      demo: false,
    };
    if (mouse) event.currentTarget.setPointerCapture(event.pointerId);
    aim(0, 0, true);
  };

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const current = press.current;
    if (!current || current.pointerId !== event.pointerId) return;
    if (!current.tilting) {
      const dx = event.clientX - current.startX;
      const dy = event.clientY - current.startY;
      if (Math.hypot(dx, dy) < DRAG_START) return;
      current.tilting = true;
      event.currentTarget.setPointerCapture(event.pointerId);
    }
    follow(current, event.clientX, event.clientY);
  };

  const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
    if (press.current?.pointerId === event.pointerId) letGo();
  };

  // Arrow keys push the card while they are held, and combine for diagonals; it comes back flat
  // when they are let go.
  const pushByKeys = () => {
    const held = keys.current;
    const across = (held.has('ArrowRight') ? 1 : 0) - (held.has('ArrowLeft') ? 1 : 0);
    const down = (held.has('ArrowDown') ? 1 : 0) - (held.has('ArrowUp') ? 1 : 0);
    const push = KEY_PUSH * motion.current.width;
    aim(across * push, down * push, held.size > 0);
  };
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!event.key.startsWith('Arrow') || event.altKey || event.ctrlKey || event.metaKey) return;
    if (reducedMotion()) return;
    event.preventDefault();
    if (keys.current.has(event.key)) return;
    keys.current.add(event.key);
    measure();
    pushByKeys();
  };
  const onKeyUp = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!keys.current.delete(event.key)) return;
    pushByKeys();
  };
  const onBlur = () => {
    if (!keys.current.size) return;
    keys.current.clear();
    pushByKeys();
  };

  // defaultTilt: the card is drawn held at that drag, kept in step with the card's width, until
  // the first drag or key.
  useLayoutEffect(() => {
    const box = holder.current;
    if (!box || !resting.current) return;
    const hold = () => {
      const tilt = resting.current;
      if (!tilt) return;
      measure();
      const m = motion.current;
      const x = tilt.x * m.width;
      const y = tilt.y * m.width;
      Object.assign(m, { x, y, toX: x, toY: y, vx: 0, vy: 0, held: false });
      draw();
    };
    hold();
    const observer = new ResizeObserver(hold);
    observer.observe(box);
    return () => observer.disconnect();
    // Read once, on mount: the reader's drag takes over from it.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(
    () => () => {
      cancelAnimationFrame(frame.current);
      frame.current = 0;
    },
    [],
  );

  // The demo: while on screen and untouched, the pointer takes the card now and then and drags
  // across it.
  const demo = autoplay && !defaultTilt && playing;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const box = holder.current;
    if (!demo || !stage || !arrow || !box) return;
    /** A point on the card, from shares of its size, on the screen. */
    const on = ({ x, y }: Point): Point => {
      const rect = box.getBoundingClientRect();
      return { x: rect.left + rect.width * x, y: rect.top + rect.height * y };
    };
    const stop = runDemo(stage, arrow, async ({ wait, moveTo, press: down, drag, lift }) => {
      await wait(700);
      for (let turn = 0; ; turn += 1) {
        const [start, ...path] = STROKES[turn % STROKES.length];
        await moveTo(on(start));
        await wait(160);
        await down(() => demoHand.begin(on(start)));
        await drag(path.map(on), 820, (point) => demoHand.move(point));
        await wait(220);
        await lift(() => demoHand.end());
        // Off the card, to its lower right, and a rest while it settles.
        await moveTo(on({ x: 1.12, y: 0.86 }));
        await wait(1500);
      }
    });
    return () => {
      stop();
      demoHand.end();
    };
    // The demo's hand reads the card through refs.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  const folder = assetPath.replace(/\/+$/, '');

  return (
    <div
      ref={root}
      className={className ? `${styles.stage} ${className}` : styles.stage}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerEnd}
      onPointerCancel={onPointerEnd}
      onLostPointerCapture={(event) => {
        const current = press.current;
        if (event.target === event.currentTarget && current?.pointerId === event.pointerId) {
          letGo();
        }
      }}
    >
      <div className={styles.scene}>
        <div ref={holder} className={styles.holder} data-part="holder">
          <div
            ref={card}
            className={styles.card}
            data-part="card"
            role="img"
            aria-label="Charizard card with a $329.00 sticker, tilts in 3D"
            aria-describedby={hintId}
            tabIndex={0}
            onKeyDown={onKeyDown}
            onKeyUp={onKeyUp}
            onBlur={onBlur}
          >
            <div className={styles.face} data-part="face">
              <img
                className={styles.print}
                data-part="print"
                src={`${folder}/cardbg.webp`}
                alt=""
                width={1264}
                height={1668}
                draggable={false}
              />
              <span ref={glare} className={styles.glare} data-part="glare" />
            </div>
            <img
              ref={figure}
              className={styles.figure}
              data-part="figure"
              src={`${folder}/charizard.webp`}
              alt=""
              width={768}
              height={711}
              draggable={false}
            />
          </div>
        </div>
      </div>
      <p id={hintId} className={styles.hidden}>
        Drag anywhere on the stage to tilt the card, or hold the arrow keys.
      </p>
      {autoplay && !defaultTilt && (
        <div className={styles.pointerLayer} aria-hidden="true">
          <DemoPointer ref={pointer} className={styles.pointer} />
        </div>
      )}
    </div>
  );
}
```

### holo-card/holo-card.module.css

```css
/* Holo card (holo-card.tsx): the printed card in the middle of a plain stage. The card's width,
   --w, follows the stage's size; everything on the card is placed in shares of its box.

   Memory. A phone keeps a page's pixels in tiles the width of the screen, and a tile painted again
   gets a second buffer. So what moves stays off the page's tiles: the card is a layer of its own,
   and inside it only the light and the character are layers, of a fixed size, moved by their
   transforms. The stage is a size container of a set height, so nothing inside it ever lays out
   the page.

   Colours are the tokens below, light-dark(light, dark): the stage takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). The card, its character and its light are printed matter, the same on
   both. */

.stage {
  /* The stage, and its edge: soft grey with a hairline by day, plain black at night. */
  --surface: #ededf0;
  --line: rgb(0 0 0 / 0.05);
  /* The card's shadow on the stage, close and far: by day it lifts the card off the grey; at
     night the black stage needs none. */
  --shadow-near: rgb(0 0 0 / 0.14);
  --shadow: rgb(0 0 0 / 0.24);
  /* The keyboard focus ring round the card. */
  --focus: #1c1c1e;
  position: relative;
  container-type: size;
  height: 500px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  /* The whole stage takes the drag. Sideways it tilts the card; up or down first, the page
     scrolls. */
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#ededf0, #000);
    --line: light-dark(rgb(0 0 0 / 0.05), transparent);
    --shadow-near: light-dark(rgb(0 0 0 / 0.14), transparent);
    --shadow: light-dark(rgb(0 0 0 / 0.24), transparent);
    --focus: light-dark(#1c1c1e, #fff);
  }
}
.stage:active {
  cursor: grabbing;
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}

.scene {
  --w: min(316px, 78cqi, 66cqh);
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  perspective: 1100px;
}

/* The card's place, which does not tilt: its size is measured on it. */
.holder {
  position: relative;
  width: var(--w);
  aspect-ratio: 316 / 417;
}
/* The card's shadow, cast by the printed card's own outline (the print has a clear margin down
   each side). It stays on the stage while the card turns above it, so it is painted once; it is
   filled too, so the edge a turn lifts away uncovers shadow, not bare stage. */
.holder::before {
  content: '';
  position: absolute;
  inset: 0 1.03% 0 1.11%;
  border-radius: calc(var(--w) * 0.05);
  background: var(--shadow-near);
  box-shadow:
    0 1px 2px var(--shadow-near),
    0 calc(var(--w) * 0.05) calc(var(--w) * 0.1) calc(var(--w) * -0.03) var(--shadow);
}

.card {
  position: absolute;
  inset: 0;
  border-radius: calc(var(--w) * 0.05);
  outline: none;
  cursor: grab;
  will-change: transform;
}
.stage:active .card {
  cursor: grabbing;
}
.card:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 6px;
}

/* The printed card, and the light that slides over it, cut to the card's rounded corners. */
.face {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
}

.print {
  display: block;
  width: 100%;
  max-width: none;
  height: 100%;
  -webkit-user-drag: none;
}

/* The light: a white bar across the card's middle, blurred into a soft glow. It moves against
   the drag, under the character, so the character stays crisp while the light passes behind. */
.glare {
  position: absolute;
  top: 44%;
  left: 2.53%;
  width: 94.94%;
  height: 11.99%;
  background: #fff;
  filter: blur(calc(var(--w) * 0.15823));
  pointer-events: none;
  will-change: transform;
}

/* The character, cut out over the art window. It drifts a little with the drag, so it reads as
   floating in front of the card. */
.figure {
  position: absolute;
  top: 14.03%;
  left: 29%;
  display: block;
  width: 54.675%;
  max-width: none;
  height: auto;
  pointer-events: none;
  -webkit-user-drag: none;
  will-change: transform;
}

/* Under reduced motion nothing moves, so nothing needs a layer of its own. */
@media (prefers-reduced-motion: reduce) {
  .card,
  .glare,
  .figure {
    will-change: auto;
  }
}

/* The demo's pointer, over everything, in a box that never needs a layout. */
.pointerLayer {
  position: absolute;
  z-index: 10;
  inset: 0;
  contain: strict;
  pointer-events: none;
}
.pointer [data-glyph='hand'],
.pointer[data-grab] [data-glyph='arrow'] {
  visibility: hidden;
}
.pointer[data-grab] [data-glyph='hand'] {
  visibility: visible;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

### holo-card/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer somewhere, press, drag it along a path, let go, calling the component's own
 * handlers as a hand would. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Nothing the pointer does needs a layout. It moves by animations the compositor plays (a drag
 * too: the point handed to the component each frame follows that animation's clock); the press
 * scales a box that keeps a transform at rest, since a box that gains or loses one is laid out
 * again; and the arrow and the hand are HTML boxes that swap by their visibility (a change of
 * style inside an SVG lays the SVG out again).
 */

export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to a point of the screen, in client px. */
  moveTo: (point: Point) => Promise<void>;
  /** Presses and holds; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /**
   * Drags through `points` of the screen (client px), `ms` for each leg, eased in at the start
   * and out at the end. `step` gets the pointer's place, in client px, on every frame.
   */
  drag: (points: Point[], ms: number, step: (point: Point) => void) => Promise<void>;
  /** Lets go; `action` runs as the pointer comes up. */
  lift: (action: () => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, in its 20 × 20 box: the point the pointer acts on. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  let dip: Animation | null = null;
  let frame = 0;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.82, y: stage.offsetHeight * 0.92 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  /** The stage's place and scale on the screen (it may sit in a scaled container). */
  const frameOf = () => {
    const box = stage.getBoundingClientRect();
    return { box, scale: box.width / stage.offsetWidth || 1 };
  };
  const toStage = (point: Point): Point => {
    const { box, scale } = frameOf();
    return { x: (point.x - box.left) / scale, y: (point.y - box.top) / scale };
  };

  const moveTo: Demo['moveTo'] = (point) => {
    const from = at;
    const to = toStage(point);
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    pointer.dataset.grab = '';
    // The hand closes a little towards its middle.
    dip =
      pointer.firstElementChild?.animate(
        [{ transform: 'scale(1)' }, { transform: 'scale(0.88)' }],
        {
          duration: 120,
          easing: 'ease-out',
          fill: 'forwards',
        },
      ) ?? null;
    action();
    await wait(140);
  };

  const lift: Demo['lift'] = async (action) => {
    action();
    dip?.reverse();
    delete pointer.dataset.grab;
    await wait(120);
  };

  const drag: Demo['drag'] = (points, ms, onStep) => {
    const path = [at, ...points.map(toStage)];
    at = path[path.length - 1];
    // The way along the path, eased at both ends, as one animation; the component reads the same
    // clock every frame.
    const legs = path.length - 1;
    const total = ms * legs;
    const pointAt = (share: number): Point => {
      const along = easeInOut(share) * legs;
      const leg = Math.min(legs - 1, Math.floor(along));
      const t = along - leg;
      const [a, b] = [path[leg], path[leg + 1]];
      return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
    };
    const keyframes: Keyframe[] = [];
    const samples = 12 * legs;
    for (let i = 0; i <= samples; i += 1)
      keyframes.push({ transform: place(pointAt(i / samples)) });
    flight?.cancel();
    pointer.style.transform = place(at);
    const motion = pointer.animate(keyframes, { duration: total, easing: 'linear' });
    flight = motion;
    return step(
      new Promise<void>((resolve) => {
        const tick = () => {
          const time = motion.currentTime;
          // Clamped at both ends; a finished animation is the end of the way.
          const share =
            motion.playState === 'finished'
              ? 1
              : clamp(typeof time === 'number' ? time / total : 0);
          const { box, scale } = frameOf();
          const point = pointAt(share);
          onStep({ x: box.left + point.x * scale, y: box.top + point.y * scale });
          if (share < 1) frame = requestAnimationFrame(tick);
          else {
            frame = 0;
            resolve();
          }
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press, drag, lift }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    cancelAnimationFrame(frame);
    flight?.cancel();
    dip?.cancel();
    pending.forEach((cancel) => cancel());
    delete pointer.dataset.grab;
    pointer.style.opacity = '0';
  };
}

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.35))',
  willChange: 'transform',
};

/** Inside it, what closes when the pointer presses, on a layer of its own. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x * 1.1}px ${TIP.y * 1.1}px`,
  willChange: 'transform',
};

/** One glyph, over the other: the 20 × 20 drawing at 22 px. */
const GLYPH: CSSProperties = { position: 'absolute', top: 0, left: 0, display: 'block' };
const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/**
 * The pointer the demo moves around: an arrow, or a closed hand while it holds the card. It is
 * decorative: the demo's effects show on the card itself.
 */
export function DemoPointer({ ref, className }: { ref: Ref<HTMLSpanElement>; className?: string }) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <span data-glyph="arrow" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            <path
              d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
              fill="#1c1c1e"
              stroke="#fff"
              strokeWidth="1.3"
              strokeLinejoin="round"
            />
          </svg>
        </span>
        <span data-glyph="hand" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            {/* The hand's middle sits on the arrow's tip. */}
            <g transform="translate(-6.6 -9.4)">
              <path
                d="M6.1 16.6c-1.2-1.2-1.8-2.6-1.8-4.3v-1.6c0-.7.5-1.2 1.1-1.2.2 0 .4 0 .6.1V8.1c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v-.5c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v.3c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v.7c0-.5.5-1 1-1 .6 0 1.1.5 1.1 1.1v3.7c0 1.9-.8 3.2-1.8 4.1z"
                fill="#fff"
                stroke="#1c1c1e"
                strokeWidth="1.1"
                strokeLinejoin="round"
              />
              <path
                d="M8.2 8.1v1.7M10.4 7.9v1.9M12.6 8.6v1.5"
                fill="none"
                stroke="#1c1c1e"
                strokeWidth="0.9"
                strokeLinecap="round"
              />
            </g>
          </svg>
        </span>
      </span>
    </span>
  );
}
```

### holo-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 };
}
```