# Object cards

> Product cards whose pictures are glass objects turning in 3D, drawn by one shared WebGL context.

Two product cards on a stage, each with a deep blue glass object in its picture: a disc with an embossed mark and a torus knot. They turn on their own at the pace the card’s range sets. Drag either one to turn it, then let go and it coasts and settles back into its spin; Copy Link copies the card’s link and confirms it for a moment.

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

Category: Media.

## Anatomy

1. **Card**: As wide as its box and 254 by 336 in proportion; every size inside is a share of its width. `tone` makes it black or white, charcoal at night.
2. **Picture**: A glass disc or torus knot drawn with WebGL, turning at the range’s speed. Drag it or use the arrow keys to turn it; an SVG stands in until WebGL draws.
3. **Title**: Two balanced lines at most, in the card’s ink.
4. **Open arrow**: A small square link in the accent, labelled “Open” plus the title for screen readers.
5. **Description**: A short monospaced paragraph in the muted ink, two or three lines.
6. **Range**: Three radio squares, 1D, 7D and 1M, on a track; the white square slides to the chosen one in 320 ms and the spin eases to slow, medium or fast.
7. **Copy Link**: Copies the link and reads Copied for 1.8 s, in the same width.
8. **Share**: Opens the system share sheet where there is one, and copies the link where not.

## When to use

- A product or feature tile on a landing page, where a turning object says more than a photo.
- Two to four cards side by side, one object each, alternating `tone` so neighbours stand apart.
- A card whose figures change with a time range: `onRangeChange` swaps the description, the object’s speed follows.
- A link worth sharing: the arrow opens it, Copy Link and Share hand it on without leaving the page.

## When not to use

- A long grid or a feed of many cards. Each one draws WebGL every frame; use a plain card with an image.
- Content that needs a real picture of the thing: a product photo, a screenshot, a person.
- Cards with more than a title and a short paragraph. The card keeps fixed proportions, so long text overflows.
- A range that filters real data on its own. Here it only sets the spin and reports the choice; wire the data yourself, or use a segmented control.
- Pages where motion must stay out of the way. Pass `still`, or choose a static card.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/object-cards.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 { ObjectCard, ObjectCards } from './object-cards';

// One card in your own grid.
export function RoboticsCard() {
  return (
    <ObjectCard
      object="knot"
      tone="light"
      title="Autonomous Robotics in Agriculture."
      description="Send field robots to read soil and crop health and plan each harvest."
      href="/robotics/agriculture"
    />
  );
}

// Or the stage of two, without its demo.
export function CardStage() {
  return <ObjectCards autoplay={false} />;
}
```

## States

- **Dark tone**: A black card with a white picture, on 1M. The default tone.
- **Light tone**: A white card with a black picture: the pair to put beside a dark one.
- **1D chosen**: The thumb on the first square: the object turns slowly (a quarter of full speed).
- **7D chosen**: The middle square: half speed. The thumb slides between them in 320 ms.
- **Link copied**: Copy Link reads Copied with a check for 1.8 s; a screen reader hears “Link copied”.
- **Light tone at night**: In a dark color-scheme the white card turns charcoal with a faint edge; the picture keeps its colours.

## Examples

### Card grid

Three cards in a grid of their own, each with its own copy and starting range, so the objects turn at slow, medium and full speed side by side, all drawn by one shared WebGL context.

```tsx
import { ObjectCard } from './object-cards';

export function CardGrid() {
  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 300px))',
        justifyContent: 'center',
        gap: 16,
        width: '100%',
      }}
    >
      <ObjectCard
        object="knot"
        tone="light"
        title="Inspection Drones for Wind Farms."
        description="Fly drones along each blade to find cracks and erosion early. Every flight maps the turbine and flags the repairs a crew should make first."
        href="#wind-inspection"
        defaultRange="1D"
      />
      <ObjectCard
        object="disc"
        tone="dark"
        title="Robotic Sorting for Recycling Plants."
        description="Teach arms to pick plastics, cans and card off a fast belt. Cameras tell each material apart, and the arms get quicker every shift."
        href="#recycling"
        defaultRange="7D"
      />
      <ObjectCard
        object="knot"
        tone="light"
        title="Soft Grippers for Fresh Produce."
        description="Pick berries, tomatoes and eggs without a bruise. Air-filled fingers take the shape of each one and pack it straight into the punnet."
        href="#soft-grippers"
      />
    </div>
  );
}
```

### Large card

One card 440px wide, as in a featured spot: every size in it is a share of its width, so the picture and the type grow together.

```tsx
import { ObjectCard } from './object-cards';

export function LargeCard() {
  return (
    <div style={{ width: 'min(100%, 440px)', margin: '0 auto' }}>
      <ObjectCard
        object="knot"
        tone="light"
        title="Underwater Robots for Offshore Cables."
        description="Send crawlers along seabed cables to check their burial and wear. Sonar maps every mile, and crews plan the repairs from the shore."
        href="#offshore-cables"
      />
    </div>
  );
}
```

### Description by range

The description follows the range the reader picks, through onRangeChange: a day, a week or a month of a scouting fleet’s work, starting on 7D at medium speed.

```tsx
'use client';

import { useState } from 'react';
import { ObjectCard } from './object-cards';
import type { Range } from './object-cards';

/** What the fleet did over each range. */
const REPORTS: Record<Range, string> = {
  '1D': 'In the last day the fleet scouted 46 rows of vines and flagged 3 for mildew. Crews treat them at first light.',
  '7D': 'In the last week the fleet scouted 310 rows of vines and flagged 19 for mildew. Crews treated each within a day.',
  '1M': 'In the last month the fleet scouted 1,240 rows of vines and flagged 64 for mildew, mapped block by block.',
};

/** The range the card starts on. */
const START: Range = '7D';

export function DescriptionByRange() {
  const [range, setRange] = useState(START);
  return (
    <div style={{ width: 'min(100%, 300px)', margin: '0 auto' }}>
      <ObjectCard
        object="disc"
        tone="dark"
        title="Field Robots for Vineyard Scouting."
        description={REPORTS[range]}
        href="#vineyard-scouting"
        defaultRange={START}
        onRangeChange={setRange}
      />
    </div>
  );
}
```

### Night colours

A light card held in its night colours by colorScheme: charcoal with a black picture whatever the page’s scheme, as on a dark band of a light page.

```tsx
import { ObjectCard } from './object-cards';

