# Wireframe laptop

> An isometric hairline laptop that types to itself; keys press under the pointer and a click ripples out.

An open laptop drawn in hairlines, in true isometric projection, that types a phrase by itself. Keys press down under the pointer, a click sends a ripple across its neighbours, and with the drawing focused your own keyboard presses the matching key. An intensity slider sets how hard keys go down; the theme button switches light and dark, and every line draws in heavy for a moment before it settles back to a hairline.

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

Category: Motion.

## Anatomy

1. **Card**: A framed box that keeps the drawing’s 1278 × 1023 proportions at any width, so the laptop scales and stays sharp.
2. **Name**: The `name` prop, quiet, in the top left corner. It also names the drawing for screen readers.
3. **Key label**: Names the key under the pointer or held on the keyboard, and says “rest” when nothing is.
4. **Lid**: Tipped back on its hinge, with the screen as a faint rounded line inside its bezel.
5. **Key**: A solid of three paths: fill, crease and outline. Pressed, its top lowers towards the deck and its outline darkens and thickens.
6. **Intensity**: A range from 0.2 to 5: how deep keys go, how wide the pointer presses and how far a ripple travels.
7. **Theme button**: Says the scheme in use and switches it; every line draws heavy for 240 ms and settles back to a hairline.
8. **Caption**: One line under the controls, then the small tags. Empty `caption` and `tags` leave them out.

## When to use

- A hero or an empty state that should show a product is about typing, coding or writing, without a screenshot.
- A quiet, playful drawing that rewards a hover: keys press under the pointer and a click ripples out.
- A page in a monochrome, technical style, where a hairline drawing fits better than a photo or a 3D model.
- Showing off a theme switch: the drawing follows the page’s `color-scheme` and flashes when it changes.

## When not to use

- Showing the real product. Use a screenshot or a live demo inside a device frame.
- A text field or any real input: it presses keys, it doesn’t take text. Use a text field.
- Dense pages with several moving things: its own typing and demo pointer pull the eye. Set `typing={false}` and `autoplay={false}`, or leave it out.
- Small spaces under about 300 px wide, where the keys are too small to aim at.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/wireframe-laptop.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 { WireframeLaptop } from './wireframe-laptop';

