# Waypoint marker

> A game pin over the spot the player marked: an orange arrow that bobs, counts the distance, grows as you close in and docks on the screen edge when the spot is out of view.

The marker a game floats over a pinned spot, so the player never has to reopen the map. It drops in with its two bars stamping after it, bobs over the spot with the distance above it, and grows as the player closes in. Turn away and it glides to the nearest edge and points out of the screen, glowing; arrive and it pops and clears itself.

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

Category: Games · HUD.

## Anatomy

1. **View**: The player’s screen, see-through and marked only by four corner ticks; it takes the arrow keys and drags, and its sides are where the marker docks.
2. **Marker**: Two orange bars over a down-arrow with a 1.7 white outline and a thin dark rim outside it, so it holds on a light screen and a dark one. It scales about its tip with distance, 0.55 times at 400 m and further.
3. **Readout**: The distance in whole units on a dark pill over the bars, counted live by the loop as the player moves.
4. **Pin chips**: One per spot, below the view, `aria-pressed` on the pinned one, which turns orange; pressing it again clears.
5. **Move chips**: Turn left, travel and turn right, each held down (pointer, Space or Enter) to keep moving.
6. **Hint**: The keys, in small muted type under the chips; under 520 px wide it shrinks to “Drag to turn · chips to pin”.

## When to use

- A spot the player chose on the map and is now travelling to, shown in the world so they never reopen the map to check the way.
- Open worlds, where the goal can sit behind the camera: the marker docks on the nearer edge and points the way to turn.
- A live distance the player watches fall, with the marker growing as they close in and clearing itself on arrival.
- A HUD over your own game view: pass `distance` and `bearing` from the game and the marker does the rest; it draws nothing behind itself.

## When not to use

- Several objectives at once. One pin is the point here; use a compass strip with a tick per objective.
- Allies or enemies that move on their own. Use an ally tracker or a radar.
- Where to aim, not where to go. Use an aim reticle.
- Giving directions on a flat map or a route in an app. Use a map with a route line and a turn list.
- A one-off prompt to press a key near an object. Use an action prompt.

## Installation

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

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