export function NightColours() {
  return (
    <div style={{ width: 'min(100%, 300px)', margin: '0 auto' }}>
      <ObjectCard
        object="knot"
        tone="light"
        colorScheme="dark"
        title="Mobile Robots for Hospital Logistics."
        description="Carry linen, meals and samples between wards around the clock. Nurses stay with patients while the fleet rides the lifts at night."
        href="#hospital-logistics"
      />
    </div>
  );
}
```

## How it works

- One hidden WebGL context draws both objects. Each card has a plain 2D canvas, and as soon as an object is drawn its picture is copied onto that card’s canvas with drawImage, so there is one context, one program and one set of buffers however many cards there are.
- The glass is a fragment shader: deep navy on the light’s side, a glowing blue on the far side, and a narrow, crisp pale band along the outline. Sharp reflections of a small virtual studio read as gloss, and on the disc the outline of the mark tilts the surface along a thin bevel, so the mark looks embossed.
- Both shapes are built in code. The disc is a cross-section turned around its axis, with its corners rounded by Chaikin cutting; the knot is a (2, 5) torus knot with a tube swept along its curve.
- Each object turns on a clock whose speed follows the range (1D slow, 7D medium, 1M full) and eases to a new speed. A drag adds a turn about the axis across the drag; let go, and the object coasts while the added turn eases away. Frames run only while a card is on screen in a visible tab, and a canvas off screen gives its pixels back.
- Before WebGL starts, and in browsers without it, an SVG drawing stands in for each object, projected from the same shapes in the same starting pose, so nothing jumps when the canvas fades in.
- The stage and the cards are light-dark() tokens, so they follow the page’s color-scheme, or the colorScheme prop: at night the white card turns charcoal and the black one lifts its quiet text. The WebGL pictures read no colour from CSS and stay as they are.

## API reference

- **object** (`'disc' | 'knot'`): ObjectCard: the object in the picture. Required.
- **tone** (`'dark' | 'light'`, default `'dark'`): ObjectCard: a black card with a white picture, or a white card (charcoal at night) with a black one.
- **title** (`string`): ObjectCard: the card’s title, on two lines. Required.
- **description** (`string`): ObjectCard: a short paragraph under the title. Required.
- **href** (`string`): ObjectCard: where the arrow leads, and the link Copy Link and Share give. Required.
- **defaultRange** (`'1D' | '7D' | '1M'`, default `'1M'`): ObjectCard: the range chosen at first, which sets how fast the object turns.
- **defaultCopied** (`boolean`, default `false`): ObjectCard: opens with Copy Link reading Copied, held until it is pressed again.
- **still** (`boolean`, default `false`): ObjectCard: holds the object still, as under reduced motion; it turns only by hand or by key.
- **onRangeChange** (`(range: Range) => void`): ObjectCard: called with the range the reader chooses.
- **ref** (`Ref<ObjectCardHandle>`): ObjectCard: drives the card as a hand would; the demo uses it.
- **autoplay** (`boolean`, default `true`): ObjectCards, the stage of two: while on screen and untouched, a ghost pointer turns the objects and changes their ranges.
- **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 card, or for the stage’s root.

## Accessibility

- Each picture is a focusable image with a name (“A blue glass knot, turning. Drag it, or use the arrow keys, to turn it.”), and the arrow keys turn the object. On touch, a sideways drag turns it and a vertical swipe still scrolls the page.
- The ranges are a radio group named “Turning speed”: the arrow keys, Home and End move the choice and the focus together, and each option’s name keeps its visible label (“1D, slow”).
- Range changes (“7D: turning medium”) and copying (“Link copied”) are announced in a polite live region; the demo’s own actions stay silent. Every control has a visible focus outline.
- Under reduced motion the objects don’t spin on their own, a drag stops without coasting, and the demo never plays.

## Source

### object-cards/object-cards.tsx

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

'use client';

import {
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
  type Ref,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { ArrowIcon, CheckIcon, CopyIcon, ShareIcon } from './icons';
import type { ObjectKind } from './renderer';
import { StandIn } from './stand-in';
import { useAutoplay } from './use-autoplay';
import { useObjectView } from './use-object-view';
import styles from './object-cards.module.css';

/*
 * Object cards: a product card whose picture is a glass object turning in 3D, a blue disc with an
 * embossed mark or a blue torus knot.
 *
 * - The objects are drawn with WebGL: one hidden context renders every card's object, and each
 *   card copies its picture onto a canvas of its own (renderer.ts). A glass shader gives them a
 *   deep body, a pale fresnel rim and sharp reflections of a studio.
 * - Each object spins on its own, a knot tumbling, a disc wobbling (spin.ts), at the speed the
 *   card's range sets: 1D slow, 7D medium, 1M full. Changing it eases the spin to the new speed.
 * - A drag turns the object about the axis across the drag; let go, it coasts and slows down. The
 *   arrow keys turn it too.
 * - Until WebGL has drawn, and where there is none, an SVG projected from the same shapes stands
 *   in for the object (stand-in.tsx).
 * - Copy Link puts the card's link on the clipboard and says so for a moment; Share opens the
 *   system's share sheet where there is one, and copies the link where not.
 * - Light or dark: the card's colours are light-dark() tokens in the CSS, so it follows the page's
 *   color-scheme, or the one the colorScheme prop sets. At night a light card turns charcoal and
 *   a dark one stays black. The picture is the same in both: the renderer's backdrops are its
 *   own constants, equal to the pictures' grounds, and it reads no colour from the page.
 * - On its own, outside the stage, a card is as wide as its box and keeps its proportions: it
 *   takes its size from its own width, where the stage sizes its two cards from the stage's.
 *
 * Below it, ObjectCards: two of them on a grey stage (near-black at night), one dark and one
 * light. While the stage is on screen and untouched, a ghost pointer turns an object now and then
 * and changes its range.
 */

/** The ranges of the segmented control: the speed each sets, and its pace in words. */
export const RANGES = [
  { label: '1D', pace: 'slow', speed: 0.25 },
  { label: '7D', pace: 'medium', speed: 0.5 },
  { label: '1M', pace: 'fast', speed: 1 },
] as const;

export type Range = (typeof RANGES)[number]['label'];

/** How long Copy Link shows that it copied, in ms. */
const COPIED = 1800;

/** Drives a card as a reader's hand would, without announcing anything (the demo uses it). */
export type ObjectCardHandle = {
  /** The object's picture, for the pointer to aim at. */
  media: () => HTMLElement | null;
  /** The button of a range. */
  segment: (range: Range) => HTMLElement | null;
  /** Takes hold of the object. */
  grab: () => void;
  /** Turns the object as a drag of (dx, dy) client px would. */
  drag: (dx: number, dy: number) => void;
  /** Lets go of the object. */
  release: () => void;
  /** Chooses a range. */
  select: (range: Range) => void;
};

/** The words for each object's picture. */
const PICTURES: Record<ObjectKind, string> = {
  disc: 'A blue glass disc with an embossed mark, turning',
  knot: 'A blue glass knot, turning',
};

export function ObjectCard({
  object,
  tone = 'dark',
  title,
  description,
  href,
  defaultRange = '1M',
  onRangeChange,
  ref,
  colorScheme,
  defaultCopied = false,
  still = false,
  className = '',
}: {
  /** The object in the picture: a glass disc or a torus knot. */
  object: ObjectKind;
  /** A black card with a white picture, or a white card (charcoal at night) with a black one. */
  tone?: 'dark' | 'light';
  /** The card's title, on two lines. */
  title: string;
  /** A short paragraph under the title. */
  description: string;
  /** Where the arrow leads, and the link Copy Link and Share give. */
  href: string;
  /** The range chosen at first, which sets how fast the object turns. */
  defaultRange?: Range;
  /** Called with the range the reader chooses. */
  onRangeChange?: (range: Range) => void;
  /** Drives the card as a hand would; the demo uses it. */
  ref?: Ref<ObjectCardHandle>;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens with Copy Link reading Copied, held until it is pressed again. */
  defaultCopied?: boolean;
  /** Holds the object still, as under reduced motion: it turns only by hand or by key. */
  still?: boolean;
  /** Extra class for the card. */
  className?: string;
}) {
  const [range, setRange] = useState<Range>(defaultRange);
  const [copied, setCopied] = useState(defaultCopied);
  const [message, setMessage] = useState('');
  const [dragging, setDragging] = useState(false);
  const index = RANGES.findIndex((option) => option.label === range);
  const view = useObjectView(object, RANGES[index].speed, still);
  const segments = useRef<(HTMLButtonElement | null)[]>([]);
  const press = useRef<{ id: number; x: number; y: number; dragging: boolean } | null>(null);
  const copiedTimer = useRef(0);
  useEffect(() => () => window.clearTimeout(copiedTimer.current), []);

  /** Says `text` in the live region (a different string each time, so a repeat is heard too). */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  const choose = (next: Range, byReader: boolean) => {
    if (next === range) return;
    setRange(next);
    if (!byReader) return;
    onRangeChange?.(next);
    const pace = RANGES.find((option) => option.label === next)?.pace;
    announce(`${next}: turning ${pace}`);
  };

  useImperativeHandle(ref, () => ({
    media: () => view.media.current,
    segment: (label) => segments.current[RANGES.findIndex((option) => option.label === label)],
    grab: view.grab,
    drag: view.drag,
    release: view.release,
    select: (label) => choose(label, false),
  }));

  const link = () => new URL(href, window.location.href).href;

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(link());
    } catch {
      announce('The link could not be copied');
      return;
    }
    setCopied(true);
    announce('Link copied');
    window.clearTimeout(copiedTimer.current);
    copiedTimer.current = window.setTimeout(() => setCopied(false), COPIED);
  };

  const share = async () => {
    if (!navigator.share) return copy();
    try {
      await navigator.share({ title, url: link() });
    } catch {
      // The reader closed the share sheet.
    }
  };

  // The object's picture: a drag turns it. A mouse drags at once; a touch once it moves sideways
  // (up or down first, the page scrolls).
  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || !view.ready) return;
    press.current = { id: event.pointerId, x: event.clientX, y: event.clientY, dragging: false };
    if (event.pointerType !== 'mouse') return;
    event.currentTarget.setPointerCapture(event.pointerId);
    press.current.dragging = true;
    setDragging(true);
    view.grab();
  };
  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const current = press.current;
    if (!current || current.id !== event.pointerId) return;
    const dx = event.clientX - current.x;
    const dy = event.clientY - current.y;
    if (!current.dragging) {
      if (Math.abs(dx) < 4) return;
      event.currentTarget.setPointerCapture(event.pointerId);
      current.dragging = true;
      setDragging(true);
      view.grab();
    }
    current.x = event.clientX;
    current.y = event.clientY;
    view.drag(dx, dy);
  };
  const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
    if (press.current?.id !== event.pointerId) return;
    press.current = null;
    setDragging(false);
    view.release();
  };

  const onMediaKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const turns: Partial<Record<string, [number, number]>> = {
      ArrowLeft: [0, -1],
      ArrowRight: [0, 1],
      ArrowUp: [-1, 0],
      ArrowDown: [1, 0],
    };
    const turn = turns[event.key];
    if (!turn || !view.ready) return;
    event.preventDefault();
    view.nudge(...turn);
  };

  // The ranges are radio buttons: the arrow keys move the choice, and the focus with it.
  const onRangeKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const step = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key];
    const to =
      event.key === 'Home'
        ? 0
        : event.key === 'End'
          ? RANGES.length - 1
          : step === undefined
            ? -1
            : (index + step + RANGES.length) % RANGES.length;
    if (to < 0) return;
    event.preventDefault();
    choose(RANGES[to].label, true);
    segments.current[to]?.focus();
  };

  return (
    <article
      className={`${styles.card} ${className}`}
      data-part="card"
      data-tone={tone}
      data-color-scheme={colorScheme}
    >
      <div
        ref={view.media}
        className={styles.media}
        data-part="picture"
        role="img"
        aria-label={`${PICTURES[object]}. Drag it, or use the arrow keys, to turn it.`}
        tabIndex={0}
        data-ready={view.ready || undefined}
        data-dragging={dragging || undefined}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerEnd}
        onPointerCancel={onPointerEnd}
        onKeyDown={onMediaKey}
      >
        <StandIn kind={object} className={styles.standIn} />
        <canvas ref={view.canvas} className={styles.canvas} />
      </div>
      <div className={styles.body}>
        <h3 className={styles.title} data-part="title">
          {title}
        </h3>
        <a className={styles.open} data-part="open" href={href} aria-label={`Open ${title}`}>
          <ArrowIcon />
        </a>
        <p className={styles.description} data-part="description">
          {description}
        </p>
        <div className={styles.controls}>
          <div
            className={styles.ranges}
            data-part="ranges"
            role="radiogroup"
            aria-label="Turning speed"
            onKeyDown={onRangeKey}
          >
            <span
              className={styles.thumb}
              data-part="thumb"
              style={{ '--index': index } as CSSProperties}
              aria-hidden="true"
            />
            {RANGES.map((option, i) => (
              <button
                key={option.label}
                ref={(button) => {
                  segments.current[i] = button;
                }}
                type="button"
                role="radio"
                className={styles.range}
                aria-checked={i === index}
                aria-label={`${option.label}, ${option.pace}`}
                tabIndex={i === index ? 0 : -1}
                onClick={() => choose(option.label, true)}
              >
                {option.label}
              </button>
            ))}
          </div>
          <button
            type="button"
            className={styles.copy}
            data-part="copy"
            data-copied={copied || undefined}
            onClick={copy}
          >
            <span className={styles.icon}>
              <span className={styles.idle}>
                <CopyIcon />
              </span>
              <span className={styles.done}>
                <CheckIcon />
              </span>
            </span>
            <span className={styles.label}>
              <span className={styles.idle}>Copy Link</span>
              <span className={styles.done} aria-hidden="true">
                Copied
              </span>
            </span>
          </button>
          <button type="button" className={styles.share} data-part="share" onClick={share}>
            <span className={styles.icon}>
              <ShareIcon />
            </span>
            Share
          </button>
        </div>
      </div>
      <p className={styles.hidden} aria-live="polite">
        {message}
      </p>
    </article>
  );
}

/** The two cards of the stage. */
const CARDS = [
  {
    object: 'disc',
    tone: 'dark',
    title: 'Collaborative Robots for Manufacturing.',
    description:
      'Put cobots beside crews on the assembly line to raise output. These arms share the floor with people, take on repetitive work and cut idle time.',
    href: '#collaborative-robots',
  },
  {
    object: 'knot',
    tone: 'light',
    title: 'Autonomous Robotics in Agriculture.',
    description:
      'Send field robots to read soil and crop health and plan each harvest. Drones survey every row from above and turn it into daily notes.',
    href: '#autonomous-robotics',
  },
] as const;

/**
 * The demo's rounds, in turn: a card (its place in CARDS), where the pointer takes its object and
 * which way it drags it (as shares of the picture), and the range it then picks.
 */
const ROUNDS: readonly { card: number; from: Point; by: Point; range: Range }[] = [
  { card: 1, from: { x: 0.34, y: 0.5 }, by: { x: 0.36, y: 0.08 }, range: '1D' },
  { card: 0, from: { x: 0.64, y: 0.46 }, by: { x: -0.32, y: 0.12 }, range: '7D' },
  { card: 1, from: { x: 0.62, y: 0.4 }, by: { x: -0.3, y: 0.18 }, range: '1M' },
  { card: 0, from: { x: 0.38, y: 0.56 }, by: { x: 0.3, y: -0.1 }, range: '1M' },
];

export function ObjectCards({
  className = '',
  autoplay = true,
  colorScheme,
}: {
  /** Extra class for the root, a stage of its own. */
  className?: string;
  /** While on screen and untouched, a ghost pointer turns the objects and changes their ranges. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  /** Keyboard focus is on the stage: the ghost leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const cards = useRef<(ObjectCardHandle | null)[]>([]);
  const pointer = useRef<HTMLSpanElement>(null);
  const stopGhost = useRef<(() => void) | null>(null);

  /** The reader takes over: the ghost stops at once and waits until they leave it alone. */
  const readerActs = () => {
    stopGhost.current?.();
    take();
  };

  const demo = autoplay && playing && !keyboard;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    /** The card whose object the ghost holds, let go when it stops. */
    let held: ObjectCardHandle | null = null;
    const stop = runDemo(stage, arrow, async ({ wait, moveTo, press, drag }) => {
      await wait(1200);
      for (let turn = 0; ; turn += 1) {
        const { card: place, from, by, range } = ROUNDS[turn % ROUNDS.length];
        const card = cards.current[place];
        const media = card?.media();
        if (!card || !media) return;
        await moveTo(media, from);
        const box = media.getBoundingClientRect();
        const shift = { x: by.x * box.width, y: by.y * box.height };
        const to = {
          x: box.left + (from.x + by.x) * box.width,
          y: box.top + (from.y + by.y) * box.height,
        };
        card.grab();
        held = card;
        let done = 0;
        await drag(to, 1300, (share) => {
          card.drag((share - done) * shift.x, (share - done) * shift.y);
          done = share;
        });
        card.release();
        held = null;
        await wait(700);
        await moveTo(card.segment(range));
        await press(() => card.select(range));
        await wait(2600);
      }
    });
    stopGhost.current = () => {
      stop();
      held?.release();
      held = null;
    };
    return stopGhost.current;
  }, [demo, root]);

  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div className={styles.scene}>
        {CARDS.map((card, i) => (
          <ObjectCard
            key={card.href}
            ref={(handle) => {
              cards.current[i] = handle;
            }}
            {...card}
          />
        ))}
      </div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer ref={pointer} className={styles.pointer} />
      </div>
    </div>
  );
}
```