export function Hero() {
  return (
    <WireframeLaptop
      phrase="shipping on friday. "
      defaultIntensity={3}
      onKey={(key) => console.log(key)}
      autoplay={false}
    />
  );
}
```

## States

- **Light, at rest**: With `colorScheme="light"`, no typing and no demo: every key up, the label says rest.
- **Dark**: With `colorScheme="dark"` the card goes near-black and the lines a dim grey. Without the prop, the page’s scheme decides.
- **Pointer over a key**: The key under the pointer goes down, its neighbours less the further they are.
- **Keys held**: Keys held on the keyboard go down as far as the intensity allows, with no falloff; the label names the last.
- **Low intensity**: At 0.6 the pointer presses one key, and only part of the way.
- **Full intensity**: At 5 the press reaches two and a half keys around the pointer, and the key under it goes all the way down.
- **Without caption**: Empty `caption` and `tags`: the card and its controls only.

## How it works

- The laptop is worked out in three dimensions and projected in the 2:1 isometric of technical drawings: a step across goes one unit right and half a unit down, a step towards the front one unit left and half a unit down. Every solid (the deck, the lid tipped back 11° on its hinge, each of the 62 keys) is a rounded rectangle in plan, extruded.
- A solid draws as two lines: its outline, the convex hull of its foot and its top projected, in the grey ink, and the outline of its top in a fainter one, which shows as the crease along its front edges. A fill in the page colour hides what is behind, so the keys paint back to front.
- Each key is an element of its own. Every key has a press from 0 to 1 that eases towards a target each frame, quickly down (25 ms) and a little slower back up (70 ms), so a moving pointer leaves a short wake of keys rising behind it. Only keys that moved are redrawn, their tops lowered towards the deck and their outlines darker and heavier.
- The target is the strongest of what presses a key: the pointer, whose position is projected back onto the keys’ plane and falls off with distance; a key held on the keyboard; the laptop’s own typing; and any ripple, a band travelling out at 9 keys a second. One frame loop runs while anything moves, then stops.
- The intensity (0.2 to 5) sets how deep keys go, how wide the pointer presses and how far a ripple reaches.
- The laptop types its phrase while on screen: a key every 100 to 170 ms, held about 95 ms, and the space bar held longer between words, as one stops to think.
- The line colours and width are registered custom properties (@property), so on a change of scheme one 240 ms animation on the drawing turns every line heavy and dark, holds it, and eases it back to a three-quarter-px hairline.
- Every colour is a light-dark() token, so it follows the page’s color-scheme or the colorScheme prop; the theme button sets its own, read back from a probe so the button names the scheme in use. While untouched, a demo pointer sweeps the keys, clicks one for a ripple and flips the theme.

## API reference

- **name** (`string`, default `'laptop'`): The name in the card’s top left corner.
- **caption** (`string`): The line under the controls; an empty string for none.
- **tags** (`string[]`, default `['01 hit', '02 order', '05 rest', '07 cost']`): The small numbered tags under the caption; an empty list for none.
- **phrase** (`string`, default `'hello world, this laptop types to itself. '`): What the laptop types to itself, again and again.
- **typing** (`boolean`, default `true`): The laptop types the phrase by itself while it is on screen.
- **defaultPressed** (`string[]`): Keys, by their label (“key h”, “space”…), held down from the start; the corner names the last one. Setting it turns the typing and the demo off.
- **defaultPointer** (`string`): The key, by its label, the pointer rests on from the start: it and its neighbours press down, less with distance. Setting it turns the typing and the demo off.
- **intensity** (`number`): How hard keys press, from 0.2 to 5, when you control it.
- **defaultIntensity** (`number`, default `2.6`): The intensity it starts at, when not controlled.
- **onIntensityChange** (`(intensity: number) => void`): Called as the slider moves.
- **onKey** (`(label: string) => void`): Called with a key’s name (“key a”, “space”…) when the reader clicks it or presses it on the keyboard.
- **onColorSchemeChange** (`(scheme: 'light' | 'dark') => void`): Called when the theme button switches the scheme.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer sweeps the keys, clicks one and flips the theme.
- **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. The theme button overrides it until it changes.
- **className** (`string`): Extra class for the root.

## Accessibility

- The drawing is one focusable element (role application, named “laptop: type while it has focus, and its keys press down”) with a visible focus ring; with it focused, keys on the keyboard press the matching key and the corner names it. Tab still moves focus on: its key only taps.
- The intensity is a native range input (role slider) with its label and an output showing its value; the theme button says which scheme is on and which it switches to.
- The drawing itself and the demo pointer are decorative (aria-hidden).
- Under reduced motion presses are instant, a click presses only its key, and there is no typing, no flash and no demo.

## Source

### wireframe-laptop/wireframe-laptop.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import {
  HEIGHT,
  KEYS,
  VIEW,
  deck,
  keyAt,
  keySolid,
  lid,
  project,
  toKeyUnits,
  trackpad,
  well,
  type Key,
} from './geometry';
import { useAutoplay } from './use-autoplay';
import styles from './wireframe-laptop.module.css';

/*
 * Wireframe laptop: an open laptop drawn in hairlines, in isometric projection, that types to
 * itself. Keys press down under the pointer, a click sends a ripple out from the key across its
 * neighbours, and typing on the reader's own keyboard, with the drawing focused, presses the
 * matching key. The corner names the key under the pointer, or "rest".
 *
 * - The drawing is worked out in three dimensions and projected (geometry.ts). Each key is an
 *   element of its own: three paths (its fill, the crease of its top, its outline), so a key that
 *   goes down is redrawn alone, its top lowered towards the deck.
 * - Every key has a press, from 0 (up) to 1 (all the way down), that eases towards a target each
 *   frame: quickly down (25 ms), a little slower back up (70 ms), so a moving pointer leaves a
 *   short wake of keys rising behind it. The target is the strongest of what presses the key:
 *   the pointer (falling off with distance), a key held on the keyboard, the laptop's own typing,
 *   and any ripple passing by. One frame loop runs while anything moves, then stops.
 * - The intensity (0.2 to 5) sets how deep keys go, how wide the pointer presses and how far a
 *   ripple travels. A pressed key's outline darkens and thickens with its press.
 * - The laptop types a phrase on its own while it is on screen: a key every 100 to 170 ms, held
 *   about 95 ms, the space bar held longer between words, as one pauses to think.
 * - Theme: the colours are light-dark() tokens, so the component follows the page's
 *   color-scheme, or the colorScheme prop; the theme button switches it. Whenever the scheme
 *   changes, every line draws in heavy and dark for a moment and settles back to a hairline
 *   (240 ms): the line tokens are registered custom properties, so the keyframes ease them.
 * - While on screen and untouched, a demo pointer sweeps the keyboard, clicks a key for a ripple
 *   and flips the theme now and then; any input of the reader's hands it over.
 * - Reduced motion: the drawing still presses and the label still follows, but presses are
 *   instant, a click presses only its key, and there is no typing, no flash and no demo.
 */

/** How quickly a press follows its target, going down and coming back up (time constants, ms). */
const DOWN_MS = 25;
const UP_MS = 70;
/** How fast a ripple travels (keys per second) and how wide its band is (keys). */
const RIPPLE_SPEED = 9;
const RIPPLE_BAND = 0.8;

/** What the intensity does: the depth of a press, the pointer's reach and a ripple's. */
const depthOf = (intensity: number) => Math.min(1, 0.3 + 0.27 * intensity);
const reachOf = (intensity: number) => 0.5 + 0.4 * intensity;
const rippleOf = (intensity: number) => 1 + 1.4 * intensity;

const MIN_INTENSITY = 0.2;
const MAX_INTENSITY = 5;

/** The character each key types, for the phrase. */
const BY_CHAR = new Map<string, Key>(
  KEYS.filter((key) => key.char).map((key) => [key.char as string, key]),
);
const BY_CODE = new Map<string, Key>(KEYS.flatMap((key) => key.codes.map((code) => [code, key])));

/** The middle of a key's top on the drawing, in the viewBox's units. */
const topOf = (key: Key): Point => {
  const [x, y] = project((key.x0 + key.x1) / 2, (key.y0 + key.y1) / 2, HEIGHT);
  return { x, y };
};

/** How far a point (in units of key) is from a key: from its nearest unit, for the wide ones. */
function distance(key: Key, at: { x: number; y: number }) {
  const dx = Math.max(0, Math.abs(at.x - key.cx) - (key.units - 1) / 2);
  return Math.hypot(dx, at.y - key.cy);
}

type Ripple = { x: number; y: number; start: number; reach: number; depth: number };

type Scheme = 'light' | 'dark';

export function WireframeLaptop({
  name = 'laptop',
  caption = 'An open laptop that types to itself; keys press down under the pointer, and a click ripples out from the key.',
  tags = ['01 hit', '02 order', '05 rest', '07 cost'],
  phrase = 'hello world, this laptop types to itself. ',
  typing = true,
  intensity: intensityProp,
  defaultIntensity = 2.6,
  onIntensityChange,
  onKey,
  onColorSchemeChange,
  autoplay = true,
  colorScheme,
  defaultPressed,
  defaultPointer,
  className = '',
}: {
  /** The name in the card's top left corner. */
  name?: string;
  /** The line under the controls; empty for none. */
  caption?: string;
  /** The small numbered tags under the caption; empty for none. */
  tags?: string[];
  /** What the laptop types to itself, again and again. */
  phrase?: string;
  /** The laptop types the phrase by itself while it is on screen. */
  typing?: boolean;
  /** How hard keys press, from 0.2 to 5 (controlled). */
  intensity?: number;
  /** The intensity it starts at, when not controlled. */
  defaultIntensity?: number;
  onIntensityChange?: (intensity: number) => void;
  /** Called with a key's name (“key a”, “space”…) when the reader presses it: click or keyboard. */
  onKey?: (label: string) => void;
  /** Called when the theme button switches the scheme. */
  onColorSchemeChange?: (scheme: Scheme) => void;
  /** While on screen and untouched, a demo pointer plays with the keys and the theme. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Keys (by their label: “key h”, “space”…) held down from the start, as if typed and held: the
   * corner names the last one. Setting it turns the typing and the demo off.
   */
  defaultPressed?: string[];
  /**
   * The key (by its label) the pointer rests on from the start: it and its neighbours press down,
   * falling off with distance. Setting it turns the typing and the demo off.
   */
  defaultPointer?: string;
  className?: string;
}) {
  const { ref: root, take, playing, visible, reduced } = useAutoplay<HTMLDivElement>(6000);
  const [ownIntensity, setOwnIntensity] = useState(defaultIntensity);
  const intensity = intensityProp ?? ownIntensity;
  /** Held by defaultPressed or defaultPointer: no typing and no demo. */
  const locked = (defaultPressed?.length ?? 0) > 0 || defaultPointer !== undefined;
  const [label, setLabel] = useState(
    () =>
      defaultPressed?.at(-1) ??
      (defaultPointer && KEYS.some((k) => k.label === defaultPointer) ? defaultPointer : 'rest'),
  );
  /** The scheme the theme button set, over the prop and the page; reset when the prop changes. */
  const [override, setOverride] = useState<Scheme | null>(null);
  const [resolved, setResolved] = useState<Scheme>('light');
  const [demoButton, setDemoButton] = useState<'' | 'hover' | 'press'>('');
  const sliderId = useId();

  const svg = useRef<SVGSVGElement>(null);
  const probe = useRef<HTMLSpanElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const themeButton = useRef<HTMLButtonElement>(null);
  /** Each key's three paths: fill, top, outline. */
  const parts = useRef<SVGPathElement[][]>([]);

  /** Everything the frame loop reads and writes, outside React. */
  const engine = useRef({
    press: new Float32Array(KEYS.length),
    drawn: new Float32Array(KEYS.length),
    typedUntil: new Float64Array(KEYS.length),
    held: new Set<number>(),
    hover: null as { x: number; y: number } | null,
    ripples: [] as Ripple[],
    intensity: defaultIntensity,
    reduced: false,
    demo: null as HTMLElement | null,
    frame: 0,
    last: 0,
  });
  engine.current.intensity = intensity;
  engine.current.reduced = reduced;

  // ---- The frame loop -------------------------------------------------------------------

  /** Redraws a key at its press: a lower top, a darker and heavier outline. */
  const drawKey = useCallback((index: number, press: number) => {
    const paths = parts.current[index];
    if (!paths) return;
    const solid = keySolid(KEYS[index], press);
    paths[0].setAttribute('d', solid.outline);
    paths[1].setAttribute('d', solid.top);
    paths[2].setAttribute('d', solid.outline);
    if (press < 0.01) {
      paths[2].style.removeProperty('stroke');
      paths[2].style.removeProperty('stroke-width');
    } else {
      const share = Math.round(Math.min(1, press * 1.15) * 100);
      paths[2].style.stroke = `color-mix(in srgb, var(--wl-press) ${share}%, var(--wl-line))`;
      paths[2].style.strokeWidth = `calc(var(--wl-hair) + ${(press * 0.55).toFixed(2)}px)`;
    }
  }, []);

  /** Where the demo's pointer is, in the viewBox's units. */
  const demoAt = (element: HTMLElement): Point | null => {
    const stage = root.current;
    const drawing = svg.current;
    if (!stage || !drawing) return null;
    const matrix = new DOMMatrixReadOnly(getComputedStyle(element).transform);
    const box = stage.getBoundingClientRect();
    const scale = box.width / stage.offsetWidth || 1;
    // The pointer stands on the tip of its arrow (demo-pointer.tsx).
    const x = box.left + (matrix.m41 + element.offsetWidth * 0.15) * scale;
    const y = box.top + (matrix.m42 + element.offsetHeight * 0.1) * scale;
    return toView(x, y);
  };

  /** A point of the screen in the viewBox's units. */
  const toView = (clientX: number, clientY: number): Point | null => {
    const drawing = svg.current;
    if (!drawing) return null;
    const rect = drawing.getBoundingClientRect();
    if (!rect.width) return null;
    return {
      x: ((clientX - rect.left) * VIEW.width) / rect.width,
      y: ((clientY - rect.top) * VIEW.height) / rect.height,
    };
  };

  const setHoverLabel = useEffectEvent((next: string) => {
    setLabel((now) => (now === next ? now : next));
  });

  /** Points the pointer's presses (and the corner label) at a point of the drawing, or nowhere. */
  const hoverAt = useCallback((at: Point | null) => {
    const state = engine.current;
    if (!at) {
      state.hover = null;
      if (!state.held.size) setHoverLabel('rest');
      return;
    }
    const key = keyAt(at.x, at.y);
    // Off the keyboard, the pointer presses nothing.
    state.hover = key ? toKeyUnits(at.x, at.y) : null;
    if (!state.held.size) setHoverLabel(key ? key.label : 'rest');
  }, []);

  const tick = useCallback(
    (now: number) => {
      const state = engine.current;
      state.frame = 0;
      const dt = state.last ? Math.min(64, now - state.last) : 16;
      state.last = now;
      if (state.demo) hoverAt(demoAt(state.demo));
      const depth = depthOf(state.intensity);
      const reach = reachOf(state.intensity);
      state.ripples = state.ripples.filter(
        (ripple) => ((now - ripple.start) / 1000) * RIPPLE_SPEED < ripple.reach + RIPPLE_BAND * 2,
      );
      let moving = state.ripples.length > 0 || state.demo !== null;
      const down = 1 - Math.exp(-dt / DOWN_MS);
      const up = 1 - Math.exp(-dt / UP_MS);
      for (let i = 0; i < KEYS.length; i += 1) {
        const key = KEYS[i];
        let target = 0;
        if (state.hover) {
          const d = distance(key, state.hover) / reach;
          if (d < 1) target = depth * (1 - d * d) ** 1.5;
        }
        if (state.held.has(i) || state.typedUntil[i] > now) target = Math.max(target, depth);
        if (state.typedUntil[i] > now) moving = true;
        for (const ripple of state.ripples) {
          const d = distance(key, ripple);
          if (d > ripple.reach) continue;
          const front = ((now - ripple.start) / 1000) * RIPPLE_SPEED;
          const band = Math.exp(-(((front - d) / RIPPLE_BAND) ** 2));
          target = Math.max(target, ripple.depth * band * (1 - d / (ripple.reach + 1)) ** 0.7);
        }
        const from = state.press[i];
        const next = state.reduced ? target : from + (target - from) * (target > from ? down : up);
        state.press[i] = Math.abs(next - target) < 0.002 ? target : next;
        if (state.press[i] !== target) moving = true;
        // Redrawn when it has moved enough to show, and once more when it is back up.
        const press = state.press[i];
        if (Math.abs(press - state.drawn[i]) > 0.004 || (press === 0 && state.drawn[i] !== 0)) {
          state.drawn[i] = state.press[i];
          drawKey(i, state.press[i]);
        }
      }
      if (moving) state.frame = requestAnimationFrame(tick);
      else state.last = 0;
    },
    // demoAt and toView read refs only.
    [drawKey, hoverAt],
  );

  /** Starts the frame loop, if it is not running. */
  const kick = useCallback(() => {
    const state = engine.current;
    if (!state.frame) state.frame = requestAnimationFrame(tick);
  }, [tick]);

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

  // The keys held, or the pointer's resting place, from the start.
  // Read once, on mount, as default values are.
  const [initial] = useState(() => ({ pressed: defaultPressed, pointer: defaultPointer }));
  useLayoutEffect(() => {
    const state = engine.current;
    for (const label of initial.pressed ?? []) {
      for (const key of KEYS) if (key.label === label) state.held.add(key.index);
    }
    const resting = initial.pointer && KEYS.find((key) => key.label === initial.pointer);
    if (resting) {
      const top = topOf(resting);
      state.hover = toKeyUnits(top.x, top.y);
    }
    kick();
  }, [initial, kick]);

  // A new intensity changes the targets of keys already pressed.
  useEffect(() => {
    kick();
  }, [intensity, kick]);

  /** A ripple from a key, out across its neighbours; under reduced motion, the key alone. */
  const ripple = useCallback(
    (key: Key) => {
      const state = engine.current;
      const now = performance.now();
      if (state.reduced) {
        state.typedUntil[key.index] = now + 160;
      } else {
        state.ripples.push({
          x: key.cx,
          y: key.cy,
          start: now,
          reach: rippleOf(state.intensity),
          depth: depthOf(state.intensity),
        });
      }
      kick();
      // Reduced motion's press ends by itself: one more frame lets the key back up.
      window.setTimeout(kick, 180);
    },
    [kick],
  );

  // ---- The laptop types by itself ------------------------------------------------------

  const typingOn = !locked && typing && visible && !reduced && phrase.length > 0;
  useEffect(() => {
    if (!typingOn) return;
    const state = engine.current;
    let index = 0;
    let timer = 0;
    const type = () => {
      const char = phrase[index % phrase.length].toLowerCase();
      index += 1;
      const key = BY_CHAR.get(char);
      const now = performance.now();
      let next = 100 + Math.random() * 70;
      if (key) {
        if (char === ' ') {
          // Between words the space bar stays down a while, as one stops to think.
          const hold = 600 + Math.random() * 700;
          state.typedUntil[key.index] = now + hold;
          next = hold + 90;
        } else state.typedUntil[key.index] = now + 95;
        kick();
      }
      if (index % phrase.length === 0) next += 1200;
      timer = window.setTimeout(type, next);
    };
    timer = window.setTimeout(type, 400);
    return () => {
      window.clearTimeout(timer);
      state.typedUntil.fill(0);
      kick();
    };
  }, [typingOn, phrase, kick]);

  // ---- The reader's pointer and keyboard -----------------------------------------------

  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.pointerType === 'touch' && !event.buttons) return;
    take();
    engine.current.demo = null;
    hoverAt(toView(event.clientX, event.clientY));
    kick();
  };
  const onPointerLeave = () => {
    hoverAt(null);
    kick();
  };
  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.pointerType !== 'touch') return;
    hoverAt(toView(event.clientX, event.clientY));
    kick();
  };
  const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
    const at = toView(event.clientX, event.clientY);
    const key = at && keyAt(at.x, at.y);
    if (key && event.button === 0) {
      ripple(key);
      onKey?.(key.label);
    }
    if (event.pointerType === 'touch') {
      hoverAt(null);
      kick();
    }
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.metaKey && event.code !== 'MetaLeft' && event.code !== 'MetaRight') return;
    const key = BY_CODE.get(event.code);
    if (!key) return;
    const state = engine.current;
    if (event.code === 'Tab') {
      // Tab still moves the focus on: its key only taps.
      state.typedUntil[key.index] = performance.now() + 140;
      setLabel(key.label);
      window.setTimeout(() => setLabel('rest'), 260);
      kick();
      return;
    }
    // Keys that would scroll the page or type into it press the laptop's key instead.
    if (!event.ctrlKey && !event.altKey) event.preventDefault();
    if (!event.repeat) onKey?.(key.label);
    state.held.add(key.index);
    setLabel(key.label);
    kick();
  };
  const onKeyUp = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const key = BY_CODE.get(event.code);
    const state = engine.current;
    if (key) state.held.delete(key.index);
    if (!state.held.size) setLabel('rest');
    kick();
  };
  const onBlur = () => {
    engine.current.held.clear();
    setLabel('rest');
    kick();
  };

  // ---- Theme ---------------------------------------------------------------------------

  // The theme button's choice lasts until the prop changes.
  const [lastProp, setLastProp] = useState(colorScheme);
  if (lastProp !== colorScheme) {
    setLastProp(colorScheme);
    setOverride(null);
  }
  const scheme = override ?? colorScheme;

  /** Reads which scheme the colours resolved to, from a probe whose colour is light-dark(). */
  const readScheme = useEffectEvent(() => {
    const element = probe.current;
    if (!element) return;
    const dark = getComputedStyle(element).color === 'rgb(255, 255, 255)';
    setResolved(dark ? 'dark' : 'light');
  });
  useLayoutEffect(() => {
    readScheme();
  }, [scheme]);
  // The page may change its scheme: the system's setting, or a class or attribute on the way up.
  useEffect(() => {
    let frame = 0;
    const later = () => {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(() => readScheme());
    };
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    media.addEventListener('change', later);
    const observer = new MutationObserver(later);
    observer.observe(document.documentElement, {
      attributes: true,
      subtree: true,
      attributeFilter: ['class', 'data-color-scheme', 'data-theme', 'data-scheme'],
    });
    return () => {
      cancelAnimationFrame(frame);
      media.removeEventListener('change', later);
      observer.disconnect();
    };
  }, []);

  // Whenever the scheme changes, the lines draw in heavy, then settle to a hairline.
  const shown = useRef<Scheme | null>(null);
  useLayoutEffect(() => {
    const element = root.current;
    const before = shown.current;
    shown.current = resolved;
    if (!element || before === null || before === resolved || reduced) return;
    element.removeAttribute('data-flash');
    void element.offsetWidth;
    element.setAttribute('data-flash', '');
  }, [resolved, reduced, root]);

  const flip = useCallback(() => {
    const next: Scheme = resolved === 'light' ? 'dark' : 'light';
    setOverride(next);
    onColorSchemeChange?.(next);
  }, [resolved, onColorSchemeChange]);

  // ---- The demo ------------------------------------------------------------------------

  const demo = !locked && autoplay && playing;
  const demoFlip = useEffectEvent(() => flip());
  const demoStart = useEffectEvent(() => override);
  const demoEnd = useEffectEvent((before: Scheme | null) => {
    if (override !== before) setOverride(before);
  });
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const drawing = svg.current;
    const button = themeButton.current;
    if (!demo || !stage || !arrow || !drawing || !button) return;
    const state = engine.current;
    const before = demoStart();

    /** A key's middle, in the root's px. */
    const keyPoint = (label: string, nudge = 0): Point => {
      const key = KEYS.find((k) => k.label === label) ?? KEYS[0];
      const top = topOf(key);
      const box = stage.getBoundingClientRect();
      const rect = drawing.getBoundingClientRect();
      const scale = box.width / stage.offsetWidth || 1;
      return {
        x: (rect.left - box.left + (top.x / VIEW.width) * rect.width) / scale + nudge,
        y: (rect.top - box.top + (top.y / VIEW.height) * rect.height) / scale,
      };
    };
    const buttonPoint = (): Point => {
      const box = stage.getBoundingClientRect();
      const rect = button.getBoundingClientRect();
      const scale = box.width / stage.offsetWidth || 1;
      return {
        x: (rect.left - box.left + rect.width * 0.55) / scale,
        y: (rect.top - box.top + rect.height * 0.6) / scale,
      };
    };
    const offKeys = (x: number, y: number): Point => ({
      x: stage.offsetWidth * x,
      y: stage.offsetHeight * y,
    });

    state.demo = arrow;
    kick();
    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const click = async (label: string) => {
        const key = KEYS.find((k) => k.label === label);
        if (key) ripple(key);
        await wait(80);
      };
      const flipTheme = async () => {
        await glide(buttonPoint(), 900);
        glyph('hand');
        setDemoButton('hover');
        await wait(380);
        setDemoButton('press');
        demoFlip();
        await wait(140);
        setDemoButton('hover');
        await wait(700);
        setDemoButton('');
        glyph('arrow');
      };
      await wait(900);
      for (;;) {
        // Along the top rows, then down to the space bar and a click on the bottom row.
        await glide(keyPoint('key r'), 900);
        await glide(keyPoint('key o'), 520);
        await glide(keyPoint('return'), 380);
        await glide(keyPoint('key ,'), 420);
        await glide(keyPoint('key z'), 700);
        await wait(260);
        await click('key z');
        await wait(900);
        await glide(offKeys(0.12, 0.58), 700);
        await wait(700);
        await flipTheme();
        await glide(keyPoint('return'), 800);
        await glide(keyPoint('key h'), 520);
        await glide(keyPoint('key b'), 300);
        await glide(keyPoint('key d'), 420);
        await glide(keyPoint('caps'), 360);
        await glide(keyPoint('tab'), 300);
        await wait(240);
        await click('tab');
        await wait(700);
        await glide(keyPoint('key m'), 600);
        await glide(keyPoint('shift'), 520);
        await glide(offKeys(0.86, 0.62), 800);
        await wait(2400);
        await flipTheme();
        await glide(offKeys(0.62, 0.15), 1000);
        await wait(3200);
      }
    });
    return () => {
      stop();
      state.demo = null;
      hoverAt(null);
      kick();
      setDemoButton('');
      demoEnd(before);
    };
  }, [demo, root, kick, ripple, hoverAt]);

  // ---- Drawing -------------------------------------------------------------------------

  const value = Math.round(intensity * 10) / 10;
  const setIntensity = (next: number) => {
    const clamped = Math.min(MAX_INTENSITY, Math.max(MIN_INTENSITY, next));
    if (intensityProp === undefined) setOwnIntensity(clamped);
    onIntensityChange?.(clamped);
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={scheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onAnimationEnd={(event) => {
        if (event.target === svg.current) root.current?.removeAttribute('data-flash');
      }}
    >
      <span ref={probe} className={styles.probe} aria-hidden="true" />
      <div className={styles.card} data-part="card">
        <span className={styles.name} data-part="name">
          {name}
        </span>
        <span className={styles.status} data-part="status">
          {label}
        </span>
        <div
          className={styles.stage}
          role="application"
          tabIndex={0}
          aria-label={`${name}: type while it has focus, and its keys press down`}
          onPointerMove={onPointerMove}
          onPointerLeave={onPointerLeave}
          onPointerDown={onPointerDown}
          onPointerUp={onPointerUp}
          onKeyDown={onKeyDown}
          onKeyUp={onKeyUp}
          onBlur={onBlur}
        >
          <svg
            ref={svg}
            className={styles.drawing}
            viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
            aria-hidden="true"
            focusable="false"
          >
            <path className={styles.fill} d={lid.outline} data-part="lid" />
            <path className={styles.faint} d={lid.top} />
            <path className={styles.faint} d={lid.screen} data-part="screen" />
            <path className={styles.line} d={lid.outline} />
            <path className={styles.fill} d={deck.outline} data-part="deck" />
            <path className={styles.faint} d={deck.top} />
            <path className={styles.line} d={deck.outline} />
            <path className={styles.faint} d={well} />
            <path className={styles.faint} d={trackpad} data-part="trackpad" />
            {KEYS.map((key) => {
              const solid = keySolid(key, 0);
              return (
                <g
                  key={key.index}
                  data-part="key"
                  data-key={key.label}
                  ref={(group) => {
                    if (group) parts.current[key.index] = [...group.children] as SVGPathElement[];
                  }}
                >
                  <path className={styles.fill} d={solid.outline} />
                  <path className={styles.faint} d={solid.top} />
                  <path className={styles.line} d={solid.outline} />
                </g>
              );
            })}
          </svg>
        </div>
      </div>
      <div className={styles.controls}>
        <label className={styles.muted} htmlFor={sliderId}>
          intensity
        </label>
        <input
          id={sliderId}
          className={styles.slider}
          data-part="intensity"
          type="range"
          min={MIN_INTENSITY}
          max={MAX_INTENSITY}
          step={0.1}
          value={value}
          aria-valuetext={value.toFixed(1)}
          onChange={(event) => setIntensity(Number(event.target.value))}
          style={{
            ['--fill' as string]: `${((value - MIN_INTENSITY) / (MAX_INTENSITY - MIN_INTENSITY)) * 100}%`,
          }}
        />
        <output className={styles.value} htmlFor={sliderId} data-part="value">
          {value.toFixed(1)}
        </output>
        <span className={styles.themeGroup}>
          <span className={styles.muted} aria-hidden="true">
            theme
          </span>
          <button
            ref={themeButton}
            type="button"
            className={styles.theme}
            data-part="theme"
            data-demo={demoButton || undefined}
            aria-label={`Theme: ${resolved}. Switch to ${resolved === 'light' ? 'dark' : 'light'}`}
            onClick={flip}
          >
            {resolved}
          </button>
        </span>
      </div>
      {caption && (
        <p className={styles.caption} data-part="caption">
          {caption}
        </p>
      )}
      {tags.length > 0 && (
        <p className={styles.tags} data-part="tags">
          {tags.map((tag, i) => (
            <span key={tag}>
              {i > 0 && (
                <span className={styles.dot} aria-hidden="true">
                  ·
                </span>
              )}
              {tag}
            </span>
          ))}
        </p>
      )}
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
    </div>
  );
}
```