export function QuestPin({ meters, bearing }: { meters: number; bearing: number }) {
  return (
    <WaypointMarker
      distance={meters}
      bearing={bearing}
      arriveDistance={8}
      autoplay={false}
    />
  );
}
```

## States

- **No pin**: `defaultPin={null}`: the empty view and its chips, with nothing marked.
- **Pinned, far**: `defaultPin="tower"`: 332 m out and straight ahead, the marker at its smallest in the middle of the view, bobbing.
- **Off screen, left**: `defaultPin="arch"`: the arch is 64° to the left, so the marker docks on the left edge, turned to point out, glowing.
- **Behind, right**: `defaultPin="tree"`: the tree is behind the player; the marker docks on the nearer side with the readout beside its tail.
- **Close in**: Driven, `distance={20}`: within `nearDistance` the marker is at its largest, 1.1 times.
- **Halfway**: Driven, `distance={150}`: the size eases between the far and near sizes as the player closes in.
- **No chips**: `controls={false}`: keys and drags still work; the hint stays.
- **Light**: `colorScheme="light"`: on a light stage: dark ticks and chips; the marker keeps its orange and white, held by its dark rim and plate.
- **Dark**: `colorScheme="dark"`: on a dark stage: light ticks and chips; the marker is the same.

## How it works

- Only the marker is drawn, on whatever is behind it: the view is the player’s screen, marked by four faint corner ticks, and the demo world (three spots on open ground, the player walking among them) lives only in numbers. Each spot is projected with a pinhole camera (70° across by default), so the marker slides across the view as the player turns, as a game’s would.
- One requestAnimationFrame loop owns the motion, and only while the view is on screen and its tab visible: it turns and moves the player, projects the pin, and writes transforms and the readout’s text. React hears only of a pin placed, an arrival or a clear; the loop sleeps when nothing moves.
- The marker is one SVG in its own units: two bars 16 wide (6.6 and 4.8 tall) over a down-arrow with a 16-wide shaft and a 44.8-wide head, 75.2 from the top bar to the tip, drawn at 1.5 px per unit, filled #fe6500 with a 1.7 white outline drawn behind the fill (paint-order: stroke), round joins, and a thin dark rim outside the white so it holds on a light screen as well as a dark one. Nothing on it is blurred; the readout sits on its own dark pill.
- It scales about its tip with the distance, 0.55 times at 400 m and further up to 1.1 times within 30 m, so far reads as a hint and near as a call. It bobs 4 px over 1.2 s, and the readout above it counts whole metres as the player moves.
- When the tip would leave the view, or the spot is behind, an edge state eases in (about 300 ms): the marker glides to the nearer side, turns ±90° about its tip to point out of the screen, nudges toward the edge instead of bobbing and pulses an orange glow, a blurred copy kept behind the sharp shapes; the readout moves beside its tail. It eases back when the spot comes into view.
- Placing drops the arrow 34 px in with an overshoot and stamps the bars after it, 80 and 150 ms later, while a ring pings under its tip; arriving squashes the marker, pops it to 1.35 times and fades it in 360 ms, and the pin clears itself.

## API reference

- **defaultPin** (`'tower' | 'arch' | 'tree' | null`, default `null`): The spot pinned at first in the demo world (ahead, left, behind); null for none.
- **onPinChange** (`(pin: 'tower' | 'arch' | 'tree' | null) => void`): Called when a pin is placed on a spot, cleared, or cleared by an arrival (null).
- **onArrive** (`(pin: 'tower' | 'arch' | 'tree') => void`): Called when the player reaches the pinned spot; the marker then pops and clears.
- **distance** (`number`): Drives the marker from your game: the distance to the spot, which sets the readout and the size. Turns the demo world, its controls and the autoplay off.
- **bearing** (`number`, default `0`): With distance: the spot’s direction from the view’s middle, in degrees (negative is left). Past the edge of the view, the marker docks on that side.
- **unit** (`string`, default `'m'`): The readout’s unit.
- **fov** (`number`, default `70`): The camera’s horizontal field of view, in degrees.
- **nearDistance** (`number`, default `30`): Within this distance the marker is at its largest, 1.1 times.
- **farDistance** (`number`, default `400`): From this distance on the marker is at its smallest, 0.55 times.
- **arriveDistance** (`number`, default `12`): Within this distance the player has arrived: the marker pops and fades (with distance set, it drops back in when the distance grows again).
- **controls** (`boolean`, default `true`): Shows the pin and travel chips of the demo world, under the view.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short demo pins each spot in turn, looks away (the edge state), turns back and travels there. Any real input hands it over; it is off under reduced motion.
- **placeOffscreen** (`boolean`, default `false`): Places the marker once while off screen too, settled and still (it otherwise waits to be seen), so a still copy such as a docs preview opens with it in place.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark corner ticks and chips, or the reader’s; without it, the page’s color-scheme. The marker keeps its orange, white and dark rim on both.
- **className** (`string`): Extra classes for the root.

## Accessibility

- The view is a focusable group with a game-view role description and a hint: the arrow keys (or W A S D) turn and travel, 1 2 3 pin a spot, Delete clears. It shows an orange focus ring.
- The pin buttons are toggle buttons (aria-pressed) with their key shortcuts; the turn and travel buttons are named, and work held down by pointer or by Space and Enter.
- A polite live region says what a player would see: a pin placed and how far it is, the pin going off screen to one side or back in view, an arrival, a clear. The live distance itself is not announced on every metre.
- The marker, the readout, the ping and the corner ticks are decorative (aria-hidden): what they show is in the announcements.
- Under reduced motion there is no demo, bob, nudge, glow pulse or overshoot, and the edge state switches without gliding; placing and arriving are short fades.

## Source

### waypoint-marker/waypoint-marker.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './waypoint-marker.module.css';

/*
 * Waypoint marker: the pin a game floats over the spot the player marked, so they never have to
 * reopen the map to know where they were going.
 *
 * - The marker is an orange down-arrow with two stacked bars above it, outlined in white and then
 *   in a thin dark line, so it reads on a bright view and a dark one alike, as a HUD's glyphs do.
 *   It hangs over the pinned spot's direction, bobbing slowly, with the distance above it on a
 *   dark plate, counting live as the player moves. It grows as they close in (0.55 times at 400 m
 *   and further, 1.1 times within 30 m) and pops and clears itself on arrival.
 * - The view is the player's screen, marked only by four corner ticks: the marker slides across
 *   it as the player turns, the spot projected with a pinhole camera (horizontal field of view
 *   `fov`). When the spot leaves the view, the marker glides to the nearest side edge and turns to
 *   point out of it, nudging and glowing there, so the player knows which way to turn; it glides
 *   back and turns down again when the spot comes into view.
 * - Nothing else is drawn: the demo world (three spots on open ground, the player walking among
 *   them) lives only in numbers, so the marker stands alone on whatever is behind it.
 * - One loop (requestAnimationFrame) owns the motion, and only while the view is on screen and its
 *   tab visible: it turns and moves the player, projects the pin, eases the edge state, and writes
 *   transforms and the readout's text. React hears only of what changes the page's structure: a
 *   pin placed, an arrival, a clear.
 * - The arrow keys (or a drag) turn and travel, 1 2 3 pin a spot, Delete clears; the chips below
 *   do the same. While on screen and untouched, a short demo pins, looks away, turns back and
 *   travels to each spot in turn; any real input hands it over.
 * - Drawn in the kit's units, 1.5 px each at scale 1: bars 16 wide, 6.6 and 4.8 tall, the shaft 16
 *   wide, the head 44.8 wide and 33.2 tall, 75.2 units from the top bar to the tip, a 1.7 white
 *   outline, orange #fe6500. All of it is vector: no blur on the shapes, only the edge glow, a
 *   separate layer behind them.
 */

export type WaypointLandmark = 'tower' | 'arch' | 'tree';

type Landmark = {
  id: WaypointLandmark;
  name: string;
  /** Its chip's label. */
  short: string;
  /** Where it stands, in metres: x east, y north. */
  x: number;
  y: number;
};

const LANDMARKS: Landmark[] = [
  { id: 'tower', name: 'Watchtower', short: 'Tower', x: 40, y: 330 },
  { id: 'arch', name: 'Stone arch', short: 'Arch', x: -250, y: 120 },
  { id: 'tree', name: 'Old tree', short: 'Tree', x: 230, y: -60 },
];

/** Px per kit unit at scale 1. */
const UNIT = 1.5;
/** The marker's height above its tip, in px at scale 1 (77 kit units, with its outline). */
const MARK_H = 77 * UNIT;
/** The readout plate's height and its gap above the marker, px. */
const READOUT_H = 26;
const READOUT_GAP = 10;
/** The turn speed, degrees per second; travel, metres per second. */
const TURN = 80;
const TRAVEL = 70;
/** The edge docks: the tip this far from the side, px. */
const EDGE = 14;
const BOB = 5;
const BOB_PERIOD = 1.2;

const clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value));
const wrap = (deg: number) => ((((deg + 180) % 360) + 360) % 360) - 180;
const easeInOut = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
const RAD = Math.PI / 180;

type Phase = 'drop' | 'arrive' | 'clear';
type Shown = { pin: WaypointLandmark | 'custom'; phase: Phase; key: number } | null;

export function WaypointMarker({
  defaultPin = null,
  onPinChange,
  onArrive,
  distance,
  bearing = 0,
  unit = 'm',
  fov = 70,
  nearDistance = 30,
  farDistance = 400,
  arriveDistance = 12,
  controls = true,
  autoplay = true,
  placeOffscreen = false,
  colorScheme,
  className = '',
}: {
  /** The spot pinned at first; null for none. */
  defaultPin?: WaypointLandmark | null;
  /** Called when a pin is placed on a spot, or cleared (null). */
  onPinChange?: (pin: WaypointLandmark | null) => void;
  /** Called when the player reaches the pinned spot (the pin then clears itself). */
  onArrive?: (pin: WaypointLandmark) => void;
  /** Drives the marker from your game: the distance to the spot. Turns the demo world off. */
  distance?: number;
  /** With `distance`: the spot's direction from the view's middle, degrees (negative is left). */
  bearing?: number;
  /** The readout's unit. */
  unit?: string;
  /** The camera's horizontal field of view, degrees. */
  fov?: number;
  /** Within this distance the marker is at its largest (1.1 times). */
  nearDistance?: number;
  /** From this distance on the marker is at its smallest (0.55 times). */
  farDistance?: number;
  /** Within this distance the player has arrived: the marker pops and clears. */
  arriveDistance?: number;
  /** Shows the pin and travel chips (the demo world only). */
  controls?: boolean;
  /** While on screen and untouched, a short demo pins and travels to each spot in turn. */
  autoplay?: boolean;
  /** Places the marker once while off screen too (it otherwise waits to be seen), settled, so a
   * still copy (a docs preview, a screenshot) opens with it in place. */
  placeOffscreen?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const driven = distance !== undefined;
  const { ref: root, playing: demoOn, visible, reduced, take } = useAutoplay<HTMLDivElement>(5000);
  const playing = autoplay && !driven && demoOn;

  const [pin, setPin] = useState<WaypointLandmark | null>(driven ? null : defaultPin);
  const [shown, setShown] = useState<Shown>(() =>
    driven
      ? { pin: 'custom', phase: 'drop', key: 0 }
      : defaultPin
        ? { pin: defaultPin, phase: 'drop', key: 0 }
        : null,
  );
  const [message, setMessage] = useState('');
  const hintId = useId();
  const glowId = useId().replace(/:/g, '');

  const view = useRef<HTMLDivElement>(null);
  const marker = useRef<HTMLDivElement>(null);
  const turner = useRef<HTMLDivElement>(null);
  const sizer = useRef<HTMLDivElement>(null);
  const readout = useRef<HTMLSpanElement>(null);
  const readoutText = useRef<HTMLSpanElement>(null);
  const ping = useRef<HTMLSpanElement>(null);

  /** The loop's state, outside React. */
  const live = useRef({
    x: 0,
    y: 0,
    yaw: 0,
    turn: 0,
    move: 0,
    keyTurn: 0,
    keyMove: 0,
    speed: 0,
    pin: pin as WaypointLandmark | null,
    blend: 0,
    rot: 0,
    side: 1,
    edgeX: 0,
    edgeSeen: false,
    shownDist: -1,
    arrived: false,
    script: { step: 'pin' as string, start: -1, from: 0, to: 0, next: 0 },
    size: { w: 0, h: 0 },
  });
  const props = useRef({
    fov,
    nearDistance,
    farDistance,
    arriveDistance,
    bearing,
    distance,
    unit,
    onArrive,
    reduced,
  });
  props.current = {
    fov,
    nearDistance,
    farDistance,
    arriveDistance,
    bearing,
    distance,
    unit,
    onArrive,
    reduced,
  };

  const nameOf = (id: WaypointLandmark) => LANDMARKS.find((l) => l.id === id)!.name;

  /** Pins a spot (or clears, with null): the marker drops in, or fades out. */
  const place = useCallback(
    (id: WaypointLandmark | null) => {
      const state = live.current;
      if (state.pin === id) return;
      state.pin = id;
      state.arrived = false;
      state.shownDist = -1;
      state.edgeSeen = false;
      setPin(id);
      onPinChange?.(id);
      setShown((current) =>
        id
          ? { pin: id, phase: 'drop', key: (current?.key ?? 0) + 1 }
          : current
            ? { ...current, phase: 'clear' }
            : null,
      );
      if (id) {
        const mark = LANDMARKS.find((l) => l.id === id)!;
        const d = Math.round(Math.hypot(mark.x - state.x, mark.y - state.y));
        setMessage(
          `Pin placed on the ${mark.name.toLowerCase()}, ${d} ${props.current.unit} away.`,
        );
      } else setMessage('Pin cleared.');
    },
    [onPinChange],
  );

  // An exit (arrive or clear) unmounts the marker once its animation is over.
  useEffect(() => {
    if (!shown || shown.phase === 'drop') return;
    const id = window.setTimeout(
      () => setShown((current) => (current && current.phase !== 'drop' ? null : current)),
      reduced ? 200 : 420,
    );
    return () => window.clearTimeout(id);
  }, [shown, reduced]);

  // Driven from outside: the marker pops when the distance comes within arrival, drops back in
  // when it leaves it.
  useEffect(() => {
    if (!driven) return;
    const inside = distance! <= arriveDistance;
    setShown((current) => {
      if (inside && current && current.phase === 'drop') return { ...current, phase: 'arrive' };
      if (!inside && (!current || current.phase !== 'drop'))
        return { pin: 'custom', phase: 'drop', key: (current?.key ?? 0) + 1 };
      return current;
    });
  }, [driven, distance, arriveDistance]);

  const wake = useRef<() => void>(() => {});

  useEffect(() => {
    const element = view.current;
    if (!element || (!visible && !placeOffscreen)) return;
    // Off screen (placeOffscreen): one settled frame per change, no motion, then sleep.
    const once = !visible;
    const state = live.current;
    let frame = 0;
    let last = performance.now();
    let clock = 0;

    const measure = () => {
      state.size = { w: element.clientWidth, h: element.clientHeight };
      wake.current();
    };
    measure();
    const resize = new ResizeObserver(measure);
    resize.observe(element);

    const tick = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      clock += dt;
      const p = props.current;
      const { w, h } = state.size;
      const f = w / 2 / Math.tan((p.fov / 2) * RAD);
      const motion = !p.reduced && !once;

      // The demo, a step at a time.
      if (playingRef.current && !driven) runScript(state, clock, dt);

      // Turn and travel.
      const turn = clamp(state.turn + state.keyTurn, -1, 1);
      state.yaw = wrap(state.yaw + turn * TURN * dt);
      const want = clamp(state.move + state.keyMove, -1, 1) * TRAVEL;
      state.speed += (want - state.speed) * (1 - Math.exp(-dt * 8));
      if (Math.abs(state.speed) < 0.05 && want === 0) state.speed = 0;
      state.x += Math.sin(state.yaw * RAD) * state.speed * dt;
      state.y += Math.cos(state.yaw * RAD) * state.speed * dt;

      /** Where a direction lands across the view: off to a side when behind. */
      const across = (delta: number) =>
        Math.abs(delta) < 89 ? w / 2 + f * Math.tan(delta * RAD) : delta < 0 ? -1e4 : 1e4;

      // The pin.
      const target = driven
        ? (() => {
            const delta = wrap(p.bearing);
            return { dist: Math.max(0, p.distance ?? 0), delta, sx: across(delta) };
          })()
        : state.pin
          ? (() => {
              const mark = LANDMARKS.find((l) => l.id === state.pin)!;
              const dx = mark.x - state.x;
              const dy = mark.y - state.y;
              const delta = wrap(Math.atan2(dx, dy) / RAD - state.yaw);
              return { dist: Math.hypot(dx, dy), delta, sx: across(delta) };
            })()
          : null;

      const node = marker.current;
      if (target && node && w > 0) {
        const near01 = 1 - clamp((target.dist - p.nearDistance) / (p.farDistance - p.nearDistance));
        const scale = 0.55 + 0.55 * near01 ** 1.6;
        // Off screen: the tip would leave the view (with a margin for the head), or the spot is
        // behind.
        const margin = 40;
        const edge = target.sx < margin || target.sx > w - margin;
        if (edge) state.side = target.delta < 0 ? -1 : 1;
        if (edge !== state.edgeSeen) {
          state.edgeSeen = edge;
          if (edge)
            setMessageRef.current(`Pin off screen to the ${state.side < 0 ? 'left' : 'right'}.`);
          else setMessageRef.current('Pin in view.');
        }
        const k = motion ? 1 - Math.exp(-dt * 11) : 1;
        state.blend += ((edge ? 1 : 0) - state.blend) * k;
        const wantX = state.side < 0 ? EDGE : w - EDGE;
        state.edgeX += (wantX - state.edgeX) * (motion ? 1 - Math.exp(-dt * 14) : 1);
        state.rot += (state.side * -90 * (edge ? 1 : 0) - state.rot) * k;
        const b = state.blend;

        // In view: the marker and its readout centred on the view's height together. At the
        // edge: the tip on the middle line, the marker lying along it.
        const s1 = scale;
        const s2 = Math.max(scale, 0.8);
        const s = s1 + (s2 - s1) * b;
        const tall = MARK_H * s;
        const onX = clamp(target.sx, margin, w - margin);
        const onY = h / 2 + (tall + READOUT_GAP + READOUT_H) / 2;
        const edgeY = h / 2;
        const x = onX + (state.edgeX - onX) * b;
        const y = onY + (edgeY - onY) * b;
        // Bob (down-up) in view, a nudge toward the edge at the edge.
        const wave = motion ? 0.5 - 0.5 * Math.cos((clock / BOB_PERIOD) * Math.PI * 2) : 0;
        const bobY = -BOB * wave * (1 - b);
        const nudgeX = state.side * BOB * wave * b;
        node.style.transform = `translate3d(${x + nudgeX}px,${y + bobY}px,0)`;
        if (turner.current) turner.current.style.transform = `rotate(${state.rot}deg)`;
        if (sizer.current) sizer.current.style.transform = `scale(${s})`;
        node.dataset.edge = edge ? (state.side < 0 ? 'left' : 'right') : 'none';

        // The readout: above the bars in view; beside the tail, inward, at the edge. It holds
        // still while the marker bobs.
        if (readout.current) {
          const width = readout.current.offsetWidth;
          const above = { x: 0, y: -tall - READOUT_GAP - READOUT_H / 2 };
          const beside = { x: -state.side * (tall + 14 + width / 2), y: 0 };
          readout.current.style.transform = `translate3d(calc(${above.x + (beside.x - above.x) * b - nudgeX}px - 50%),calc(${above.y + (beside.y - above.y) * b - bobY}px - 50%),0)`;
        }
        const d = Math.round(target.dist);
        if (d !== state.shownDist && readoutText.current) {
          state.shownDist = d;
          readoutText.current.textContent = `${d} ${p.unit}`;
        }
        if (ping.current) ping.current.style.transform = `translate3d(${onX}px,${onY}px,0)`;

        // Arrival (the demo world).
        if (!driven && state.pin && target.dist <= p.arriveDistance && !state.arrived) {
          state.arrived = true;
          const id = state.pin;
          state.pin = null;
          setPin(null);
          setShown((current) => (current ? { ...current, phase: 'arrive' } : null));
          setMessageRef.current(`Arrived at the ${nameOf(id).toLowerCase()}.`);
          p.onArrive?.(id);
          onPinChangeRef.current?.(null);
        }
      }

      const still =
        state.speed === 0 && turn === 0 && !playingRef.current && (!target || p.reduced);
      frame = still || once ? 0 : requestAnimationFrame(tick);
    };
    wake.current = () => {
      if (!frame) {
        last = performance.now();
        frame = requestAnimationFrame(tick);
      }
    };
    frame = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(frame);
      frame = 0;
      wake.current = () => {};
      resize.disconnect();
    };
    // The loop reads everything else through refs.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [visible, driven, placeOffscreen]);

  // Refs the loop reads, so it never restarts for a new callback.
  const playingRef = useRef(playing);
  const setMessageRef = useRef(setMessage);
  const onPinChangeRef = useRef(onPinChange);
  const placeRef = useRef(place);
  useEffect(() => {
    playingRef.current = playing;
    onPinChangeRef.current = onPinChange;
    placeRef.current = place;
    wake.current();
  });

  // Wake the loop whenever the marker's state changes (a still loop sleeps).
  useEffect(() => wake.current(), [shown, distance, bearing, reduced]);

  // The demo starts each round from the pin step.
  useEffect(() => {
    if (!playing) {
      const state = live.current;
      state.turn = 0;
      state.move = 0;
      state.script.start = -1;
      state.script.step = 'pin';
    }
  }, [playing]);

  /** The demo's script: pin, look away, turn back, travel, arrive, rest, next. */
  function runScript(state: typeof live.current, clock: number, dt: number) {
    const s = state.script;
    if (s.start < 0) s.start = clock;
    const t = clock - s.start;
    const go = (step: string) => {
      s.step = step;
      s.start = clock;
    };
    const order: WaypointLandmark[] = ['tower', 'arch', 'tree'];
    const bearingOf = (id: WaypointLandmark) => {
      const mark = LANDMARKS.find((l) => l.id === id)!;
      return Math.atan2(mark.x - state.x, mark.y - state.y) / RAD;
    };
    state.turn = 0;
    if (s.step === 'pin') {
      state.move = 0;
      if (t > 0.7) {
        const id = order[s.next % order.length];
        s.next++;
        placeRef.current(id);
        go('hold');
      }
    } else if (s.step === 'hold') {
      if (t > 1.1) {
        // Look away, past the edge on whichever side is nearer.
        const want = bearingOf(state.pin ?? 'tower');
        const off = wrap(want - state.yaw);
        s.from = state.yaw;
        s.to = want - (off >= 0 ? 1 : -1) * (props.current.fov / 2 + 34);
        go('away');
      }
    } else if (s.step === 'away' || s.step === 'face') {
      const dur = s.step === 'away' ? 1.2 : 1.1;
      const k = easeInOut(clamp(t / dur));
      state.yaw = wrap(s.from + wrap(s.to - s.from) * k);
      if (t >= dur) {
        if (s.step === 'away') go('look');
        else go('travel');
      }
    } else if (s.step === 'look') {
      if (t > 1.0) {
        s.from = state.yaw;
        s.to = bearingOf(state.pin ?? 'tower');
        go('face');
      }
    } else if (s.step === 'travel') {
      if (!state.pin) {
        state.move = 0;
        go('rest');
      } else {
        // Keep the heading on the spot while travelling, as a player steering would.
        const want = bearingOf(state.pin);
        state.yaw = wrap(state.yaw + wrap(want - state.yaw) * (1 - Math.exp(-dt * 4)));
        state.move = 1;
      }
    } else if (s.step === 'rest') {
      state.move = 0;
      if (t > 1.0) go('pin');
    }
  }

  // Reader input: keys on the view, drags on it, held chips.
  const keys = useRef(new Set<string>());
  const readKeys = () => {
    const k = keys.current;
    const state = live.current;
    state.keyTurn =
      (k.has('ArrowRight') || k.has('d') ? 1 : 0) - (k.has('ArrowLeft') || k.has('a') ? 1 : 0);
    state.keyMove =
      (k.has('ArrowUp') || k.has('w') ? 1 : 0) - (k.has('ArrowDown') || k.has('s') ? 1 : 0);
    wake.current();
  };
  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (driven || event.metaKey || event.ctrlKey || event.altKey) return;
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'w', 'a', 's', 'd'].includes(key)) {
      event.preventDefault();
      take();
      keys.current.add(key);
      readKeys();
    } else if (key === '1' || key === '2' || key === '3') {
      take();
      place((['tower', 'arch', 'tree'] as const)[Number(key) - 1]);
    } else if (key === 'Delete' || key === 'Backspace') {
      take();
      place(null);
    }
  };
  const onKeyUp = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    if (keys.current.delete(key)) readKeys();
  };
  const onBlur = () => {
    keys.current.clear();
    readKeys();
  };

  const drag = useRef<{ id: number; x: number } | null>(null);
  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (driven || event.button !== 0) return;
    take();
    drag.current = { id: event.pointerId, x: event.clientX };
    event.currentTarget.setPointerCapture(event.pointerId);
  };
  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== event.pointerId) return;
    const state = live.current;
    const w = state.size.w || 1;
    state.yaw = wrap(state.yaw - ((event.clientX - d.x) / w) * fov);
    d.x = event.clientX;
    wake.current();
  };
  const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (drag.current?.id === event.pointerId) drag.current = null;
  };

  /** A chip held down to turn or travel. */
  const hold = (field: 'turn' | 'move', value: number) => ({
    onPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => {
      event.stopPropagation();
      take();
      live.current[field] = value;
      wake.current();
      event.currentTarget.setPointerCapture(event.pointerId);
    },
    onPointerUp: () => {
      live.current[field] = 0;
    },
    onPointerCancel: () => {
      live.current[field] = 0;
    },
    onLostPointerCapture: () => {
      live.current[field] = 0;
    },
    onKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>) => {
      if (event.key !== ' ' && event.key !== 'Enter') return;
      event.preventDefault();
      event.stopPropagation();
      take();
      live.current[field] = value;
      wake.current();
    },
    onKeyUp: (event: ReactKeyboardEvent<HTMLButtonElement>) => {
      if (event.key === ' ' || event.key === 'Enter') live.current[field] = 0;
    },
    onBlur: () => {
      live.current[field] = 0;
    },
  });

  return (
    <div
      ref={root}
      data-part="stage"
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-reduced={reduced || undefined}
      data-playing={playing || undefined}
    >
      <div
        ref={view}
        className={styles.view}
        data-part="view"
        role="group"
        aria-roledescription="game view"
        aria-label={driven ? 'Waypoint' : 'Game view with a waypoint marker'}
        aria-describedby={driven ? undefined : hintId}
        tabIndex={driven ? undefined : 0}
        onKeyDown={onKeyDown}
        onKeyUp={onKeyUp}
        onBlur={onBlur}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
      >
        <div className={styles.frame} aria-hidden="true" data-part="frame">
          <span data-corner="tl" />
          <span data-corner="tr" />
          <span data-corner="bl" />
          <span data-corner="br" />
        </div>

        {shown && (
          <div className={styles.layer} aria-hidden="true">
            <span
              ref={ping}
              key={`ping-${shown.key}-${shown.phase}`}
              className={styles.ping}
              data-phase={shown.phase}
              data-part="ping"
            />
            <div ref={marker} className={styles.marker} data-part="marker" data-edge="none">
              <div ref={turner} className={styles.turner}>
                <div ref={sizer} className={styles.sizer}>
                  <div key={shown.key} className={styles.stamp} data-phase={shown.phase}>
                    <MarkerArt glowId={glowId} />
                  </div>
                </div>
              </div>
              <span
                ref={readout}
                className={styles.readout}
                data-part="readout"
                data-phase={shown.phase}
              >
                <span ref={readoutText} />
              </span>
            </div>
          </div>
        )}
      </div>

      {!driven && controls && (
        <div className={styles.controls} data-part="controls">
          <div className={styles.group} role="group" aria-label="Pin a spot">
            {LANDMARKS.map((mark, i) => (
              <button
                key={mark.id}
                type="button"
                className={styles.chip}
                aria-pressed={pin === mark.id}
                aria-keyshortcuts={String(i + 1)}
                onClick={() => {
                  take();
                  place(pin === mark.id ? null : mark.id);
                }}
              >
                <PinGlyph />
                {mark.short}
              </button>
            ))}
          </div>
          <div className={styles.group} role="group" aria-label="Move">
            <button
              type="button"
              className={styles.chip}
              aria-label="Turn left (hold)"
              {...hold('turn', -1)}
            >
              <Chevron dir="left" />
            </button>
            <button
              type="button"
              className={styles.chip}
              aria-label="Travel forward (hold)"
              {...hold('move', 1)}
            >
              <Chevron dir="up" />
            </button>
            <button
              type="button"
              className={styles.chip}
              aria-label="Turn right (hold)"
              {...hold('turn', 1)}
            >
              <Chevron dir="right" />
            </button>
          </div>
        </div>
      )}

      {!driven && (
        <p id={hintId} className={styles.hint} data-part="hint">
          <span className={styles.hintWide}>← → turn · ↑ travel · 1 2 3 pin · Delete clears</span>
          <span className={styles.hintNarrow}>Drag to turn · chips to pin</span>
        </p>
      )}

      <p className={styles.srOnly} aria-live="polite">
        {message}
      </p>
    </div>
  );
}

/** The marker, in the kit's units, its tip at (0, 75.2): two bars over a down-arrow, outlined in
 * white and then in a thin dark line. The glow behind is its own blurred copy, shown at the edge. */
function MarkerArt({ glowId }: { glowId: string }) {
  const shapes = (
    <>
      <rect data-bar="1" x="-8" y="0" width="16" height="6.6" rx="1" />
      <rect data-bar="2" x="-8" y="14.2" width="16" height="4.8" rx="1" />
      <path d="M-8 26.9H8V42H22.4L0 75.2L-22.4 42H-8Z" />
    </>
  );
  return (
    <svg className={styles.art} viewBox="-24 -2 48 80" aria-hidden="true" data-part="arrow">
      <defs>
        <filter id={glowId} x="-1" y="-1" width="3" height="3">
          <feGaussianBlur stdDeviation="4" />
        </filter>
      </defs>
      <g className={styles.glow} filter={`url(#${glowId})`}>
        {shapes}
      </g>
      <g className={styles.rim}>
        <rect className={styles.bar} data-bar="1" x="-8" y="0" width="16" height="6.6" rx="1" />
        <rect className={styles.bar} data-bar="2" x="-8" y="14.2" width="16" height="4.8" rx="1" />
        <path className={styles.arrow} d="M-8 26.9H8V42H22.4L0 75.2L-22.4 42H-8Z" />
      </g>
      <g className={styles.outline}>
        <rect className={styles.bar} data-bar="1" x="-8" y="0" width="16" height="6.6" rx="1" />
        <rect className={styles.bar} data-bar="2" x="-8" y="14.2" width="16" height="4.8" rx="1" />
        <path className={styles.arrow} d="M-8 26.9H8V42H22.4L0 75.2L-22.4 42H-8Z" />
      </g>
    </svg>
  );
}