### object-cards/object-cards.module.css

```css
/* Object cards (object-cards.tsx): two product cards side by side on a grey stage, one under the
   other in a narrow one; at night the stage is near-black, the dark card stays black and the light
   one turns charcoal, and the pictures (the objects and their grounds) keep their colours. Every
   size in a card is a multiple of --u, a 254th of the card's width, which the scene sets from the
   stage's width (a card on its own measures itself), so a card keeps its proportions at any size.

   Memory and layout. The scene is a layout boundary of a size known from the stage's width
   alone, so nothing that changes inside it lays out more than the scene. What changes while the
   demo plays stays off the page's tiles (a phone keeps them in buffers of several MB, and a tile
   painted again gets a second one): the objects turn on their canvases, the ranges and Copy Link
   each paint on a small layer of their own, and the pointer moves on its own layer without any
   layout (demo-pointer.tsx). */

.stage {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the stage
     inherits from the page, or the one the colorScheme prop sets (below). The stage and its
     edge. */
  --surface: #c1c1c1;
  --line: rgb(0 0 0 / 0.06);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#c1c1c1, #18181a);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
  }
}
/* The stage, and a card on its own, bring their type and box model with them. */
.stage,
.card {
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
.stage *,
.stage *::before,
.stage *::after,
.card,
.card *,
.card *::before,
.card *::after {
  box-sizing: border-box;
}

/* The colorScheme prop, on the stage or on a card. Without it they set nothing and inherit the
   page's color-scheme. */
:is(.stage, .card)[data-color-scheme='light'] {
  color-scheme: light;
}
:is(.stage, .card)[data-color-scheme='dark'] {
  color-scheme: dark;
}
:is(.stage, .card)[data-color-scheme='system'] {
  color-scheme: light dark;
}

.scene {
  /* The stage is 695 units wide and 517 tall, the two cards and the gap between them (556 units)
     in its middle, up to 1.3px a unit. */
  --u: min(1.3px, 100cqw / 695);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: calc(var(--u) * 48);
  height: calc(var(--u) * 517);
  contain: size layout;
}

/* The card. Its colours come from its tone, light-dark() pairs where night changes them: the
   card, its picture's ground (the renderer's backdrop, the same in both schemes), the title, the
   description, the ranges' track and labels, Copy Link and its text, Share and the arrow (the
   accent) and their glyphs. At night the edge, a faint light hairline, sets the card off the
   stage. */
.card {
  --focus: #3b82f6;
  --card-line: transparent;
  /* The chosen range's square: its edge and its shade. */
  --thumb-line: rgb(0 0 0 / 0.07);
  --shadow: rgb(0 0 0 / 0.1);
  position: relative;
  display: flex;
  flex: none;
  flex-direction: column;
  width: calc(var(--u) * 254);
  height: calc(var(--u) * 336);
  padding: calc(var(--u) * 4.3);
  border-radius: calc(var(--u) * 12.5);
  background: var(--surface-2);
  box-shadow: 0 0 0 1px var(--card-line);
  color: var(--ink);
}
@supports (color: light-dark(#fff, #000)) {
  /* The card. Its colours come from its tone, light-dark() pairs where night changes them: the
   card, its picture's ground (the renderer's backdrop, the same in both schemes), the title, the
   description, the ranges' track and labels, Copy Link and its text, Share and the arrow (the
   accent) and their glyphs. At night the edge, a faint light hairline, sets the card off the
   stage. */
  .card {
    --card-line: light-dark(transparent, rgb(255 255 255 / 0.08));
    --thumb-line: light-dark(rgb(0 0 0 / 0.07), rgb(0 0 0 / 0.2));
    --shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
  }
}
/* A card on its own, outside the stage (in a grid of the page's, say): as wide as its box, in the
   same proportions. It measures itself as a container, so its units are a 254th of its own width;
   it has no padding, so that width is all of it, and its picture and text sit in by margins of
   4.3 units instead. Its corners are shares of its size. */
.card:not(.scene > .card) {
  --u: calc(100cqw / 254);
  container-type: inline-size;
  flex: 0 1 auto;
  width: 100%;
  height: auto;
  aspect-ratio: 254 / 336;
  padding: 0;
  border-radius: 4.921% / 3.72%;
}
.card:not(.scene > .card) > .media {
  margin: calc(var(--u) * 4.3) calc(var(--u) * 4.3) 0;
}
.card:not(.scene > .card) > .body {
  margin: 0 calc(var(--u) * 4.3) calc(var(--u) * 4.3);
}
/* Black by day and by night; at night its quieter text is lifted to read on it. */
.card[data-tone='dark'] {
  --surface-2: #030303;
  --picture: #fdfdfd;
  --ink: #f4f4f4;
  --ink-muted: #5e5e5e;
  --track: #444;
  --track-ink: #a3a3a3;
  --button: #444;
  --button-ink: #c2c2c2;
  --accent: #fcfcfc;
  --accent-ink: #161616;
}
@supports (color: light-dark(#fff, #000)) {
  /* Black by day and by night; at night its quieter text is lifted to read on it. */
  .card[data-tone='dark'] {
    --ink-muted: light-dark(#5e5e5e, #8c8c8c);
    --track-ink: light-dark(#a3a3a3, #b8b8b8);
  }
}
/* White by day, charcoal at night; its picture stays black. */
.card[data-tone='light'] {
  --surface-2: #fff;
  --picture: #0f0f0f;
  --ink: #0d0d0d;
  --ink-muted: #9a9a9a;
  --track: #f2f2f2;
  --track-ink: #686868;
  --button: #e7e7e7;
  --button-ink: #555;
  --accent: #030303;
  --accent-ink: #f0f0f0;
}
@supports (color: light-dark(#fff, #000)) {
  /* White by day, charcoal at night; its picture stays black. */
  .card[data-tone='light'] {
    --surface-2: light-dark(#fff, #2c2c2f);
    --ink: light-dark(#0d0d0d, #f4f4f4);
    --ink-muted: light-dark(#9a9a9a, #a1a1a1);
    --track: light-dark(#f2f2f2, #3d3d40);
    --track-ink: light-dark(#686868, #b0b0b0);
    --button: light-dark(#e7e7e7, #48484b);
    --button-ink: light-dark(#555, #d6d6d6);
    --accent: light-dark(#030303, #fcfcfc);
    --accent-ink: light-dark(#f0f0f0, #161616);
  }
}
.card :where(button, a) {
  margin: 0;
  border: 0;
  font: inherit;
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}
.card :where(button, a, .media):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The picture: the drawing that stands in for the object, and the canvas over it once WebGL has
   drawn. A sideways drag turns the object; up and down, the page scrolls. */
.media {
  position: relative;
  flex: none;
  height: calc(var(--u) * 183);
  overflow: hidden;
  border-radius: calc(var(--u) * 8.5);
  background: var(--picture);
  cursor: grab;
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
}
.media[data-dragging] {
  cursor: grabbing;
}
.standIn,
.canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}
.canvas {
  opacity: 0;
  transition: opacity 0.4s ease;
  will-change: transform;
}
.media[data-ready] .canvas {
  opacity: 1;
}
.media[data-ready] .standIn {
  visibility: hidden;
}

/* The text: the title, the arrow beside it, the description, and the controls at the bottom. */
.body {
  position: relative;
  display: flex;
  flex: 1;
  flex-direction: column;
  padding: calc(var(--u) * 19.3) calc(var(--u) * 4.7) calc(var(--u) * 6.5) calc(var(--u) * 3.5);
}
.title {
  max-width: 11.2em;
  margin: 0;
  font-size: calc(var(--u) * 13);
  font-weight: 400;
  line-height: calc(var(--u) * 13.7);
  text-wrap: balance;
}
.open {
  position: absolute;
  top: calc(var(--u) * 18.1);
  right: calc(var(--u) * 4.7);
  display: grid;
  place-items: center;
  width: calc(var(--u) * 15.9);
  height: calc(var(--u) * 15.9);
  border-radius: calc(var(--u) * 3);
  background: var(--accent);
  color: var(--accent-ink);
}
.open svg {
  width: calc(var(--u) * 8);
  height: calc(var(--u) * 8);
}
.description {
  max-width: 42.5ch;
  margin: calc(var(--u) * 9.6) 0 0;
  color: var(--ink-muted);
  font-family: 'JetBrains Mono', JetBrains, ui-monospace, Menlo, monospace;
  font-size: calc(var(--u) * 6.28);
  line-height: calc(var(--u) * 8.23);
}
.controls {
  display: flex;
  align-items: center;
  gap: calc(var(--u) * 4.8);
  height: calc(var(--u) * 18);
  margin-top: auto;
  font-size: calc(var(--u) * 6.1);
  font-weight: 500;
}

/* The ranges: three square buttons on a track, and a white square under the chosen one, which
   slides to it. */
.ranges {
  position: relative;
  display: flex;
  gap: calc(var(--u) * 7.2);
  height: 100%;
  margin-right: auto;
  padding: calc(var(--u) * 1.2) calc(var(--u) * 1.2) calc(var(--u) * 1.2) calc(var(--u) * 1.5);
  border-radius: calc(var(--u) * 3);
  background: var(--track);
  /* A small layer of its own: a choice repaints it alone, not the page around it. */
  will-change: transform;
}
.thumb {
  position: absolute;
  top: calc(var(--u) * 1.2);
  left: calc(var(--u) * 1.5);
  width: calc(var(--u) * 15.6);
  height: calc(var(--u) * 15.6);
  border-radius: calc(var(--u) * 2.4);
  background: #fff;
  box-shadow:
    0 0 0 calc(var(--u) * 0.5) var(--thumb-line),
    0 calc(var(--u) * 0.6) calc(var(--u) * 1.2) var(--shadow);
  transform: translateX(calc(var(--index) * var(--u) * 22.8));
  transition: transform 0.32s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.range {
  position: relative;
  width: calc(var(--u) * 15.6);
  height: 100%;
  padding: 0;
  border-radius: calc(var(--u) * 2.4);
  background: none;
  color: var(--track-ink);
  font-size: calc(var(--u) * 5.3);
  line-height: 1;
  transition: color 0.2s ease;
}
.range[aria-checked='true'] {
  color: #333;
}

/* Copy Link and Share. Copy Link's two states sit in the same place, so it keeps its width. */
.copy,
.share {
  display: flex;
  align-items: center;
  gap: calc(var(--u) * 7.2);
  height: 100%;
  padding: 0 calc(var(--u) * 5.4) 0 calc(var(--u) * 5.6);
  border-radius: calc(var(--u) * 2.6);
  white-space: nowrap;
  transition: filter 0.2s ease;
}
.copy {
  background: var(--button);
  color: var(--button-ink);
  will-change: transform;
}
.share {
  background: var(--accent);
  color: var(--accent-ink);
}
.copy:hover,
.share:hover {
  filter: brightness(0.94);
}
.card[data-tone='dark'] .copy:hover {
  filter: brightness(1.18);
}
.icon,
.label {
  display: grid;
}
.icon {
  width: calc(var(--u) * 6);
  height: calc(var(--u) * 6);
}
.icon svg {
  display: block;
  width: 100%;
  height: 100%;
}
.icon > *,
.label > * {
  grid-area: 1 / 1;
  transition: opacity 0.2s ease;
}
.label > * {
  justify-self: start;
}
.done,
.copy[data-copied] .idle {
  opacity: 0;
}
.copy[data-copied] .done {
  opacity: 1;
}

/* The demo's pointer: an arrow, or a closed hand while it drags. It moves over the whole stage,
   in a box of its own. */
.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;
  will-change: opacity;
}

/* Narrow: one card under the other, as wide as the stage allows up to 1.2px a unit. */
@container (max-width: 479px) {
  .scene {
    --u: min(1.2px, (100cqw - 32px) / 254);
    flex-direction: column;
    gap: 16px;
    height: calc(var(--u) * 672 + 48px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .stage *,
  .stage *::before,
  .stage *::after,
  .card *,
  .card *::before,
  .card *::after {
    transition: none !important;
  }
}
```