### wireframe-laptop/wireframe-laptop.module.css

```css
/* Wireframe laptop (wireframe-laptop.tsx): a framed card with the laptop drawn in hairlines, the
   intensity slider and the theme button under it, then a caption and a row of tags. Near-white by
   day, near-black at night, and the lines a mid grey for outlines and a faint one for creases.

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

   The drawing's line colours and width are registered custom properties, so the flash on a change
   of scheme can ease them: every path takes them from the drawing, and one animation on the
   drawing restyles them all.

   Memory. The card is one layout boundary sized from its width; the drawing is one SVG of about
   two hundred paths, and only the keys that move are redrawn, one frame at a time. */

@property --wl-line {
  syntax: '<color>';
  inherits: true;
  initial-value: #9c9c9c;
}
@property --wl-faint {
  syntax: '<color>';
  inherits: true;
  initial-value: #dadada;
}
@property --wl-hair {
  syntax: '<length>';
  inherits: true;
  initial-value: 1px;
}

.root {
  /* The page and the card. */
  --bg: #fdfdfd;
  --border: #dbdbdb;
  /* The drawing: outlines, creases, a pressed key's outline, and every line in the flash. */
  --wl-line: #9c9c9c;
  --wl-faint: #dadada;
  --wl-press: #141414;
  --flash: #161616;
  /* A hairline: three quarters of a px, a pixel and a half on a sharp screen. */
  --wl-hair: 1px;
  /* Words: the strong ones (the key, the value, the button, the caption) and the quiet ones. */
  --text: #111;
  --muted: #5a5a5a;
  /* The slider's track and its ring, the button's edge. */
  --track: #666;
  --ring: #121212;
  --edge: #dadada;
  --edge-hover: #b4b4b4;
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --mono: var(--font-mono, ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace);
  position: relative;
  container-type: inline-size;
  width: 100%;
  max-width: 760px;
  box-sizing: border-box;
  margin-inline: auto;
  padding: 24px;
  border-radius: 16px;
  background: var(--bg);
  color: var(--text);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --bg: light-dark(#fdfdfd, #06090a);
    --border: light-dark(#dbdbdb, #212325);
    --wl-line: light-dark(#9c9c9c, #4f5152);
    --wl-faint: light-dark(#dadada, #212324);
    --wl-press: light-dark(#141414, #d4d7da);
    --flash: light-dark(#161616, #dcdfe1);
    --text: light-dark(#111, #d6d9dc);
    --muted: light-dark(#5a5a5a, #85888b);
    --track: light-dark(#666, #7f8189);
    --ring: light-dark(#121212, #cdd2db);
    --edge: light-dark(#dadada, #2a2d30);
    --edge-hover: light-dark(#b4b4b4, #4a4e52);
  }
}
/* The colorScheme prop, or the theme button. Without them, color-scheme comes from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* Which scheme the colours resolved to, for the theme button's word. */
.probe {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
  color: #000;
}
@supports (color: light-dark(#fff, #000)) {
  .probe {
    color: light-dark(#000, #fff);
  }
}

.card {
  position: relative;
  aspect-ratio: 1278 / 1023;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--bg);
  contain: layout paint;
}

.name,
.status {
  position: absolute;
  top: 13px;
  z-index: 1;
  font: 400 11.25px/1 var(--mono);
  pointer-events: none;
  user-select: none;
}
.name {
  left: 15px;
  color: var(--muted);
}
.status {
  right: 15px;
  color: var(--text);
}

.stage {
  position: absolute;
  inset: 0;
  border-radius: 11px;
  outline: none;
  touch-action: pan-y;
}
.stage:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}

/* Something on the lab's pages sizes unsized SVGs: this one is sized here. */
.drawing {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.drawing path {
  vector-effect: non-scaling-stroke;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.fill {
  fill: var(--bg);
  stroke: none;
}
.faint {
  fill: none;
  stroke: var(--wl-faint);
  stroke-width: var(--wl-hair);
}
.line {
  fill: none;
  stroke: var(--wl-line);
  stroke-width: var(--wl-hair);
}

/* A change of scheme: every line heavy and dark at once, then back to a hairline. */
.root[data-flash] .drawing {
  animation: flash 240ms cubic-bezier(0.3, 0, 0.2, 1);
}
@keyframes flash {
  0%,
  35% {
    --wl-line: var(--flash);
    --wl-faint: var(--flash);
    --wl-hair: 1.5px;
  }
}

.controls {
  display: flex;
  align-items: center;
  gap: 13px;
  margin-top: 15px;
  min-height: 31px;
}
.muted,
.value {
  flex: none;
  font: 400 11.25px/1 var(--mono);
  white-space: nowrap;
}
.muted {
  color: var(--muted);
}
.value {
  min-width: 3ch;
  margin-left: 6px;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

.slider {
  flex: 1;
  min-width: 48px;
  height: 31px;
  margin: 0;
  padding: 0;
  background: linear-gradient(var(--track), var(--track)) center / 100% 1px no-repeat;
  cursor: pointer;
  appearance: none;
  touch-action: pan-y;
}
.slider::-webkit-slider-runnable-track {
  height: 31px;
  background: none;
}
.slider::-moz-range-track {
  height: 31px;
  background: none;
}
.slider::-webkit-slider-thumb {
  box-sizing: border-box;
  width: 12.5px;
  height: 12.5px;
  margin-top: 9.25px;
  border: 1px solid var(--ring);
  border-radius: 50%;
  background: var(--bg);
  appearance: none;
}
.slider::-moz-range-thumb {
  box-sizing: border-box;
  width: 12.5px;
  height: 12.5px;
  border: 1px solid var(--ring);
  border-radius: 50%;
  background: var(--bg);
}
.slider:focus {
  outline: none;
}
.slider:focus-visible::-webkit-slider-thumb {
  box-shadow:
    0 0 0 2px var(--bg),
    0 0 0 4px var(--focus);
}
.slider:focus-visible::-moz-range-thumb {
  box-shadow:
    0 0 0 2px var(--bg),
    0 0 0 4px var(--focus);
}

.themeGroup {
  display: flex;
  flex: none;
  align-items: center;
  gap: 13px;
}
.theme {
  flex: none;
  height: 31px;
  padding: 0 12.5px;
  border: 1px solid var(--edge);
  border-radius: 6px;
  background: transparent;
  color: var(--text);
  font: 400 11.25px/1 var(--mono);
  cursor: pointer;
  transition:
    border-color 0.15s ease,
    transform 0.1s ease;
}
.theme:focus {
  outline: none;
}
.theme:focus-visible {
  box-shadow: 0 0 0 2px var(--focus);
}
@media (hover: hover) {
  .theme:hover {
    border-color: var(--edge-hover);
  }
}
.theme[data-demo] {
  border-color: var(--edge-hover);
}
.theme:active,
.theme[data-demo='press'] {
  transform: scale(0.96);
}

.caption {
  max-width: 600px;
  margin: 14px 0 0;
  color: var(--text);
  font: 400 13px/19.5px var(--body, Inter, system-ui, sans-serif);
  text-wrap: pretty;
}
.tags {
  margin: 5px 0 0;
  color: var(--muted);
  font: 400 11.25px/19.5px var(--mono);
}
.dot {
  margin: 0 0.62em;
}

.pointerLayer {
  position: absolute;
  inset: 0;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
}
.pointer {
  width: clamp(18px, 3.4cqi, 24px);
  height: clamp(18px, 3.4cqi, 24px);
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

/* A phone: a little less frame, and the controls in two lines when they need it. */
@container (width < 440px) {
  .controls {
    flex-wrap: wrap;
    row-gap: 4px;
  }
  .themeGroup {
    justify-content: flex-end;
    width: 100%;
  }
}
@media (width < 480px) {
  .root {
    padding: 16px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root[data-flash] .drawing {
    animation: none;
  }
  .theme {
    transition: none;
  }
}
```