function PinGlyph() {
  return (
    <svg className={styles.glyph} viewBox="0 0 16 16" aria-hidden="true">
      <path d="M5 1.5h6v2H5zM5 5h6v1.6H5zM5 8h6v1.5h3L8 15 2 9.5h3z" />
    </svg>
  );
}

function Chevron({ dir }: { dir: 'left' | 'up' | 'right' }) {
  const rotate = dir === 'left' ? 180 : dir === 'up' ? -90 : 0;
  return (
    <svg
      className={styles.glyph}
      viewBox="0 0 16 16"
      aria-hidden="true"
      style={{ rotate: `${rotate}deg` }}
    >
      <path className={styles.stroke} d="M5.5 2.5 11 8l-5.5 5.5" />
    </svg>
  );
}
```

### waypoint-marker/waypoint-marker.module.css

```css
/* Waypoint marker (waypoint-marker.tsx): the marker alone, on whatever is behind it. The view is
   the player's screen, marked only by four faint corner ticks; the loop places the marker by
   transforms from the view's top-left, at its tip, which it turns (at the edges) and scales (with
   proximity) about. The chips and the hint sit below the view, as plain lab controls.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets
   (data-color-scheme). The marker keeps the kit's orange and white on both, with a thin dark rim
   outside the white and the readout on its own dark plate, so it reads on a light stage too.

   The marker, in the kit's units (1.5 px each at scale 1): bars 16 wide, 6.6 and 4.8 tall, 7.6
   apart; the shaft 16 wide, 15 tall; the head 44.8 wide, 33.2 tall; a 1.7 white outline outside the
   orange, round-joined, and a 1.3 dark rim outside that. */