### object-cards/use-object-view.ts

```tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { holdRenderer, releaseRenderer, type ObjectKind } from './renderer';
import { spinAt } from './spin';
import { axisAngle, identity, isIdentity, multiply, shrink, tidy, type Turn } from './turn';

/*
 * One object in its picture: the canvas it is drawn on, its own spin and the turn a hand gives
 * it. Frame by frame, the clock of its spin moves on at the chosen speed and the object is drawn
 * as `spinAt(clock)`, turned further by what the reader added: a drag turns it about the axis
 * across the drag; let go, it keeps turning, slower and slower, while the added turn eases away
 * and the object settles back into its own spin. Only while it is on screen in a visible tab:
 * off screen the canvas gives its pixels back, and in a hidden tab nothing runs. Under reduced
 * motion, or with `hold` (the card's `still`), it does not spin on its own, and it stays where
 * the reader turns it.
 */

/**
 * How long the spin takes to reach a new speed, a let-go object to slow down, and the turn the
 * reader added to ease away, in seconds (each the time to cover about two thirds of the way).
 */
const EASE = 0.25;
const COAST = 0.45;
const SETTLE = 1.4;
/** A key's turn: a push, in radians per second, or a step, in radians, under reduced motion. */
const PUSH = 2.6;
const STEP = Math.PI / 12;
/** The fastest a flick sets it turning, in radians per second. */
const FLICK = 14;

type Motion = {
  /** How far the object's own spin has gone, in seconds at full speed. */
  clock: number;
  /** The speed the spin goes at now, and the one asked for (0 to 1). */
  rate: number;
  speed: number;
  /** What the reader turned it by, on top of its spin. */
  extra: Turn;
  /** How fast it still turns from a drag, in radians per second about the view's x and y. */
  spin: [number, number];
  /** A hand holds it: its spin waits. */
  held: boolean;
  /** The hand's last move, for the speed it lets go at. */
  last: { time: number; x: number; y: number };
  /** Radians per client px of a drag, set as the hand takes hold (the height: half a turn). */
  perPixel: number;
};

/** Pixels per CSS pixel: the screen's, capped at 2 (sharp enough, for half the memory of 3×). */
const pixelRatio = () => Math.min(2, window.devicePixelRatio || 1);

export function useObjectView(kind: ObjectKind, speed: number, hold = false) {
  const media = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  /** The canvas has drawn the object: the drawing that stood in for it can go. */
  const [ready, setReady] = useState(false);
  const motion = useRef<Motion>({
    clock: 0,
    rate: speed,
    speed,
    extra: identity(),
    spin: [0, 0],
    held: false,
    last: { time: 0, x: 0, y: 0 },
    perPixel: 0.01,
  });
  /** Starts the frames if they are stopped and allowed to run (the effect below sets it up). */
  const wake = useRef(() => {});
  /** Whether turns must be instant (reduced motion, or the card's `still`). */
  const still = useRef(false);
  /** The card's `still`, for the effect below to read. */
  const held = useRef(hold);
  /** Reads reduced motion and `still` again (the effect below sets it up). */
  const rereadMotion = useRef(() => {});

  useEffect(() => {
    const box = media.current;
    const view = canvas.current;
    if (!box || !view) return;
    const renderer = holdRenderer();
    // Without WebGL, the drawing stays.
    if (!renderer) return;
    const context = view.getContext('2d', { alpha: false });
    if (!context) {
      releaseRenderer();
      return;
    }
    const m = motion.current;
    let frame = 0;
    let time = 0;
    let onScreen = false;
    let drawn = false;
    let size = { width: 0, height: 0 };

    const draw = () => {
      if (!view.width || !renderer.draw(context, kind, multiply(m.extra, spinAt(kind, m.clock))))
        return;
      if (!drawn) setReady(true);
      drawn = true;
    };

    /** The canvas's pixels: its box's on screen, none off it. */
    const fit = () => {
      const ratio = pixelRatio();
      const width = onScreen ? Math.round(size.width * ratio) : 0;
      const height = onScreen ? Math.round(size.height * ratio) : 0;
      if (view.width === width && view.height === height) return;
      view.width = width;
      view.height = height;
      draw();
    };

    const running = () => onScreen && !document.hidden;
    const tick = (now: number) => {
      // A frame's time can come before the time the frames started: never a step back.
      const dt = Math.min(0.05, Math.max(0, (now - time) / 1000));
      time = now;
      const goal = still.current || m.held ? 0 : m.speed;
      m.rate += (goal - m.rate) * (1 - Math.exp(-dt / EASE));
      if (still.current || Math.abs(goal - m.rate) < 0.002) m.rate = goal;
      m.clock += dt * m.rate;
      const [x, y] = m.spin;
      if (!m.held && (x || y)) {
        m.extra = tidy(multiply(axisAngle(x, y, 0, Math.hypot(x, y) * dt), m.extra));
        const fade = Math.exp(-dt / COAST);
        m.spin = Math.hypot(x, y) * fade < 0.02 ? [0, 0] : [x * fade, y * fade];
      }
      if (!m.held && !still.current && !isIdentity(m.extra)) {
        m.extra = shrink(m.extra, Math.exp(-dt / SETTLE));
      }
      draw();
      const settling = !m.held && !still.current && !isIdentity(m.extra);
      const moving = m.rate || goal || m.spin[0] || m.spin[1] || settling;
      frame = running() && moving ? requestAnimationFrame(tick) : 0;
    };
    wake.current = () => {
      if (frame || !running()) return;
      time = performance.now();
      frame = requestAnimationFrame(tick);
    };
    const sleep = () => {
      cancelAnimationFrame(frame);
      frame = 0;
    };

    const resize = new ResizeObserver(([entry]) => {
      size = { width: entry.contentRect.width, height: entry.contentRect.height };
      fit();
    });
    resize.observe(box);
    const seen = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting === onScreen) return;
        onScreen = entry.isIntersecting;
        renderer.show(onScreen);
        fit();
        if (onScreen) wake.current();
        else sleep();
      },
      { rootMargin: '120px 0px' },
    );
    seen.observe(box);
    const onVisibility = () => (document.hidden ? sleep() : wake.current());
    document.addEventListener('visibilitychange', onVisibility);
    const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => {
      still.current = motionQuery.matches || held.current;
      wake.current();
    };
    rereadMotion.current = readMotion;
    readMotion();
    motionQuery.addEventListener('change', readMotion);

    return () => {
      sleep();
      if (onScreen) renderer.show(false);
      wake.current = () => {};
      rereadMotion.current = () => {};
      resize.disconnect();
      seen.disconnect();
      document.removeEventListener('visibilitychange', onVisibility);
      motionQuery.removeEventListener('change', readMotion);
      view.width = 0;
      view.height = 0;
      releaseRenderer();
    };
  }, [kind]);

  // Held still or let go: the spin stops where it is, or starts again.
  useEffect(() => {
    held.current = hold;
    rereadMotion.current();
  }, [hold]);

  // A new speed: the spin eases to it.
  useEffect(() => {
    motion.current.speed = speed;
    wake.current();
  }, [speed]);

  /** A hand takes the object: its spin waits, its coasting stops. */
  const grab = () => {
    const m = motion.current;
    m.held = true;
    m.spin = [0, 0];
    m.last = { time: performance.now(), x: 0, y: 0 };
    m.perPixel = Math.PI / (media.current?.getBoundingClientRect().height || 200);
    wake.current();
  };

  /** The hand moves by (dx, dy) client px: the object turns about the axis across the move. */
  const drag = (dx: number, dy: number) => {
    const m = motion.current;
    const k = m.perPixel;
    const angle = Math.hypot(dx, dy) * k;
    if (!angle) return;
    m.extra = tidy(multiply(axisAngle(dy, dx, 0, angle), m.extra));
    // The speed of the hand, smoothed: what the object keeps when let go.
    const now = performance.now();
    const dt = Math.max(0.008, (now - m.last.time) / 1000);
    m.last = {
      time: now,
      x: m.last.x * 0.4 + ((dy * k) / dt) * 0.6,
      y: m.last.y * 0.4 + ((dx * k) / dt) * 0.6,
    };
    wake.current();
  };

  /** The hand lets go: the object coasts at the hand's speed, unless the hand had stopped. */
  const release = () => {
    const m = motion.current;
    if (!m.held) return;
    m.held = false;
    const moving = performance.now() - m.last.time < 90 && !still.current;
    const fast = Math.hypot(m.last.x, m.last.y);
    const cap = fast > FLICK ? FLICK / fast : 1;
    m.spin = moving ? [m.last.x * cap, m.last.y * cap] : [0, 0];
    wake.current();
  };

  /** A key's turn about the view's x (`dx`) or y (`dy`) axis, by -1 or 1. */
  const nudge = (dx: number, dy: number) => {
    const m = motion.current;
    if (still.current) {
      m.extra = tidy(multiply(axisAngle(dx, dy, 0, STEP), m.extra));
    } else m.spin = [m.spin[0] + dx * PUSH, m.spin[1] + dy * PUSH];
    wake.current();
  };

  return { media, canvas, ready, grab, drag, release, nudge };
}
```