### wireframe-laptop/geometry.ts

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

/*
 * The laptop's drawing, worked out in three dimensions and projected to the page.
 *
 * - The projection is the 2:1 isometric of technical drawings: a step along the laptop's width
 *   (x) goes one unit right and half a unit down, a step towards its front edge (y) one unit left
 *   and half a unit down, and a step up (z) one unit up. Lines along x and y slope at 26.57°.
 * - Every solid is a rounded rectangle in plan, extruded: the deck, each key (whose top is a
 *   little smaller than its foot), and the lid, tipped back on its hinge. A solid draws as two
 *   lines: its outline (the convex hull of its top and its foot, projected) in the ink, and the
 *   outline of its top in the faint ink, which shows as the crease along its front edges.
 * - Units are those of the drawing's viewBox (1278 × 1023, twice the card's px at its own size of
 *   640 px), so the numbers below are the sizes the drawing was measured at.
 */

export type Point = [number, number];

/** The viewBox: the card, at twice its px. */
export const VIEW = { width: 1278, height: 1023 };

/** Where the back left corner of the deck's top lands on the page. */
const ORIGIN: Point = [521, 511];

/** A point of the laptop (x across, y towards the front, z up) on the page. */
export function project(x: number, y: number, z: number): Point {
  return [ORIGIN[0] + x - y, ORIGIN[1] + (x + y) / 2 - z];
}