.root {
  /* The view's corner ticks. */
  --frame: rgb(0 0 0 / 0.18);
  /* The marker: the kit's orange and white, the dark rim, the glow at the edge. */
  --orange: #fe6500;
  --outline: #ffffff;
  --rim: rgb(12 12 14 / 0.55);
  --glow: #ff7a1a;
  /* The readout's plate. */
  --plate: rgb(14 15 18 / 0.82);
  --plate-line: rgb(255 255 255 / 0.14);
  --readout: #ffffff;
  /* Lab chips under the view. */
  --chip: rgb(0 0 0 / 0.05);
  --chip-hover: rgb(0 0 0 / 0.09);
  --chip-line: rgb(0 0 0 / 0.1);
  --chip-ink: #1f2023;
  --chip-on: #fe6500;
  --hint: rgb(0 0 0 / 0.5);
  --focus: #fe6500;
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  width: 100%;
  color: var(--chip-ink);
  font-family:
    ui-rounded,
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
  -webkit-user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --frame: light-dark(rgb(0 0 0 / 0.18), rgb(255 255 255 / 0.2));
    --rim: light-dark(rgb(12 12 14 / 0.55), rgb(0 0 0 / 0.6));
    --plate: light-dark(rgb(14 15 18 / 0.82), rgb(0 0 0 / 0.62));
    --chip: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --chip-hover: light-dark(rgb(0 0 0 / 0.09), rgb(255 255 255 / 0.12));
    --chip-line: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.12));
    --chip-ink: light-dark(#1f2023, #ecedef);
    --hint: light-dark(rgb(0 0 0 / 0.5), rgb(255 255 255 / 0.5));
    --focus: light-dark(#d95500, #ff8a3d);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The view: the player's screen; it takes the keys and the drags. */
.view {
  position: relative;
  width: 100%;
  height: clamp(250px, 44cqi, 340px);
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  cursor: grab;
  touch-action: pan-y;
  outline: none;
}
.view:active {
  cursor: grabbing;
}
.view:not([tabindex]) {
  cursor: default;
}
.view:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}

/* The corner ticks: 18 px arms, 2 px thick, 8 px in from the view's corners. */
.frame {
  position: absolute;
  inset: 8px;
  pointer-events: none;
}
.frame span {
  position: absolute;
  width: 18px;
  height: 18px;
  border: 0 solid var(--frame);
}
.frame span[data-corner='tl'] {
  top: 0;
  left: 0;
  border-width: 2px 0 0 2px;
  border-top-left-radius: 6px;
}
.frame span[data-corner='tr'] {
  top: 0;
  right: 0;
  border-width: 2px 2px 0 0;
  border-top-right-radius: 6px;
}
.frame span[data-corner='bl'] {
  bottom: 0;
  left: 0;
  border-width: 0 0 2px 2px;
  border-bottom-left-radius: 6px;
}
.frame span[data-corner='br'] {
  right: 0;
  bottom: 0;
  border-width: 0 2px 2px 0;
  border-bottom-right-radius: 6px;
}

/* The marker. */
.layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.marker,
.ping {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
}
.turner,
.sizer {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  transform-origin: 0 0;
}
/* The art is 48 by 80 kit units at 1.5 px each, its tip 77.2 units down. */
.stamp {
  position: absolute;
  left: -36px;
  top: -115.8px;
  width: 72px;
  height: 120px;
  transform-origin: 36px 115.8px;
}
.art {
  display: block;
  width: 72px;
  height: 120px;
  overflow: visible;
}
.rim {
  fill: var(--rim);
  stroke: var(--rim);
  stroke-width: 6px;
  stroke-linejoin: round;
}
.outline {
  fill: var(--orange);
  stroke: var(--outline);
  stroke-width: 3.4px;
  stroke-linejoin: round;
  paint-order: stroke;
}
.glow {
  fill: var(--glow);
  opacity: 0;
}
.bar,
.arrow {
  transform-box: fill-box;
  transform-origin: 50% 100%;
}

/* Place: the arrow drops in with an overshoot, the bars follow it in, staggered (rim and fill
   together). */
.stamp[data-phase='drop'] .arrow {
  animation: drop 420ms cubic-bezier(0.3, 0.7, 0.4, 1.35) both;
}
.stamp[data-phase='drop'] .bar[data-bar='2'] {
  animation: drop 380ms 80ms cubic-bezier(0.3, 0.7, 0.4, 1.35) both;
}
.stamp[data-phase='drop'] .bar[data-bar='1'] {
  animation: drop 360ms 150ms cubic-bezier(0.3, 0.7, 0.4, 1.35) both;
}
@keyframes drop {
  from {
    opacity: 0;
    transform: translateY(-34px);
  }
  35% {
    opacity: 1;
  }
  to {
    opacity: 1;
    transform: none;
  }
}
/* Arrive: a squash, then a pop and fade. */
.stamp[data-phase='arrive'] {
  animation: arrive 360ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
}
@keyframes arrive {
  from {
    transform: none;
    opacity: 1;
  }
  28% {
    transform: scale(1.14, 0.8);
    opacity: 1;
  }
  to {
    transform: scale(1.35);
    opacity: 0;
  }
}
.stamp[data-phase='clear'] {
  animation: clear 220ms ease-in both;
}
@keyframes clear {
  to {
    transform: scale(0.7);
    opacity: 0;
  }
}

/* Off screen: the glow behind the sharp shapes pulses at the edge (the bob turns into a nudge,
   from the loop). */
.marker:is([data-edge='left'], [data-edge='right']) .glow {
  animation: edge 1.2s ease-in-out infinite;
}
@keyframes edge {
  0%,
  100% {
    opacity: 0.25;
  }
  50% {
    opacity: 0.85;
  }
}

/* The ground ping at the spot. */
.ping::after {
  content: '';
  position: absolute;
  left: -32px;
  top: -9px;
  width: 64px;
  height: 18px;
  border: 2.5px solid var(--orange);
  border-radius: 50%;
  box-shadow:
    0 0 0 1px var(--rim),
    inset 0 0 0 1px var(--rim);
  opacity: 0;
}
.ping[data-phase='drop']::after {
  animation: ping 700ms 120ms ease-out both;
}
.ping[data-phase='arrive']::after {
  animation: ping 600ms ease-out both;
}
@keyframes ping {
  from {
    opacity: 0.95;
    transform: scale(0.2);
  }
  to {
    opacity: 0;
    transform: scale(1.7);
  }
}

/* The distance, on its own dark plate. */
.readout {
  position: absolute;
  top: 0;
  left: 0;
  box-sizing: border-box;
  height: 26px;
  padding: 0 10px;
  display: flex;
  align-items: center;
  border-radius: 999px;
  background: var(--plate);
  box-shadow: inset 0 0 0 1px var(--plate-line);
  white-space: nowrap;
  color: var(--readout);
  font-size: 14px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  transition: opacity 160ms ease;
}
.readout[data-phase='drop'] {
  animation: fade 240ms 220ms ease-out both;
}
.readout:is([data-phase='arrive'], [data-phase='clear']) {
  opacity: 0;
}
@keyframes fade {
  from {
    opacity: 0;
  }
}

/* The chips and the hint: plain lab controls under the view. */
.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px 16px;
}
.group {
  display: flex;
  gap: 6px;
}
.chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  min-width: 34px;
  height: 34px;
  padding: 0 12px;
  border: 0;
  border-radius: 999px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-line);
  color: var(--chip-ink);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  touch-action: none;
  transition:
    background-color 140ms ease,
    transform 120ms ease;
}
.chip:hover {
  background: var(--chip-hover);
}
.chip:active {
  transform: scale(0.95);
}
.chip[aria-pressed='true'] {
  background: var(--chip-on);
  box-shadow: none;
  color: #fff;
}
.chip:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.glyph {
  width: 14px;
  height: 14px;
  flex: none;
  fill: currentColor;
}
.stroke {
  fill: none;
  stroke: currentColor;
  stroke-width: 2px;
  stroke-linecap: round;
  stroke-linejoin: round;
}
@container (max-width: 400px) {
  .chip {
    padding: 0 10px;
    gap: 4px;
  }
}
.hint {
  margin: 0;
  color: var(--hint);
  font-size: 12px;
  font-weight: 500;
  line-height: 1.3;
  text-align: center;
}
.hintNarrow {
  display: none;
}
@container (max-width: 520px) {
  .hintWide {
    display: none;
  }
  .hintNarrow {
    display: inline;
  }
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* Reduced motion: fades only, no overshoot, no pulse (the loop drops the bob and the nudge). */
.root[data-reduced] .stamp[data-phase] .bar,
.root[data-reduced] .stamp[data-phase] .arrow,
.root[data-reduced] .marker .glow {
  animation: none;
}
.root[data-reduced] .marker:is([data-edge='left'], [data-edge='right']) .glow {
  opacity: 0.6;
}
.root[data-reduced] .stamp[data-phase='drop'] {
  animation: fade 200ms ease-out both;
}
.root[data-reduced] .stamp:is([data-phase='arrive'], [data-phase='clear']) {
  animation: out 200ms ease-out both;
}
.root[data-reduced] .ping::after {
  animation: none;
}
@keyframes out {
  to {
    opacity: 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  .chip {
    transition: none;
  }
}
```

### waypoint-marker/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. `visible` and `reduced` are exposed too, for
 * motion that is not the demo (a loop that should sleep offscreen, a bob that should stop).
 */
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 };
}
```