### object-cards/renderer.ts

```tsx
import { discMesh, knotMesh, DISC_FACE, type Mesh } from './shapes';
import type { Turn } from './turn';

/*
 * One WebGL context draws every object on the page. It renders on a canvas of its own, which is
 * never shown; each card has a plain 2D canvas, and as soon as an object is drawn its picture is
 * copied there with drawImage. One context, one set of buffers and one program serve any number
 * of cards, where a context per card would hold its own of each, multisampled buffers included.
 *
 * The glass is a fragment shader, lit in view space:
 * - the body goes from a deep blue on the key light's side to a glowing one on the far side,
 *   where the light that went through gathers, and is deepest where it faces the eye;
 * - toward the outline the light caught inside glows, and a narrow band along the outline turns
 *   pale, as glass reflects more at grazing angles (dark under the disc, as the edge of a glass
 *   on a white table is);
 * - the highlights are reflections of a studio (a band of light round the horizon, a light
 *   overhead, two soft boxes), with sharp edges, so they read as gloss;
 * - on the disc's top face, a mark is embossed: its outline, a distance field, bends the
 *   surface's normal along a narrow bevel, which catches the light. The face is a little
 *   hollow, so its reflections move as the disc wobbles.
 */

export type ObjectKind = 'disc' | 'knot';

/** The camera: how far it stands from the objects' middle, and its lens (about 24 degrees). */
const CAMERA = { distance: 6, focal: 4.7 } as const;

/** What each object is made of: its colours, as 0–1 RGB, and how it fills the picture. */
export const LOOKS: Record<
  ObjectKind,
  {
    /** The glass where the eye looks through the most of it. */
    deep: [number, number, number];
    /** The glass where the light gathers. */
    glow: [number, number, number];
    /** Its outline, at grazing angles. */
    rim: [number, number, number];
    /** The backdrop the glass sits on. */
    backdrop: [number, number, number];
    /** How much of the backdrop shows through where the glass is thin. */
    clear: number;
    /** How much deeper the glass looks where it faces the eye. */
    depth: number;
    /**
     * How the glass turns from deep to glowing across the object, away from the light: how fast
     * (per object radius), and how far along it is at the middle.
     */
    fall: [number, number];
    /** How far the outline turns to the rim's colour. */
    sheen: number;
    /** How brightly the light caught inside glows toward the outline. */
    gather: number;
    /** Where the pale outline starts, from the middle (0) to the edge (1) of the object as seen. */
    band: number;
    /** How much the studio shows in the glass where it faces the eye. */
    gloss: number;
    /** How much the outline's colours depend on the side the light comes from (0 to 1). */
    side: number;
    /** How the disc's face curves, for its reflections (below 0: hollow; the knot ignores it). */
    dome: number;
    /** The object's radius, as a share of the picture's half height. */
    size: number;
    /** Where its middle sits off the picture's middle, right and up, in the same shares. */
    shift: [number, number];
  }
> = {
  disc: {
    deep: [0.05, 0.1, 0.3],
    glow: [0.41, 0.56, 0.8],
    rim: [0.86, 0.89, 0.94],
    backdrop: [0.992, 0.992, 0.992],
    clear: 0.7,
    depth: 0.1,
    fall: [2.8, 1.15],
    sheen: 0.8,
    gather: 0,
    band: 0.68,
    gloss: 0.16,
    side: 0,
    dome: -0.2,
    size: 0.645,
    shift: [0.016, -0.074],
  },
  knot: {
    deep: [0.02, 0.04, 0.19],
    glow: [0.1, 0.16, 0.85],
    rim: [0.72, 0.9, 1],
    backdrop: [0.059, 0.059, 0.059],
    clear: 0,
    depth: 0.9,
    fall: [0.7, 0.4],
    sheen: 0.55,
    gather: 0.8,
    band: 0.5,
    gloss: 0.1,
    side: 0.2,
    dome: 0,
    size: 0.87,
    shift: [0.05, -0.03],
  },
};

const VERTEX = /* glsl */ `
attribute vec3 aPosition;
attribute vec3 aNormal;
uniform mat3 uTurn;
uniform vec2 uLens;
uniform vec2 uShift;
uniform float uSize;
varying vec3 vPosition;
varying vec3 vNormal;
varying vec3 vView;
varying vec2 vAcross;
const float DISTANCE = ${CAMERA.distance.toFixed(1)};
void main() {
  vPosition = aPosition;
  vNormal = aNormal;
  // Where the point is across the object as seen, from -1 to 1.
  vAcross = (uTurn * aPosition).xy;
  vec3 p = uTurn * aPosition * uSize;
  p.z -= DISTANCE;
  vView = p;
  // A narrow lens (near plane 1, far plane 10): close to flat, with a little depth. The object
  // sits off the middle of the picture by uShift.
  gl_Position = vec4(
    p.x * uLens.x - uShift.x * p.z,
    p.y * uLens.y - uShift.y * p.z,
    -1.2222 * p.z - 2.2222,
    -p.z
  );
}
`;

const FRAGMENT = /* glsl */ `
precision highp float;
uniform mat3 uTurn;
uniform vec3 uDeep;
uniform vec3 uGlow;
uniform vec3 uRim;
uniform vec3 uBackdrop;
uniform float uClear;
uniform float uDepth;
uniform vec2 uFall;
uniform float uSheen;
uniform float uGather;
uniform float uBand;
uniform float uGloss;
uniform float uSide;
uniform float uDome;
uniform float uDisc;
varying vec3 vPosition;
varying vec3 vNormal;
varying vec3 vView;
varying vec2 vAcross;

const vec3 KEY = vec3(-0.42, 0.62, 0.66);
// The radius of the disc's face, inside its lip.
const float FACE = ${DISC_FACE.radius.toFixed(2)};

// The mark, as a signed distance (negative inside): a round tile cut in two along a slant, its
// halves slid apart along the cut.
float mark(vec2 p) {
  vec2 q = mat2(0.866, 0.5, -0.5, 0.866) * p;
  q.x -= sign(q.y) * 0.06;
  return max(length(q) - 0.3, 0.04 - abs(q.y));
}

// A studio around the object, seen in a reflection: dim walls, a band of light round the
// horizon, a light overhead, a soft box above left and a smaller one to the right.
vec3 studio(vec3 r) {
  vec3 colour = mix(vec3(0.03, 0.04, 0.1), vec3(0.3, 0.34, 0.5), smoothstep(-0.6, 0.9, r.y));
  colour += 0.75 * smoothstep(0.12, 0.04, abs(r.y - 0.12));
  colour += 0.6 * smoothstep(0.84, 0.92, r.y);
  colour += 1.6 * smoothstep(0.93, 0.955, dot(r, normalize(KEY)));
  colour += 1.1 * smoothstep(0.972, 0.988, dot(r, normalize(vec3(0.62, 0.3, 0.72))));
  return colour;
}