/** The page's point back on the plane at height z: where the pointer is on a key's top. */
export function unproject(px: number, py: number, z: number): { x: number; y: number } {
  const across = px - ORIGIN[0];
  const down = 2 * (py - ORIGIN[1] + z);
  return { x: (down + across) / 2, y: (down - across) / 2 };
}

/** The deck: its width, its depth, its thickness, the radius of its corners. */
export const DECK = { width: 487, depth: 334.5, thickness: 28, radius: 30 };

/** The trackpad, a flat rounded rectangle on the deck, in the middle of its width. */
const TRACKPAD = { x0: 152, y0: 220, x1: 338, y1: 318, radius: 16 };

/**
 * The keyboard: one unit of key is PITCH wide, and five rows of 12.5 units sit in the middle of
 * the deck. Each key's foot is the pitch less GAP; its top is INSET smaller on every side and
 * HEIGHT above the deck.
 */
const PITCH = 34.6;
const GAP = 4.2;
const INSET = 1.4;
export const HEIGHT = 15;
/** How low a key goes, fully pressed: its top stays this far above the deck. */
const FLOOR = 1.4;
const KEY_RADIUS = 6;
const TOP_RADIUS = 4.6;
const ROW_LENGTH = 12.5;
const KEYBOARD_X = (DECK.width - ROW_LENGTH * PITCH) / 2;
const KEYBOARD_Y = 21.6;
/** The well around the keys: a faint line at the deck's level. */
const WELL = { margin: 7, radius: 12 };