void main() {
  vec3 n = normalize(vNormal);
  float radius = length(vPosition.xz);
  if (uDisc > 0.5 && n.y > 0.97 && radius < FACE - 0.04) {
    // The emboss: along the mark's outline, a bevel tilts the face's normal.
    vec2 p = vec2(vPosition.x, -vPosition.z);
    float e = 0.003;
    float h = smoothstep(0.034, 0.0, mark(p));
    float hx = smoothstep(0.034, 0.0, mark(p + vec2(e, 0.0)));
    float hy = smoothstep(0.034, 0.0, mark(p + vec2(0.0, e)));
    vec2 slope = vec2(hx - h, hy - h) / e * 0.06;
    // The face is a little hollow, so its reflections move across it as the disc wobbles.
    n = normalize(vec3(-slope.x + uDome * vPosition.x, 1.0, slope.y + uDome * vPosition.z));
  }
  vec3 N = normalize(uTurn * n);
  vec3 V = normalize(-vView);
  float facing = clamp(dot(N, V), 0.0, 1.0);
  float edge = 1.0 - facing;
  vec3 light = normalize(KEY);

  // Light that enters on the key light's side gathers on the far side: across the object, from
  // its top left to its bottom right, the glass goes from deep to glowing.
  float far = clamp(dot(vAcross, -normalize(light.xy)) * uFall.x + uFall.y, 0.0, 1.0);
  vec3 body = mix(uDeep, uGlow, far);
  // Where the surface faces the eye, one looks through the most glass: deeper there.
  body *= mix(1.2, 1.05 - uDepth, facing * facing);
  // Which way the surface faces in the picture: toward the key light (1) or away from it (0).
  float lit = dot(normalize(N.xy + vec2(0.0, 1e-4)), normalize(light.xy)) * 0.5 + 0.5;
  // Toward the outline, the light caught inside glows, more on the side away from the light.
  float away = mix(1.0, 1.0 - 0.8 * lit, uSide);
  body = mix(body, uGlow * 1.3, smoothstep(0.4, 0.95, edge) * uGather * away);
  // The lip shades the disc's face just inside it, on the light's side.
  float lee = smoothstep(0.45, 0.8, radius) * (1.0 - smoothstep(0.12, 0.18, vPosition.y));
  body *= 1.0 - uDisc * lee * clamp(dot(vAcross, normalize(light.xy)) * 1.4 + 0.3, 0.0, 1.0) * 0.35;
  // The lip's inner wall, seen through the glass across the face, is dark.
  float inner = smoothstep(0.2, 0.6, -dot(n.xz, vPosition.xz) / max(radius, 1e-3));
  body = mix(body, uDeep * 0.45, uDisc * inner * step(FACE - 0.04, radius));
  // The disc's side is thick glass, seen across: a middle blue.
  float wall = 1.0 - smoothstep(0.3, 0.7, abs(n.y));
  body = mix(body, mix(uDeep, uGlow, 0.6), uDisc * wall * 0.75);
  // The disc's rounded underside, turned away from the studio's lights, is darker.
  body *= 1.0 - uDisc * smoothstep(-0.25, -0.85, n.y) * 0.55;
  // The disc's lip is thin glass: the backdrop shows through it.
  float lip = smoothstep(FACE + 0.02, FACE + 0.08, radius) * smoothstep(0.14, 0.2, vPosition.y);
  body = mix(body, uBackdrop * mix(uGlow, vec3(1.0), 0.7), uClear * uDisc * lip);

  // The pale outline, with a crisp inner edge, palest on the light's side. Under the disc's lip
  // it turns dark, as the edge of a glass on a white table does.
  float band = smoothstep(uBand, uBand + 0.12, edge);
  vec3 rim = mix(uDeep, uRim, 1.0 - uDisc * smoothstep(0.3, -0.3, n.y));
  vec3 colour = mix(body, rim, band * uSheen * mix(1.0, lit, uSide));
  // The studio's reflections: faint face on, strong at grazing angles.
  float fresnel = pow(edge, 2.2);
  colour += studio(reflect(-V, N)) * (uGloss + (1.0 - uGloss) * fresnel);
  gl_FragColor = vec4(colour, 1.0);
}
`;

const UNIFORMS = [
  'uTurn',
  'uLens',
  'uShift',
  'uSize',
  'uDeep',
  'uGlow',
  'uRim',
  'uBackdrop',
  'uClear',
  'uDepth',
  'uFall',
  'uSheen',
  'uGather',
  'uBand',
  'uGloss',
  'uSide',
  'uDome',
  'uDisc',
] as const;

type Uniforms = Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;

/** What the context holds: the program's inputs, and each mesh's buffers once it is drawn. */
type Resources = {
  uniforms: Uniforms;
  position: number;
  normal: number;
  meshes: Partial<
    Record<ObjectKind, { vertices: WebGLBuffer; indices: WebGLBuffer; count: number }>
  >;
};

/**
 * Where a point of an object (in its own units) lands in a picture of `width` × `height`, as the
 * renderer draws it: x and y from the top left corner, and its distance from the camera.
 */
export function project(
  kind: ObjectKind,
  turn: Turn,
  [x, y, z]: readonly [number, number, number],
  width: number,
  height: number,
) {
  const { size, shift } = LOOKS[kind];
  const scale = (size * CAMERA.distance) / CAMERA.focal;
  const px = (turn[0] * x + turn[3] * y + turn[6] * z) * scale;
  const py = (turn[1] * x + turn[4] * y + turn[7] * z) * scale;
  const depth = CAMERA.distance - (turn[2] * x + turn[5] * y + turn[8] * z) * scale;
  return {
    x: width / 2 + ((px * CAMERA.focal) / depth + shift[0]) * (height / 2),
    y: height / 2 - ((py * CAMERA.focal) / depth + shift[1]) * (height / 2),
    depth,
  };
}

export type Renderer = {
  /**
   * Draws `kind`, turned by `turn`, onto `target` (a 2D context) at its canvas's size. False when
   * there is nothing to draw with (the context is lost) or nowhere to draw (no pixels).
   */
  draw: (target: CanvasRenderingContext2D, kind: ObjectKind, turn: Turn) => boolean;
  /**
   * A picture comes on screen (true) or leaves it (false). While none is on screen, the hidden
   * canvas gives its pixels back; the next draw takes them again.
   */
  show: (on: boolean) => void;
};

/** The shared renderer, made on first use, and how many cards hold it. */
let shared: { renderer: Renderer; dispose: () => void } | null = null;
let holders = 0;

/** Takes the shared renderer (made on first use), or null where WebGL is not available. */
export function holdRenderer(): Renderer | null {
  shared ??= createRenderer();
  if (!shared) return null;
  holders += 1;
  return shared.renderer;
}

/** Gives the renderer back; the last card to do so frees its context. */
export function releaseRenderer() {
  holders = Math.max(0, holders - 1);
  if (holders || !shared) return;
  shared.dispose();
  shared = null;
}

function compile(gl: WebGLRenderingContext, 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)) return shader;
  console.error(gl.getShaderInfoLog(shader));
  gl.deleteShader(shader);
  return null;
}

/** The program and its inputs, on a new or restored context. */
function prepare(gl: WebGLRenderingContext): Resources | null {
  const program = gl.createProgram();
  const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
  const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
  if (!program || !vertex || !fragment) return null;
  gl.attachShader(program, vertex);
  gl.attachShader(program, fragment);
  gl.linkProgram(program);
  gl.deleteShader(vertex);
  gl.deleteShader(fragment);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program));
    return null;
  }
  gl.useProgram(program);
  gl.enable(gl.DEPTH_TEST);
  const position = gl.getAttribLocation(program, 'aPosition');
  const normal = gl.getAttribLocation(program, 'aNormal');
  gl.enableVertexAttribArray(position);
  gl.enableVertexAttribArray(normal);
  const uniforms = Object.fromEntries(
    UNIFORMS.map((name) => [name, gl.getUniformLocation(program, name)]),
  ) as Uniforms;
  return { uniforms, position, normal, meshes: {} };
}

function createRenderer() {
  // The canvas WebGL draws on: never shown, as large as the largest picture asked of it.
  const canvas = document.createElement('canvas');
  canvas.width = 1;
  canvas.height = 1;
  const gl = canvas.getContext('webgl', {
    alpha: false,
    antialias: true,
    depth: true,
    powerPreference: 'low-power',
  });
  if (!gl) return null;
  let resources = prepare(gl);
  if (!resources) return null;

  /** A mesh's buffers, made the first time it is drawn. */
  const meshOf = (kind: ObjectKind) => {
    if (!resources) return null;
    const made = resources.meshes[kind];
    if (made) return made;
    const mesh: Mesh = kind === 'disc' ? discMesh() : knotMesh();
    const vertices = gl.createBuffer();
    const indices = gl.createBuffer();
    if (!vertices || !indices) return null;
    gl.bindBuffer(gl.ARRAY_BUFFER, vertices);
    gl.bufferData(gl.ARRAY_BUFFER, mesh.vertices, gl.STATIC_DRAW);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indices);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW);
    return (resources.meshes[kind] = { vertices, indices, count: mesh.indices.length });
  };

  const onLost = (event: Event) => {
    event.preventDefault();
    resources = null;
  };
  const onRestored = () => {
    resources = prepare(gl);
  };
  canvas.addEventListener('webglcontextlost', onLost);
  canvas.addEventListener('webglcontextrestored', onRestored);
  let shown = 0;

  const renderer: Renderer = {
    show(on) {
      shown = Math.max(0, shown + (on ? 1 : -1));
      if (shown) return;
      canvas.width = 1;
      canvas.height = 1;
    },
    draw(target, kind, turn) {
      const { width, height } = target.canvas;
      const mesh = meshOf(kind);
      if (!resources || !mesh || !width || !height) return false;
      if (canvas.width < width || canvas.height < height) {
        canvas.width = Math.max(canvas.width, width);
        canvas.height = Math.max(canvas.height, height);
      }
      const look = LOOKS[kind];
      const { uniforms, position, normal } = resources;
      gl.viewport(0, 0, width, height);
      gl.clearColor(look.backdrop[0], look.backdrop[1], look.backdrop[2], 1);
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
      gl.uniform2f(uniforms.uLens, (CAMERA.focal * height) / width, CAMERA.focal);
      gl.uniform2f(uniforms.uShift, (look.shift[0] * height) / width, look.shift[1]);
      gl.uniform1f(uniforms.uSize, (look.size * CAMERA.distance) / CAMERA.focal);
      gl.uniformMatrix3fv(uniforms.uTurn, false, turn);
      gl.uniform3fv(uniforms.uDeep, look.deep);
      gl.uniform3fv(uniforms.uGlow, look.glow);
      gl.uniform3fv(uniforms.uRim, look.rim);
      gl.uniform3fv(uniforms.uBackdrop, look.backdrop);
      gl.uniform1f(uniforms.uClear, look.clear);
      gl.uniform1f(uniforms.uDepth, look.depth);
      gl.uniform2fv(uniforms.uFall, look.fall);
      gl.uniform1f(uniforms.uSheen, look.sheen);
      gl.uniform1f(uniforms.uGather, look.gather);
      gl.uniform1f(uniforms.uBand, look.band);
      gl.uniform1f(uniforms.uGloss, look.gloss);
      gl.uniform1f(uniforms.uSide, look.side);
      gl.uniform1f(uniforms.uDome, look.dome);
      gl.uniform1f(uniforms.uDisc, kind === 'disc' ? 1 : 0);
      gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vertices);
      gl.vertexAttribPointer(position, 3, gl.FLOAT, false, 24, 0);
      gl.vertexAttribPointer(normal, 3, gl.FLOAT, false, 24, 12);
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, mesh.indices);
      gl.drawElements(gl.TRIANGLES, mesh.count, gl.UNSIGNED_SHORT, 0);
      // The picture is the bottom left corner of the canvas (WebGL counts its rows upward).
      target.drawImage(canvas, 0, canvas.height - height, width, height, 0, 0, width, height);
      return true;
    },
  };

  return {
    renderer,
    dispose() {
      canvas.removeEventListener('webglcontextlost', onLost);
      canvas.removeEventListener('webglcontextrestored', onRestored);
      gl.getExtension('WEBGL_lose_context')?.loseContext();
      canvas.width = 0;
      canvas.height = 0;
    },
  };
}
```

### object-cards/shapes.ts