/**
 * The lid: its front face starts at the hinge (a little into the deck, which hides it) and runs up
 * and back along UP. It is THICK deep, its back face set back along BACK.
 */
const HINGE = { y: 2.5, z: -9.6 };
const LID_LENGTH = 376;
const UP: [number, number, number] = [0, -74.2 / LID_LENGTH, 368.8 / LID_LENGTH];
const BACK: [number, number, number] = [0, -16, -3.2];
const LID_RADIUS = 26;
/** The screen inside the lid's face: its borders, sides, top and bottom, and its corners. */
const BEZEL = { side: 22, top: 22, bottom: 24, radius: 9 };

export type KeyDef = {
  /** What the corner label says while it is pressed. */
  label: string;
  /** The keyboard's KeyboardEvent.code values that press it. */
  codes: string[];
  /** Its width, in units of key. */
  units: number;
  /** A character it types, for the phrase the laptop types by itself. */
  char?: string;
};

const letter = (char: string): KeyDef => ({
  label: `key ${char}`,
  codes: [`Key${char.toUpperCase()}`],
  units: 1,
  char,
});
const digit = (char: string): KeyDef => ({
  label: `key ${char}`,
  codes: [`Digit${char}`],
  units: 1,
  char,
});

/** The rows, back to front. Each adds up to 12.5 units. */
export const ROWS: KeyDef[][] = [
  [
    { label: 'key `', codes: ['Backquote'], units: 1, char: '`' },
    ...'1234567890'.split('').map(digit),
    { label: 'delete', codes: ['Backspace', 'Delete', 'Minus', 'Equal'], units: 1.5 },
  ],
  [
    { label: 'tab', codes: ['Tab'], units: 1.5 },
    ...'qwertyuiop'.split('').map(letter),
    { label: 'key [', codes: ['BracketLeft', 'BracketRight', 'Backslash'], units: 1, char: '[' },
  ],
  [
    { label: 'caps', codes: ['CapsLock'], units: 1.75 },
    ...'asdfghjkl'.split('').map(letter),
    { label: 'return', codes: ['Enter', 'NumpadEnter', 'Semicolon', 'Quote'], units: 1.75 },
  ],
  [
    { label: 'shift', codes: ['ShiftLeft'], units: 2.25 },
    ...'zxcvbnm'.split('').map(letter),
    { label: 'key ,', codes: ['Comma'], units: 1, char: ',' },
    { label: 'key .', codes: ['Period', 'Slash'], units: 1, char: '.' },
    { label: 'shift', codes: ['ShiftRight'], units: 1.25 },
  ],
  [
    { label: 'fn', codes: ['Fn'], units: 1 },
    { label: 'control', codes: ['ControlLeft', 'ControlRight'], units: 1 },
    { label: 'option', codes: ['AltLeft'], units: 1 },
    { label: 'command', codes: ['MetaLeft'], units: 1.25 },
    { label: 'space', codes: ['Space'], units: 5.25, char: ' ' },
    { label: 'command', codes: ['MetaRight'], units: 1 },
    { label: 'option', codes: ['AltRight'], units: 1 },
    {
      label: 'arrow',
      codes: ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'],
      units: 1,
    },
  ],
];