```tsx
/*
 * The two objects, as meshes built once: a glass disc turned on a lathe, and a torus knot swept
 * along its curve. Each is a list of triangles over vertices that carry a position and a normal,
 * interleaved (x, y, z, nx, ny, nz). Both fit in a sphere of radius 1 around the origin.
 */

export type Mesh = {
  /** Six floats per vertex: its position, then its normal. */
  vertices: Float32Array;
  /** Three vertex numbers per triangle. */
  indices: Uint16Array;
};

export type Vec3 = [number, number, number];

/**
 * The disc's cross-section, in units of its radius, from the middle of its bottom face round its
 * edge to the middle of its top face, as (distance from the axis, height): a flat bottom, a
 * straight side, a flat-topped lip, and a top face sunk a little inside the lip, like a dish.
 * The corners are rounded when the mesh is built.
 */
export const DISC_SECTION: readonly [number, number][] = [
  [0, -0.21],
  [0.9, -0.21],
  [1, -0.13],
  [1, 0.13],
  [0.97, 0.21],
  [0.91, 0.21],
  [0.85, 0.08],
  [0.45, 0.065],
  [0, 0.06],
];

/** The top face, inside the lip: where the disc's mark can go. */
export const DISC_FACE = { height: 0.06, radius: 0.8 } as const;

/** The top of the lip, between its inner and outer rounds. */
export const DISC_LIP = { height: 0.21, radius: 0.94 } as const;

/** The knot: the times it winds around its axis and through its hole, and its tube's radius. */
export const KNOT = { p: 2, q: 5, tube: 0.17 } as const;

/** What scales the knot into the unit sphere: its curve reaches 1.5 from the middle, its tube more. */
const KNOT_SCALE = 1 / (1.5 + KNOT.tube);

/**
 * The disc: its cross-section turned around the y axis. The section's corners are rounded by
 * cutting them again and again (Chaikin's method: each segment keeps its middle half).
 */
export function discMesh(): Mesh {
  let section = [...DISC_SECTION];
  for (let pass = 0; pass < 4; pass += 1) {
    const next: [number, number][] = [section[0]];
    for (let i = 0; i < section.length - 1; i += 1) {
      const [r0, y0] = section[i];
      const [r1, y1] = section[i + 1];
      next.push([r0 * 0.75 + r1 * 0.25, y0 * 0.75 + y1 * 0.25]);
      next.push([r0 * 0.25 + r1 * 0.75, y0 * 0.25 + y1 * 0.75]);
    }
    next.push(section[section.length - 1]);
    section = next;
  }
  // Normals of the section, from its neighbours (outward: the section runs counterclockwise).
  const normals = section.map((_, i) => {
    const [r0, y0] = section[Math.max(0, i - 1)];
    const [r1, y1] = section[Math.min(section.length - 1, i + 1)];
    const dr = r1 - r0;
    const dy = y1 - y0;
    const length = Math.hypot(dr, dy) || 1;
    return [dy / length, -dr / length] as const;
  });

  const AROUND = 96;
  const vertices = new Float32Array(section.length * (AROUND + 1) * 6);
  let v = 0;
  for (let j = 0; j <= AROUND; j += 1) {
    const angle = (2 * Math.PI * j) / AROUND;
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    section.forEach(([r, y], i) => {
      const [nr, ny] = normals[i];
      vertices.set([r * cos, y, r * sin, nr * cos, ny, nr * sin], v);
      v += 6;
    });
  }
  return { vertices, indices: grid(AROUND, section.length - 1) };
}

/** A point of the knot's curve at `u` (0 to 2πp), scaled to fit the unit sphere with its tube. */
export function knotPoint(u: number): Vec3 {
  const { p, q } = KNOT;
  const winding = (q / p) * u;
  const radius = (2 + Math.cos(winding)) * 0.5;
  return [
    radius * Math.cos(u) * KNOT_SCALE,
    radius * Math.sin(u) * KNOT_SCALE,
    Math.sin(winding) * 0.5 * KNOT_SCALE,
  ];
}

/** The knot's tube's radius, at the knot's scale. */
export const KNOT_TUBE = KNOT.tube * KNOT_SCALE;

/**
 * The knot: a tube swept along the curve. At each step a frame is set from the curve's direction
 * and the line from the knot's middle (which never lines up with it on a torus knot), and a ring
 * of vertices goes around the curve in that frame.
 */
export function knotMesh(): Mesh {
  const ALONG = 360;
  const AROUND = 22;
  const end = 2 * Math.PI * KNOT.p;
  const vertices = new Float32Array((ALONG + 1) * (AROUND + 1) * 6);
  let v = 0;
  for (let i = 0; i <= ALONG; i += 1) {
    const u = (end * i) / ALONG;
    const here = knotPoint(u);
    const next = knotPoint(u + 0.01);
    const tangent = sub(next, here);
    const binormal = normalize(cross(tangent, add(next, here)));
    const normal = normalize(cross(binormal, tangent));
    for (let j = 0; j <= AROUND; j += 1) {
      const angle = (2 * Math.PI * j) / AROUND;
      const out: Vec3 = [
        Math.cos(angle) * normal[0] + Math.sin(angle) * binormal[0],
        Math.cos(angle) * normal[1] + Math.sin(angle) * binormal[1],
        Math.cos(angle) * normal[2] + Math.sin(angle) * binormal[2],
      ];
      vertices.set(
        [
          here[0] + KNOT_TUBE * out[0],
          here[1] + KNOT_TUBE * out[1],
          here[2] + KNOT_TUBE * out[2],
          ...out,
        ],
        v,
      );
      v += 6;
    }
  }
  return { vertices, indices: grid(ALONG, AROUND) };
}

/** Two triangles for each cell of a `columns` × `rows` grid (its vertices column by column). */
function grid(columns: number, rows: number) {
  const indices = new Uint16Array(columns * rows * 6);
  let k = 0;
  for (let c = 0; c < columns; c += 1) {
    for (let r = 0; r < rows; r += 1) {
      const a = c * (rows + 1) + r;
      const b = a + rows + 1;
      indices.set([a, b, a + 1, b, b + 1, a + 1], k);
      k += 6;
    }
  }
  return indices;
}

const add = (a: Vec3, b: Vec3): Vec3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const cross = (a: Vec3, b: Vec3): Vec3 => [
  a[1] * b[2] - a[2] * b[1],
  a[2] * b[0] - a[0] * b[2],
  a[0] * b[1] - a[1] * b[0],
];
const normalize = (a: Vec3): Vec3 => {
  const length = Math.hypot(a[0], a[1], a[2]) || 1;
  return [a[0] / length, a[1] / length, a[2] / length];
};
```

### object-cards/spin.ts

```tsx
import type { ObjectKind } from './renderer';
import { axisAngle, chain, type Turn } from './turn';

/*
 * How each object turns on its own, as a function of time: where it is after `time` seconds at
 * the normal speed. The knot turns about an axis that leans to the left, showing its narrow side
 * and its five-fold face in turn, while it also turns slowly about its own axis. The disc spins
 * about its own axis, leaning back by about 40 degrees, and wobbles a little once a turn, so its
 * face always shows, its edge catches the light below it and its reflections move.
 */

/** The disc's spin, and its wobble with it: a full turn every 3.7 seconds, in radians per second. */
const WOBBLE = (2 * Math.PI) / 3.7;

export function spinAt(kind: ObjectKind, time: number): Turn {
  if (kind === 'knot') {
    return chain(
      axisAngle(0, 0, 1, 0.29),
      axisAngle(0, 1, 0, 0.292 * time - 2.32),
      axisAngle(1, 0, 0, 0.17),
      axisAngle(0, 0, 1, 0.06 - 0.13 * time),
    );
  }
  // The disc's axis is its y: a quarter turn about x brings its top face to the viewer, less
  // `lean` tips it back; `sway` turns the whole in the picture's plane.
  const phase = WOBBLE * time;
  const lean = 0.67 + 0.08 * Math.sin(phase);
  const sway = 0.35 + 0.1 * Math.cos(phase);
  return chain(
    axisAngle(0, 0, 1, sway),
    axisAngle(1, 0, 0, Math.PI / 2 - lean),
    axisAngle(0, 1, 0, phase),
  );
}
```

### object-cards/turn.ts

```tsx
/*
 * Rotations as 3 × 3 matrices, in the column-major order WebGL takes them: element (row r,
 * column c) sits at c * 3 + r. Enough to turn an object about any axis and to stack turns.
 */

export type Turn = Float32Array;

export const identity = (): Turn => new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);

/** A turn by `angle` radians about the axis (x, y, z), which need not be of length 1. */
export function axisAngle(x: number, y: number, z: number, angle: number): Turn {
  const length = Math.hypot(x, y, z);
  if (!length || !angle) return identity();
  const [a, b, c] = [x / length, y / length, z / length];
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  const k = 1 - cos;
  return new Float32Array([
    cos + a * a * k,
    b * a * k + c * sin,
    c * a * k - b * sin,
    a * b * k - c * sin,
    cos + b * b * k,
    c * b * k + a * sin,
    a * c * k + b * sin,
    b * c * k - a * sin,
    cos + c * c * k,
  ]);
}

/** The turn `b` followed by the turn `a` (the product a · b). */
export function multiply(a: Turn, b: Turn): Turn {
  const out = new Float32Array(9);
  for (let column = 0; column < 3; column += 1) {
    for (let row = 0; row < 3; row += 1) {
      out[column * 3 + row] =
        a[row] * b[column * 3] + a[3 + row] * b[column * 3 + 1] + a[6 + row] * b[column * 3 + 2];
    }
  }
  return out;
}

/** The turns, applied right to left: turns(a, b, c) is c first, then b, then a. */
export const chain = (...turns: Turn[]): Turn => turns.reduce((all, turn) => multiply(all, turn));

/**
 * Keeps a turn that is multiplied frame after frame a true rotation: rounding errors would
 * otherwise skew it, slowly. Its columns are made of length 1 and square to each other again.
 */
export function tidy(turn: Turn): Turn {
  const x = [turn[0], turn[1], turn[2]];
  const y = [turn[3], turn[4], turn[5]];
  const lx = Math.hypot(x[0], x[1], x[2]) || 1;
  x[0] /= lx;
  x[1] /= lx;
  x[2] /= lx;
  const along = x[0] * y[0] + x[1] * y[1] + x[2] * y[2];
  y[0] -= along * x[0];
  y[1] -= along * x[1];
  y[2] -= along * x[2];
  const ly = Math.hypot(y[0], y[1], y[2]) || 1;
  y[0] /= ly;
  y[1] /= ly;
  y[2] /= ly;
  const z = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]];
  return new Float32Array([...x, ...y, ...z]);
}

/**
 * The turn brought back toward no turn at all: the same axis, `keep` of the angle. Its axis and
 * angle are read from the matrix as a quaternion (each part from the diagonal, its sign from the
 * difference across it). A turn of under a tenth of a degree left becomes none.
 */
export function shrink(turn: Turn, keep: number): Turn {
  const [m00, m10, m20, m01, m11, m21, m02, m12, m22] = turn;
  const part = (value: number) => Math.sqrt(Math.max(0, value)) / 2;
  const w = part(1 + m00 + m11 + m22);
  const x = Math.sign(m21 - m12) * part(1 + m00 - m11 - m22);
  const y = Math.sign(m02 - m20) * part(1 - m00 + m11 - m22);
  const z = Math.sign(m10 - m01) * part(1 - m00 - m11 + m22);
  const angle = 2 * Math.atan2(Math.hypot(x, y, z), w) * keep;
  return angle < 0.002 ? identity() : axisAngle(x, y, z, angle);
}

/** Whether a turn is no turn at all. */
export const isIdentity = (turn: Turn) =>
  turn[0] === 1 && turn[4] === 1 && turn[8] === 1 && !turn[1] && !turn[3] && !turn[6];
```

### object-cards/stand-in.tsx

```tsx
import { memo, useId } from 'react';
import { LOOKS, project, type ObjectKind } from './renderer';
import { DISC_FACE, DISC_LIP, DISC_SECTION, KNOT, KNOT_TUBE, knotPoint } from './shapes';
import { spinAt } from './spin';

/*
 * The objects as plain drawings, for the first frame (the server's, before WebGL starts) and for
 * browsers without WebGL. Each is projected from the same shapes, turned as the renderer turns
 * them at the start, so the picture does not jump when the canvas takes over:
 * - the disc is a few rings of its section (the outline, the lip, the well inside it, the face),
 *   filled with gradients, and the mark's outline;
 * - the knot is its curve cut into short pieces, painted from the farthest to the nearest, each
 *   a thick stroke with a pale rim, a deep core and a highlight along it, so the near tube covers
 *   the far one where they cross.
 */

/** The picture's size, in the units of its view box: the media area's proportions. */
const PICTURE = { width: 245, height: 183 } as const;

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

const where = (kind: ObjectKind, point: readonly [number, number, number]): Point =>
  project(kind, spinAt(kind, 0), point, PICTURE.width, PICTURE.height);

const pathOf = (points: Point[], closed = true) =>
  points.map((p, i) => `${i ? 'L' : 'M'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join('') +
  (closed ? 'Z' : '');

/** A ring of the disc: the circle of `radius` at `height`, as seen. */
const ring = (radius: number, height: number, steps = 72) =>
  Array.from({ length: steps }, (_, i) => {
    const angle = (2 * Math.PI * i) / steps;
    return where('disc', [radius * Math.cos(angle), height, radius * Math.sin(angle)]);
  });

/** The smallest convex outline around `points` (Andrew's monotone chain). */
function hull(points: Point[]) {
  const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
  const turn = (o: Point, a: Point, b: Point) =>
    (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
  const half = (list: Point[]) => {
    const out: Point[] = [];
    for (const p of list) {
      while (out.length > 1 && turn(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
      out.push(p);
    }
    out.pop();
    return out;
  };
  return [...half(sorted), ...half([...sorted].reverse())];
}

/** The mark's two pieces, as outlines on the disc's face (see `mark` in the renderer's shader). */
function markPieces() {
  const cos = Math.cos(Math.PI / 6);
  const sin = Math.sin(Math.PI / 6);
  const start = Math.asin(0.04 / 0.3);
  return [1, -1].map((side) => {
    const points: Point[] = [];
    for (let i = 0; i <= 24; i += 1) {
      const angle = start + ((Math.PI - 2 * start) * i) / 24;
      // In the mark's own frame, then turned back by 30 degrees onto the face.
      const qx = 0.3 * Math.cos(angle) + side * 0.06;
      const qy = side * 0.3 * Math.sin(angle);
      const px = cos * qx + sin * qy;
      const py = -sin * qx + cos * qy;
      points.push(where('disc', [px, DISC_FACE.height, -py]));
    }
    return pathOf(points);
  });
}

function DiscDrawing({ id }: { id: string }) {
  const outline = pathOf(hull(DISC_SECTION.flatMap(([radius, height]) => ring(radius, height))));
  const lip = pathOf(ring(DISC_LIP.radius, DISC_LIP.height));
  // The well inside the lip: its far wall shows dark above the face, which it also clips.
  const well = pathOf(ring(DISC_LIP.radius - 0.04, DISC_LIP.height));
  const face = pathOf(ring(DISC_FACE.radius + 0.05, DISC_FACE.height));
  return (
    <>
      <defs>
        <linearGradient id={`${id}-side`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#4d6db8" />
          <stop offset="1" stopColor="#9db8ea" />
        </linearGradient>
        <linearGradient id={`${id}-face`} x1="0.15" y1="0.1" x2="0.85" y2="0.95">
          <stop offset="0" stopColor="#101c3c" />
          <stop offset="0.4" stopColor="#3a63b8" />
          <stop offset="0.7" stopColor="#7499db" />
          <stop offset="1" stopColor="#86a8e6" />
        </linearGradient>
        <clipPath id={`${id}-well`}>
          <path d={well} />
        </clipPath>
      </defs>
      <path d={outline} fill={`url(#${id}-side)`} />
      <path d={lip} fill="#e2e8f0" />
      <path d={well} fill="#14224a" />
      <path d={face} fill={`url(#${id}-face)`} clipPath={`url(#${id}-well)`} />
      {markPieces().map((d) => (
        <path key={d} d={d} fill="none" stroke="rgb(255 255 255 / 0.7)" strokeWidth="0.8" />
      ))}
    </>
  );
}

function KnotDrawing() {
  // The curve, cut into pieces of a few points; each piece also takes the last point of the one
  // before and the first of the one after, so neighbours overlap and meet without a seam.
  const STEPS = 240;
  const PIECE = 4;
  const end = 2 * Math.PI * KNOT.p;
  const points = Array.from({ length: STEPS }, (_, i) =>
    where('knot', knotPoint((end * i) / STEPS)),
  );
  const pieces = Array.from({ length: STEPS / PIECE }, (_, k) => {
    const run = Array.from(
      { length: PIECE + 2 },
      (_, i) => points[(k * PIECE + i - 1 + STEPS) % STEPS],
    );
    return { run, depth: run.reduce((sum, p) => sum + p.depth, 0) / run.length };
  }).sort((a, b) => b.depth - a.depth);
  // The tube's width as seen: the object's radius fills `size` of the picture's half height.
  const width = KNOT_TUBE * LOOKS.knot.size * PICTURE.height;
  return pieces.map(({ run }) => {
    const d = pathOf(run, false);
    return (
      <g key={d} fill="none" strokeLinejoin="round">
        <path d={d} stroke="#8fb0f0" strokeWidth={width} />
        <path d={d} stroke="#2235b0" strokeWidth={width * 0.84} />
        <path d={d} stroke="#0d1650" strokeWidth={width * 0.5} />
        <path d={d} stroke="rgb(170 195 255 / 0.35)" strokeWidth={width * 0.08} />
      </g>
    );
  });
}

/** The object `kind` as a drawing, at the size of its box (drawn once: it never changes). */
export const StandIn = memo(function StandIn({
  kind,
  className,
}: {
  kind: ObjectKind;
  className?: string;
}) {
  const id = useId().replace(/[^\w-]/g, '');
  return (
    <svg
      className={className}
      viewBox={`0 0 ${PICTURE.width} ${PICTURE.height}`}
      preserveAspectRatio="xMidYMid slice"
      aria-hidden="true"
    >
      {kind === 'disc' ? <DiscDrawing id={id} /> : <KnotDrawing />}
    </svg>
  );
});
```

### object-cards/icons.tsx

```tsx
/*
 * The cards' small icons, drawn on a 12 × 12 grid with strokes in the text's colour. They are
 * decorative: each sits beside a text or inside a labelled control.
 */

const STROKE = {
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 1,
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
} as const;

/** An arrow up and to the right: open the item. */
export function ArrowIcon() {
  return (
    <svg viewBox="0 0 12 12" aria-hidden="true">
      <path {...STROKE} d="M3.5 8.5 8.5 3.5M4.6 3.5h3.9v3.9" />
    </svg>
  );
}

/** Two sheets, one over the other: copy. The front one is filled with the button's colour. */
export function CopyIcon() {
  return (
    <svg viewBox="0 0 12 12" aria-hidden="true">
      <rect {...STROKE} x="1.5" y="1.5" width="6.5" height="6.5" rx="1.6" />
      <rect {...STROKE} x="4" y="4" width="6.5" height="6.5" rx="1.6" fill="var(--button)" />
    </svg>
  );
}

/** A tick: copied. */
export function CheckIcon() {
  return (
    <svg viewBox="0 0 12 12" aria-hidden="true">
      <path {...STROKE} d="m2.6 6.3 2.3 2.3 4.6-5" />
    </svg>
  );
}

/** A sheet with an arrow out of it: share. */
export function ShareIcon() {
  return (
    <svg viewBox="0 0 12 12" aria-hidden="true">
      <rect {...STROKE} x="2.5" y="1.5" width="7" height="9" rx="1.6" />
      <path {...STROKE} d="M6 7.8V4.3M4.5 5.6 6 4.1l1.5 1.5" />
    </svg>
  );
}
```

### object-cards/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 onto a control and press it, or grab an object and drag it, 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 while the demo plays needs a layout, because one here in the same
 * frame as one in the cards would make the browser lay out the whole page (see the note at the
 * top of the stylesheet). So it moves by animations the compositor plays (a drag too: the dragged
 * object 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 onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Glides the pointer to a point of the screen, in client px. */
  moveToPoint: (point: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /**
   * Grabs, drags to a point of the screen (client px) over `ms`, and lets go. `step` gets the
   * share of the way done on every frame, so the dragged object moves with the hand.
   */
  drag: (to: Point, ms: number, step: (share: number) => 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 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);
      }),
    );

  /** A point of the screen in the stage's own px (the stage may sit in a scaled container). */
  const toStage = (point: Point): Point => {
    const frameBox = stage.getBoundingClientRect();
    const scale = frameBox.width / stage.offsetWidth || 1;
    return { x: (point.x - frameBox.left) / scale, y: (point.y - frameBox.top) / scale };
  };

  const glide = (to: Point) => {
    const from = at;
    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(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    return glide(toStage({ x: box.left + box.width * share.x, y: box.top + box.height * share.y }));
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  const drag: Demo['drag'] = (point, ms, onStep) => {
    const from = at;
    const to = toStage(point);
    at = to;
    pointer.dataset.grab = '';
    // The hand's path, eased, as an animation; the value reads the same clock every frame.
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const share = easeInOut(i / 16);
      keyframes.push({
        transform: place({
          x: from.x + (to.x - from.x) * share,
          y: from.y + (to.y - from.y) * share,
        }),
      });
    }
    flight?.cancel();
    pointer.style.transform = place(to);
    const motion = pointer.animate(keyframes, { duration: ms, 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 t =
            motion.playState === 'finished' ? 1 : clamp(typeof time === 'number' ? time / ms : 0);
          onStep(easeInOut(t));
          if (t < 1) frame = requestAnimationFrame(tick);
          else {
            frame = 0;
            delete pointer.dataset.grab;
            resolve();
          }
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({
    wait,
    moveTo,
    moveToPoint: (point) => glide(toStage(point)),
    press,
    drag,
  }).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();
    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,
  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.25))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip 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 drags. It is decorative:
 * the demo's effects show on the controls themselves.
 */
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>
  );
}
```

### object-cards/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 };
}
```