export type Key = KeyDef & {
  index: number;
  /** Its foot, in plan. */
  x0: number;
  y0: number;
  x1: number;
  y1: number;
  /** Its middle, in units of key: how far keys are from each other, for the presses. */
  cx: number;
  cy: number;
};

/** Every key, back to front and left to right: the order they paint in. */
export const KEYS: Key[] = (() => {
  const keys: Key[] = [];
  ROWS.forEach((row, r) => {
    let at = 0;
    for (const def of row) {
      const x0 = KEYBOARD_X + at * PITCH + GAP / 2;
      const y0 = KEYBOARD_Y + r * PITCH + GAP / 2;
      keys.push({
        ...def,
        index: keys.length,
        x0,
        y0,
        x1: x0 + def.units * PITCH - GAP,
        y1: y0 + PITCH - GAP,
        cx: at + def.units / 2,
        cy: r + 0.5,
      });
      at += def.units;
    }
  });
  return keys.sort((a, b) => a.x0 + a.y0 - (b.x0 + b.y0)).map((key, index) => ({ ...key, index }));
})();

/** The key under a point of the page (at the height of the keys' tops), if any. */
export function keyAt(px: number, py: number): Key | null {
  // Front keys paint over the ones behind them, so the frontmost hit wins.
  for (let i = KEYS.length - 1; i >= 0; i -= 1) {
    const key = KEYS[i];
    for (const z of [HEIGHT, 0]) {
      const { x, y } = unproject(px, py, z);
      if (x >= key.x0 && x <= key.x1 && y >= key.y0 && y <= key.y1) return key;
    }
  }
  return null;
}

/** A point of the page in units of key, on the keyboard's plane: where the pointer presses. */
export function toKeyUnits(px: number, py: number): { x: number; y: number } {
  const { x, y } = unproject(px, py, HEIGHT);
  return { x: (x - KEYBOARD_X) / PITCH, y: (y - KEYBOARD_Y) / PITCH };
}

/** The outline of a rounded rectangle in plan, corner arcs drawn through `steps` segments. */
function rounded(
  x0: number,
  y0: number,
  x1: number,
  y1: number,
  radius: number,
  steps = 6,
): [number, number][] {
  const r = Math.min(radius, (x1 - x0) / 2, (y1 - y0) / 2);
  const corners: [number, number, number][] = [
    [x1 - r, y1 - r, 0],
    [x0 + r, y1 - r, 90],
    [x0 + r, y0 + r, 180],
    [x1 - r, y0 + r, 270],
  ];
  const points: [number, number][] = [];
  for (const [cx, cy, start] of corners) {
    for (let i = 0; i <= steps; i += 1) {
      const angle = ((start + (90 * i) / steps) * Math.PI) / 180;
      points.push([cx + r * Math.cos(angle), cy + r * Math.sin(angle)]);
    }
  }
  return points;
}

/** The convex hull of points on the page (monotone chain), for a solid's outline. */
function hull(points: Point[]): Point[] {
  const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  const cross = (o: Point, a: Point, b: Point) =>
    (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
  const lower: Point[] = [];
  for (const p of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
      lower.pop();
    lower.push(p);
  }
  const upper: Point[] = [];
  for (let i = sorted.length - 1; i >= 0; i -= 1) {
    const p = sorted[i];
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
      upper.pop();
    upper.push(p);
  }
  return lower.slice(0, -1).concat(upper.slice(0, -1));
}

const path = (points: Point[]) =>
  `M${points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join('L')}Z`;

/** A solid's two lines: its outline, and the outline of its top. */
export type Solid = { outline: string; top: string };

/** A rounded block standing on z = 0: its foot in plan, its top `inset` smaller at `height`. */
function block(
  x0: number,
  y0: number,
  x1: number,
  y1: number,
  radius: number,
  topRadius: number,
  inset: number,
  bottom: number,
  height: number,
): Solid {
  const foot = rounded(x0, y0, x1, y1, radius).map(([x, y]) => project(x, y, bottom));
  const top = rounded(x0 + inset, y0 + inset, x1 - inset, y1 - inset, topRadius).map(([x, y]) =>
    project(x, y, height),
  );
  return { outline: path(hull([...foot, ...top])), top: path(top) };
}

/** A key, pressed by `press` (0 up, 1 all the way down). */
export function keySolid(key: Key, press: number): Solid {
  const height = HEIGHT - (HEIGHT - FLOOR) * Math.min(1, Math.max(0, press));
  return block(key.x0, key.y0, key.x1, key.y1, KEY_RADIUS, TOP_RADIUS, INSET, 0, height);
}

/** The deck: a slab whose top is the plane z = 0. */
export const deck: Solid = block(
  0,
  0,
  DECK.width,
  DECK.depth,
  DECK.radius,
  DECK.radius,
  0,
  -DECK.thickness,
  0,
);

/** The flat outlines on the deck: the keyboard's well and the trackpad. */
export const well = path(
  rounded(
    KEYBOARD_X - WELL.margin,
    KEYBOARD_Y - WELL.margin,
    DECK.width - KEYBOARD_X + WELL.margin,
    KEYBOARD_Y + ROWS.length * PITCH + WELL.margin,
    WELL.radius,
  ).map(([x, y]) => project(x, y, 0)),
);
export const trackpad = path(
  rounded(TRACKPAD.x0, TRACKPAD.y0, TRACKPAD.x1, TRACKPAD.y1, TRACKPAD.radius).map(([x, y]) =>
    project(x, y, 0),
  ),
);

/** A point of the lid's face: `u` across, `v` up from the hinge; `back` steps to its back. */
function lidPoint(u: number, v: number, back = false): Point {
  const y = HINGE.y + v * UP[1] + (back ? BACK[1] : 0);
  const z = HINGE.z + v * UP[2] + (back ? BACK[2] : 0);
  return project(u, y, z);
}

/** The lid: its outline (both faces), its front face, and the screen inside it. */
export const lid = (() => {
  const face = rounded(0, 0, DECK.width, LID_LENGTH, LID_RADIUS, 8);
  const front = face.map(([u, v]) => lidPoint(u, v));
  const back = face.map(([u, v]) => lidPoint(u, v, true));
  const screen = rounded(
    BEZEL.side,
    BEZEL.bottom,
    DECK.width - BEZEL.side,
    LID_LENGTH - BEZEL.top,
    BEZEL.radius,
  ).map(([u, v]) => lidPoint(u, v));
  return { outline: path(hull([...front, ...back])), top: path(front), screen: path(screen) };
})();
```

### wireframe-laptop/demo-pointer.tsx

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

'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, or
 * glide the pointer somewhere. 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.
 *
 * Each glide is a curved flight played by the Web Animations API. The points of the flight are
 * handed to whoever wants to move along with it; the laptop reads where the pointer is from its
 * transform instead, each frame, to press the keys under it. The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

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

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** 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;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  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.8, y: stage.offsetHeight * 0.9 };

  /** 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);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    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.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        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,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

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

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the root's tokens (wireframe-laptop.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over the theme button. It is
 * decorative: what it does shows on the laptop itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} 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"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### wireframe-laptop/use-autoplay.ts

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

'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. It also says whether the component is on
 * screen (`visible`) and whether the reader asks for less motion (`reduced`), for the work it
 * does whoever is in charge.
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```