# Aim reticle

> A game sight in two kinds: three ticks that spread and tighten with a distance readout, and a glowing cross that locks on.

The sight a game draws in the middle of the screen, on the line of fire, shown on its own. The spread reticle opens while the view turns, closes as it holds still, draws in further while a shot is held and kicks out when it flies, with the distance to whatever sits under the dot laid along one tick. The lock reticle zooms while held, and snaps its four ticks in round a target kept under the cross. Each shape carries its own dark edge, so the sight reads on light and dark alike.

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

Category: Games · HUD.

## Anatomy

1. **Stage**: The see-through 16:9 aiming area, 340 px tall at least, with the sight in its middle: the pointer, a finger or the arrow keys turn the view under the sight.
2. **Reticle switch**: Two chips under the sight, Spread and Lock-on, that lower one sight and raise the other.
3. **Dot**: The 6 px point of aim, white with a dark edge: where a shot lands, with a ring and, on a target, a hit mark.
4. **Spread tick**: One of three white 6 × 26 px ticks, their middles 48 px from the dot at rest; turning opens them by up to 32 px, a full draw closes them by 20.
5. **Distance readout**: The distance to whatever sits under the dot, 28 px white figures with a dark outline, laid along the lower-right tick; `distance` pins it.
6. **Lock cross**: A 60 px cyan cross, 4 px thick, with a dark edge and a glow behind; it pops and pulses twice when the lock takes.
7. **Lock tick**: One of four cyan 4 × 22 px ticks 100 px out, drawn in by 14 at full zoom and snapped in to 52 when locked on.

## When to use

- A game or playful view where the player aims at a point and the sight has to say how steady the aim is and how far the shot will go.
- A charged action, held to draw and released to fire: the spread ticks close in as the draw builds and flash at full draw.
- Picking out one target to track: the lock reticle zooms while held and, while held, snaps its ticks in once the target stays under the cross.
- A sight laid over your own game view: the stage is see-through, and the sight carries its own dark edge, so it reads over bright and dark frames alike.

## When not to use

- Picking a point on a map or an image in a working tool. Use a plain crosshair cursor or a map pin.
- Showing how far along a task is. Use a progress bar or a progress ring.
- Choosing one of a few options by holding. Use a radial menu or a quick selector.
- A countdown before an action happens. Use a timer ring or an effect timer.
- Pointing the reader at something off screen. Use a waypoint marker at the screen’s edge.

## Installation

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

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

export function BowHud({ aiming, drawn, meters }: { aiming: boolean; drawn: number; meters: number }) {
  if (!aiming) return null;
  return (
    <AimReticle
      variant="spread"
      charge={drawn}
      distance={meters}
      controls={false}
      autoplay={false}
    />
  );
}
```

## States

- **Spread at rest**: The view is still and off the target: the three ticks sit 48 px out and the readout reads the open ground.
- **Moving**: `spread={1}`: the view is turning fast and the ticks fly apart, so a shot would stray.
- **On target**: `aimOnTarget`: a target sits under the dot, so the readout reads its distance and a shot hits.
- **Drawing**: `charge={0.5}`: held halfway, the ticks are closing in on the dot.
- **Full draw**: `charge={1}`: the ticks are drawn in tight with a glow and the dot flashes once. Releasing fires.
- **Lock, searching**: `variant="lock"`: the cross with its four ticks out wide, nothing under it yet.
- **Lock, zoomed**: `zoom={1}`: held, the view zooms and the ticks draw in a little.
- **Locked on**: `locked` with `aimOnTarget` and `zoom={1}`: the ticks snap in round the cross and the glow pulses twice.
- **Sight only**: `controls={false}`: no chips or hint, just the sight on its see-through stage, to lay over your own game view.
- **Light**: `colorScheme="light"`, or a light page: the white and cyan keep a firm dark edge, so they hold on a pale stage.
- **Dark**: `colorScheme="dark"`, or a dark page: the same sights, with the chips turned light on dark.

## How it works

- The sight stays in the middle of its stage, as a HUD keeps it in the middle of the screen; the pointer, a finger or the arrow keys turn the view under it. A target sits where the view started, so turning back to the middle puts the dot on it; `aimOnTarget` says so from your game instead.
- One requestAnimationFrame loop owns the motion, and only while the stage is on screen and its tab visible: the look eases after the input, its speed heats the spread (fast to open, about 260 ms to cool), and it sleeps once everything is still.
- Each kind of tick rides a damped spring, a little under critical so it overshoots and settles like a physical sight. The loop writes their radii as two custom properties, --r and --lr, and every tick places itself with rotate() and translateY() from them.
- Spread: three 6 × 26 px ticks at 0°, 120° and 240°, 48 px out at rest, around a 6 px dot. Turning opens them by up to 32 px; a hold draws them in by 20 px over 0.9 s, with a flash and a warm flare at full draw; the release kicks the spring outward, and a ring lands on the dot later the further the shot flies, with a four-mark hit sign on a target.
- The readout runs outward along the lower-right tick on its upper side, so it travels with the spread. It reads the target’s distance when the dot is on it, and otherwise the ground’s, further as the look rises; the number rolls there rather than jumping.
- Lock: a 60 px cross 4 px thick and four 4 × 22 px ticks 100 px out, cyan with a glow behind each bar, so where the bars cross the light doubles. Holding zooms and draws the ticks to 86 px; only then, a target under the cross for 200 ms (within 26 px) locks: the ticks snap to 52 px, the cross pops and the glow pulses twice; it lets go past 40 px.
- Every shape is a CSS box drawn at its size times `scale`, with no bitmap, blur filter or scaling transform at rest, so it stays sharp at any pixel ratio. White and cyan keep a crisp dark outline (a box-shadow spread, or a stroke painted under the figures) with a soft shade or glow behind it, so the sight reads on a light page as on a dark one.

## API reference

- **variant** (`'spread' | 'lock'`): Which reticle shows; with it the reticle is controlled, so pair it with onVariantChange. A change lowers one sight and raises the other.
- **defaultVariant** (`'spread' | 'lock'`, default `'spread'`): The reticle shown first, when variant is not set.
- **onVariantChange** (`(variant: 'spread' | 'lock') => void`): Called when the reader picks the other reticle.
- **distance** (`number`): Pins the spread readout to this number; otherwise it reads the look. Turns the demo off.
- **spread** (`number`): Pins how far turning has opened the spread ticks, 0 to 1 (up to 32 px). Turns the demo off.
- **charge** (`number`): Pins the draw, 0 to 1: the spread ticks close in by up to 20 px, with a flash at 1. Turns the demo off.
- **zoom** (`number`): Pins the lock reticle’s zoom, 0 to 1. Turns the demo off.
- **locked** (`boolean`): Pins the lock: the ticks snap in round the cross. Turns the demo off.
- **aimOnTarget** (`boolean`): Says whether a target sits under the sight, in place of the look’s own target: the readout reads the target, a shot hits and the lock reticle can lock on. Turns the demo off.
- **controls** (`boolean`, default `true`): Shows the two chips that swap the reticle, and the hold hint, under the sight.
- **scale** (`number`, default `1`): Draws the reticle this many times its own size.
- **onFire** (`(shot: { charge: number; distance: number; hit: boolean }) => void`): Called on each shot of the spread reticle, with how far it was drawn.
- **onLockChange** (`(locked: boolean) => void`): Called when the lock reticle locks on or loses the target.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run turns, draws and fires the spread reticle, then zooms and locks on with the other. Any real input hands it over.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours (the edge’s weight and the chips), or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Added to the stage.

## Accessibility

- The aiming area is one focusable group: the arrow keys turn the view (Shift for bigger steps), Space or Enter held draws or zooms, released shoots, and Escape lets go without shooting. Its description says how, and the current distance or lock.
- A status region announces each shot (its distance, a full draw, a hit) and each lock gained or lost.
- The reticle buttons are real buttons with aria-pressed, and every control has a visible focus ring.
- The sights and the shot effects are aria-hidden: their meaning is in the text above.
- With reduced motion there is no demo, no overshoot, pulse or flash: the sights move straight to each state.
- The loop and the demo pause when the stage is off screen or its tab hidden.

## Source

### aim-reticle/aim-reticle.tsx

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

'use client';

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

/*
 * Aim reticle: the sight a game draws on the player's line of fire, in two kinds, shown on its own
 * in the middle of its aiming area, as a HUD draws it in the middle of the screen.
 *
 * - Spread: three white ticks around a dot, with the distance to whatever sits under the dot laid
 *   along the lower-right tick. The ticks fly apart while the view turns and close back in as it
 *   holds still; holding draws the shot and pulls them in further, to a flash at full draw, and
 *   releasing fires: the ticks kick out, and a ring (with a hit mark on a target) lands on the dot
 *   a little later the further the shot flies.
 * - Lock: a glowing cyan cross with four ticks around it. Holding zooms, and the ticks draw in a
 *   little; keep a target under the cross for a moment and the ticks snap in round it, the cross
 *   pops and the glow pulses twice: locked on. Turn off it and they ease back out.
 *
 * - The sight stays put; the pointer (or a finger, or the arrow keys) turns the view under it, so
 *   the look is a point measured from the sight. A target sits where the view started, at the
 *   look's origin: aim back there and the dot is on it. `aimOnTarget` says so from outside instead.
 * - One loop (requestAnimationFrame) owns the motion, and only while the stage is on screen and
 *   its tab visible: the look eases after the input, its speed heats the spread, and two springs
 *   (one per kind of tick) carry the radii, with a little overshoot so they feel physical. It
 *   writes two custom properties (--r, --lr); React only hears of what a reader can perceive: the
 *   readout's number, a lock, a full draw.
 * - The radii are in the drawing's px (times `scale`): the spread ticks are 6 wide and 26 long,
 *   their middles 48 from the dot at rest; the lock cross is 60 across, its ticks 22 long at 100.
 *   Every shape is a CSS box drawn at its size (no bitmap, no blur, no scaling transform at rest),
 *   with a dark edge so the white and cyan read on a light page as on a dark one.
 * - While on screen and untouched, a short demo plays both reticles; any real input hands it over.
 */

export type AimVariant = 'spread' | 'lock';
export type AimShot = { charge: number; distance: number; hit: boolean };
type Point = { x: number; y: number };

/** The spread ticks' middles, from the dot, at rest; how far turning opens them, a draw closes them. */
const REST = 48;
const MOVE_SPREAD = 32;
const DRAW_IN = 20;
/** The outward kick of a shot, in px per second given to the spread spring. */
const KICK = 1900;
/** The lock ticks' middles: at rest, how much a full zoom draws them in, and locked on. */
const LOCK_OUT = 100;
const LOCK_ZOOM_IN = 14;
const LOCK_IN = 52;
/** Lock on within LOCK_ENTER px of the target, held LOCK_DWELL ms; let go past LOCK_LEAVE px. */
const LOCK_ENTER = 26;
const LOCK_LEAVE = 40;
const LOCK_DWELL = 200;
/** A full draw, and a full zoom, in ms; a full zoom magnifies the look 1 + ZOOM_MAX times. */
const DRAW_TIME = 900;
const ZOOM_TIME = 300;
const ZOOM_MAX = 0.6;
/** The readout over open ground (the look level), and over the target. */
const GROUND = 60;
const TARGET_DISTANCE = 42;
/** Within this many px of the look's origin the dot is on the target: the readout and a shot see it. */
const ON_TARGET = 30;

const clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value));
const easeOut = (t: number) => 1 - (1 - t) ** 3;
const easeInOut = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);

/** A damped spring, one step: `ratio` under 1 overshoots. */
function spring(
  at: number,
  speed: number,
  to: number,
  stiffness: number,
  ratio: number,
  dt: number,
): [number, number] {
  const damping = 2 * Math.sqrt(stiffness) * ratio;
  // Small steps, so a long frame cannot throw the spring.
  const steps = Math.ceil(dt / 0.008);
  const h = dt / steps;
  for (let i = 0; i < steps; i++) {
    speed += (stiffness * (to - at) - damping * speed) * h;
    at += speed * h;
  }
  return [at, speed];
}

const SPREAD_TICKS = [0, 120, 240];
const LOCK_TICKS = [0, 90, 180, 270];
const HIT_MARKS = [45, 135, 225, 315];
const STOP = Symbol('stop');

export function AimReticle({
  variant,
  defaultVariant = 'spread',
  onVariantChange,
  distance,
  spread,
  charge,
  zoom,
  locked,
  aimOnTarget,
  controls = true,
  scale = 1,
  onFire,
  onLockChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** Which reticle shows; with it, the reticle is controlled (pair it with onVariantChange). */
  variant?: AimVariant;
  /** The reticle shown first, when `variant` is not set. */
  defaultVariant?: AimVariant;
  /** Called when the reader picks the other reticle. */
  onVariantChange?: (variant: AimVariant) => void;
  /** Pins the spread reticle's readout to this number; otherwise it reads the look. */
  distance?: number;
  /** Pins how far turning has opened the spread ticks, 0 to 1. */
  spread?: number;
  /** Pins the draw, 0 to 1: the spread ticks close in, with a flash at 1. */
  charge?: number;
  /** Pins the lock reticle's zoom, 0 to 1. */
  zoom?: number;
  /** Pins the lock: the ticks snap in round the cross. */
  locked?: boolean;
  /** Says whether a target sits under the sight, instead of the look's own target. */
  aimOnTarget?: boolean;
  /** Shows the chips that swap the reticle, and the hold hint, under it. */
  controls?: boolean;
  /** Draws the reticle this many times its own size. */
  scale?: number;
  /** Called on each shot of the spread reticle. */
  onFire?: (shot: AimShot) => void;
  /** Called when the lock reticle locks on or loses the target. */
  onLockChange?: (locked: boolean) => void;
  /** While on screen and untouched, a short demo turns, draws, fires and locks on. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(5000);
  const [own, setOwn] = useState<AimVariant>(defaultVariant);
  const wanted = variant ?? own;
  /** The reticle on screen: a swap lowers one before raising the other. */
  const [shown, setShown] = useState<AimVariant>(wanted);
  const [raised, setRaised] = useState(true);
  const [isLocked, setIsLocked] = useState(false);
  const [full, setFull] = useState(false);
  const [readout, setReadout] = useState(GROUND);
  const [message, setMessage] = useState('');

  const sight = useRef<HTMLDivElement>(null);
  const impact = useRef<HTMLSpanElement>(null);
  const hitMark = useRef<HTMLSpanElement>(null);

  /** The loop's state, outside React: `aim` is where the input points the look, `pos` the look. */
  const live = useRef({
    aim: { x: 0, y: 0 } as Point,
    pos: { x: 0, y: 0 } as Point,
    glide: null as null | { from: Point; to: Point; start: number; dur: number },
    heat: 0,
    charge: 0,
    zoom: 0,
    holding: false,
    r: REST,
    rv: 0,
    lr: LOCK_OUT,
    lrv: 0,
    dwell: 0,
    lockedNow: false,
    lockedShown: false,
    full: false,
    dist: GROUND,
    readout: GROUND,
    gap: 0,
    variant: wanted,
    reach: { x: 0, y: 0 },
  });
  const wake = useRef<() => void>(() => {});

  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  /** What the loop reads of the props, fresh each render. */
  const props = useRef({ distance, spread, charge, zoom, locked, aimOnTarget, shown });
  props.current = { distance, spread, charge, zoom, locked, aimOnTarget, shown };
  const handlers = useRef({ onFire, onLockChange, announce });
  handlers.current = { onFire, onLockChange, announce };

  const pinned =
    distance !== undefined ||
    spread !== undefined ||
    charge !== undefined ||
    zoom !== undefined ||
    locked !== undefined ||
    aimOnTarget !== undefined;
  const demo = autoplay && playing && !pinned;

  // A swap: the shown reticle lowers, then the other one rises.
  useEffect(() => {
    if (wanted === shown) return;
    const quick = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    setRaised(false);
    const timer = window.setTimeout(
      () => {
        setShown(wanted);
        setRaised(true);
      },
      quick ? 0 : 140,
    );
    return () => window.clearTimeout(timer);
  }, [wanted, shown]);

  /** Fires the spread reticle: the kick, then the ring (and a hit mark) landing on the dot. */
  const fire = useRef(() => {});
  fire.current = () => {
    const s = live.current;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const drawn = props.current.charge ?? s.charge;
    const hit = s.gap < ON_TARGET;
    const meters = s.readout;
    s.charge = 0;
    if (!reduced) s.rv += KICK * (0.45 + 0.55 * drawn);
    wake.current();
    handlers.current.onFire?.({ charge: drawn, distance: meters, hit });
    handlers.current.announce(
      `${drawn >= 1 ? 'Full draw shot' : 'Shot'} at ${meters}${hit ? ', hit' : ''}.`,
    );
    // The shot takes longer to land the further it flies, and less the harder it was drawn.
    const flight = reduced ? 0 : (110 + meters * 2.4) * (1.5 - 0.5 * drawn);
    impact.current?.animate(
      reduced
        ? [{ opacity: 0.9 }, { opacity: 0 }]
        : [
            { transform: 'scale(0.3)', opacity: 1 },
            { transform: 'scale(1.6)', opacity: 0 },
          ],
      { duration: 420, delay: flight, easing: 'cubic-bezier(0.2, 0.8, 0.3, 1)' },
    );
    if (hit)
      hitMark.current?.animate(
        reduced
          ? [{ opacity: 1 }, { opacity: 1, offset: 0.7 }, { opacity: 0 }]
          : [
              { transform: 'scale(0.6)', opacity: 0 },
              { transform: 'scale(1.15)', opacity: 1, offset: 0.2 },
              { transform: 'scale(1)', opacity: 1, offset: 0.7 },
              { transform: 'scale(1)', opacity: 0 },
            ],
        { duration: 560, delay: flight, easing: 'ease-out' },
      );
  };

  const hold = () => {
    live.current.holding = true;
    wake.current();
  };
  /** Lets go: the spread reticle fires (a cancel never does); the lock reticle zooms back out. */
  const release = (cancel = false) => {
    const s = live.current;
    if (!s.holding) return;
    s.holding = false;
    if (!cancel && props.current.shown === 'spread' && s.charge > 0.04) fire.current();
    wake.current();
  };
  const releaseRef = useRef(release);
  releaseRef.current = release;

  // The loop.
  useEffect(() => {
    const stage = root.current;
    const reticle = sight.current;
    if (!stage || !reticle) return;
    const s = live.current;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
    let frame = 0;
    let last = 0;
    let onScreen = false;
    // How far the look may turn: about as far as the pointer can reach from the sight.
    const measure = () => {
      s.reach = {
        x: Math.max(0, stage.clientWidth / 2 - 12),
        y: Math.max(0, stage.clientHeight / 2 - 12),
      };
    };
    measure();

    const step = (now: number) => {
      frame = 0;
      const dt = Math.min(0.05, Math.max(0.001, (now - last) / 1000));
      last = now;
      const p = props.current;
      const still = reduced.matches;

      // A new reticle starts afresh.
      if (p.shown !== s.variant) {
        s.variant = p.shown;
        s.charge = 0;
        s.zoom = 0;
        s.dwell = 0;
        s.lockedNow = false;
        s.holding = false;
      }

      // The input: the demo's glides, else where the reader put it.
      if (s.glide) {
        const k = clamp((now - s.glide.start) / s.glide.dur);
        const e = easeInOut(k);
        s.aim = {
          x: s.glide.from.x + (s.glide.to.x - s.glide.from.x) * e,
          y: s.glide.from.y + (s.glide.to.y - s.glide.from.y) * e,
        };
        if (k >= 1) s.glide = null;
      }
      s.aim = {
        x: clamp(s.aim.x, -s.reach.x, s.reach.x),
        y: clamp(s.aim.y, -s.reach.y, s.reach.y),
      };

      // The look eases after the input; its speed heats the spread, which cools when still.
      const ease = still ? 1 : 1 - Math.exp(-dt * 16);
      const before = { ...s.pos };
      s.pos = {
        x: s.pos.x + (s.aim.x - s.pos.x) * ease,
        y: s.pos.y + (s.aim.y - s.pos.y) * ease,
      };
      const speed = Math.hypot(s.pos.x - before.x, s.pos.y - before.y) / dt;
      const heatTo = still ? 0 : clamp(speed / 700);
      s.heat += (heatTo - s.heat) * (1 - Math.exp(-dt / (heatTo > s.heat ? 0.05 : 0.26)));
      if (s.heat < 0.001) s.heat = 0;

      // Holding draws the spread reticle, or zooms the lock one.
      if (s.variant === 'spread')
        s.charge = s.holding ? clamp(s.charge + (dt * 1000) / DRAW_TIME) : clamp(s.charge - dt * 6);
      const zoomTo = s.variant === 'lock' && s.holding ? 1 : 0;
      const zoomStep = still ? 1 : (dt * 1000) / ZOOM_TIME;
      s.zoom = zoomTo > s.zoom ? Math.min(1, s.zoom + zoomStep) : Math.max(0, s.zoom - zoomStep);

      const heat = clamp(p.spread ?? s.heat);
      const drawn = clamp(p.charge ?? s.charge);
      const zoomed = easeInOut(clamp(p.zoom ?? s.zoom));
      const magnify = 1 + ZOOM_MAX * zoomed;

      const isFull = s.variant === 'spread' && drawn >= 1;
      if (isFull !== s.full) {
        s.full = isFull;
        setFull(isFull);
      }

      // How far the target is from the dot, on screen (a zoom magnifies the gap).
      s.gap =
        p.aimOnTarget !== undefined
          ? p.aimOnTarget
            ? 0
            : Infinity
          : Math.hypot(s.pos.x, s.pos.y) * magnify;
      if (s.variant === 'lock') {
        // Only a held (zoomed) sight takes a lock, as a game's aim-down-sight does.
        const close = zoomed > 0.5 && s.gap < (s.lockedNow ? LOCK_LEAVE : LOCK_ENTER);
        s.dwell = close ? s.dwell + dt * 1000 : 0;
        const lock = s.lockedNow ? close : s.dwell >= LOCK_DWELL;
        if (lock !== s.lockedNow) {
          s.lockedNow = lock;
          if (p.locked === undefined) handlers.current.onLockChange?.(lock);
        }
      } else s.lockedNow = false;
      const lockNow = s.variant === 'lock' && (p.locked ?? s.lockedNow);
      if (lockNow !== s.lockedShown) {
        s.lockedShown = lockNow;
        setIsLocked(lockNow);
        if (lockNow) handlers.current.announce('Locked on.');
        else if (s.variant === 'lock') handlers.current.announce('Lock lost.');
      }

      // The springs.
      const rTo = REST + MOVE_SPREAD * heat - DRAW_IN * easeOut(drawn);
      const lrTo = lockNow ? LOCK_IN : LOCK_OUT - LOCK_ZOOM_IN * zoomed;
      if (still) {
        [s.r, s.rv, s.lr, s.lrv] = [rTo, 0, lrTo, 0];
      } else {
        [s.r, s.rv] = spring(s.r, s.rv, rTo, 520, 0.5, dt);
        [s.lr, s.lrv] = spring(s.lr, s.lrv, lrTo, 640, 0.42, dt);
      }

      // The readout: the target's distance under the dot, else the ground's, further as the look rises.
      const distTo =
        p.distance ?? (s.gap < ON_TARGET ? TARGET_DISTANCE : GROUND * 2 ** (-s.pos.y / 90));
      s.dist = still ? distTo : s.dist + (distTo - s.dist) * (1 - Math.exp(-dt * 10));
      if (Math.abs(distTo - s.dist) < 0.4) s.dist = distTo;
      const number = Math.round(clamp(s.dist, 1, 999));
      if (number !== s.readout) {
        s.readout = number;
        setReadout(number);
      }

      // Drawing: two numbers; every tick places itself from them.
      reticle.style.setProperty('--r', s.r.toFixed(2));
      reticle.style.setProperty('--lr', s.lr.toFixed(2));

      // Sleep once all is still.
      const settled =
        !s.glide &&
        !s.holding &&
        s.heat === 0 &&
        Math.abs(s.aim.x - s.pos.x) < 0.05 &&
        Math.abs(s.aim.y - s.pos.y) < 0.05 &&
        Math.abs(s.r - rTo) < 0.02 &&
        Math.abs(s.rv) < 0.05 &&
        Math.abs(s.lr - lrTo) < 0.02 &&
        Math.abs(s.lrv) < 0.05 &&
        (s.charge === 0 || s.charge === 1) &&
        (s.zoom === 0 || s.zoom === 1) &&
        s.dist === distTo;
      if (!settled) frame = requestAnimationFrame(step);
    };

    const start = () => {
      if (frame || !onScreen || document.hidden) return;
      last = performance.now();
      frame = requestAnimationFrame(step);
    };
    const stop = () => {
      cancelAnimationFrame(frame);
      frame = 0;
    };
    wake.current = start;
    const resize = new ResizeObserver(() => {
      measure();
      start();
    });
    resize.observe(stage);
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      if (onScreen) start();
      else stop();
    });
    observer.observe(stage);
    const visibility = () => (document.hidden ? stop() : start());
    document.addEventListener('visibilitychange', visibility);
    reduced.addEventListener('change', start);
    return () => {
      stop();
      wake.current = () => {};
      resize.disconnect();
      observer.disconnect();
      document.removeEventListener('visibilitychange', visibility);
      reduced.removeEventListener('change', start);
    };
  }, [root]);

  // Props the loop reads: a change wakes it.
  useEffect(() => {
    wake.current();
  }, [distance, spread, charge, zoom, locked, aimOnTarget, shown]);

  // The demo: a short run with each reticle, through the same inputs a reader uses.
  useEffect(() => {
    if (!demo) return;
    const s = live.current;
    let cancelled = false;
    const timers = new Set<number>();
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        const id = window.setTimeout(() => {
          timers.delete(id);
          if (cancelled) reject(STOP);
          else resolve();
        }, ms);
        timers.add(id);
      });
    const glide = (to: Point, dur: number) => {
      s.glide = { from: { ...s.pos }, to, start: performance.now(), dur };
      wake.current();
      return wait(dur);
    };
    const sections: AimVariant[] = variant ? [variant] : ['spread', 'lock'];
    const run = async () => {
      for (;;) {
        for (const kind of sections) {
          if (!variant) setOwn(kind);
          await wait(520);
          if (kind === 'spread') {
            // A flick (the ticks fly open), a look up the hill, then back onto the target and a
            // full draw.
            await glide({ x: -190, y: 40 }, 240);
            await wait(760);
            await glide({ x: -60, y: -70 }, 420);
            await wait(620);
            await glide({ x: 0, y: 0 }, 640);
            await wait(600);
            hold();
            await wait(1300);
            releaseRef.current();
            await wait(1300);
          } else {
            // Zoom in, turn onto the target, lock on, hold it, slip off, zoom out.
            hold();
            await wait(400);
            await glide({ x: 120, y: 70 }, 420);
            await wait(200);
            await glide({ x: 0, y: 0 }, 820);
            await wait(1500);
            await glide({ x: -58, y: 40 }, 360);
            await wait(600);
            releaseRef.current();
            await wait(350);
            await glide({ x: 0, y: 0 }, 640);
            await wait(800);
          }
        }
      }
    };
    run().catch(() => {});
    return () => {
      cancelled = true;
      for (const id of timers) window.clearTimeout(id);
      s.glide = null;
      releaseRef.current(true);
    };
    // hold() and the setters only touch refs and stable state setters.
  }, [demo, variant]);

  // The reader's inputs: the pointer's offset from the sight is the look.
  const aimAt = (event: ReactPointerEvent<HTMLDivElement>) => {
    const rect = event.currentTarget.getBoundingClientRect();
    const reticle = sight.current;
    const s = live.current;
    // Screen px to the stage's own, should it sit in a scaled container.
    const ratio = rect.width / event.currentTarget.offsetWidth || 1;
    const middle = reticle
      ? { x: reticle.offsetLeft, y: reticle.offsetTop }
      : { x: rect.width / 2 / ratio, y: rect.height / 2 / ratio };
    s.glide = null;
    s.aim = {
      x: (event.clientX - rect.left) / ratio - middle.x,
      y: (event.clientY - rect.top) / ratio - middle.y,
    };
    wake.current();
  };
  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    aimAt(event);
    hold();
  };
  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const s = live.current;
    const nudge = event.shiftKey ? 40 : 14;
    const moves: Record<string, Point> = {
      ArrowLeft: { x: -nudge, y: 0 },
      ArrowRight: { x: nudge, y: 0 },
      ArrowUp: { x: 0, y: -nudge },
      ArrowDown: { x: 0, y: nudge },
    };
    const move = moves[event.key];
    if (move) {
      event.preventDefault();
      // From where the look is headed, so quick presses add up; a demo's glide starts from the look.
      const from = s.glide ? s.pos : s.aim;
      s.glide = null;
      s.aim = { x: from.x + move.x, y: from.y + move.y };
      wake.current();
    } else if (event.key === ' ' || event.key === 'Enter') {
      event.preventDefault();
      if (!event.repeat) hold();
    } else if (event.key === 'Escape') release(true);
  };
  const onKeyUp = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.key === ' ' || event.key === 'Enter') release();
  };

  const pick = (kind: AimVariant) => {
    if (kind === wanted) return;
    if (variant === undefined) setOwn(kind);
    onVariantChange?.(kind);
  };

  const holdWord = shown === 'spread' ? 'draw' : 'zoom';
  const hintId = `${useId()}-hint`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-controls={controls}
      data-color-scheme={colorScheme}
      style={{ '--s': scale } as CSSProperties}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div
        className={styles.field}
        data-part="aim-area"
        tabIndex={0}
        role="group"
        aria-roledescription="aiming area"
        aria-label={shown === 'spread' ? 'Spread reticle' : 'Lock reticle'}
        aria-describedby={hintId}
        onPointerDown={onPointerDown}
        onPointerMove={(event) => {
          if (event.pointerType !== 'touch' || live.current.holding) aimAt(event);
        }}
        onPointerUp={() => release()}
        onPointerCancel={() => release(true)}
        onLostPointerCapture={() => release()}
        onKeyDown={onKeyDown}
        onKeyUp={onKeyUp}
        onBlur={() => release(true)}
      />

      <div
        ref={sight}
        className={styles.reticle}
        data-part="reticle"
        data-variant={shown}
        data-raised={raised}
        data-locked={isLocked}
        data-full={full}
        aria-hidden="true"
      >
        <span ref={impact} className={styles.impact} data-part="impact" />
        <span ref={hitMark} className={styles.hitMark} data-part="hit-mark">
          {HIT_MARKS.map((angle) => (
            <span key={angle} style={{ '--a': `${angle}deg` } as CSSProperties} />
          ))}
        </span>
        <div className={styles.sight}>
          {shown === 'spread' ? (
            <div className={styles.spread} data-part="spread">
              <span className={styles.dot} data-part="dot" />
              {SPREAD_TICKS.map((angle) => (
                <span
                  key={angle}
                  className={styles.tick}
                  data-part="spread-tick"
                  style={{ '--a': `${angle}deg` } as CSSProperties}
                />
              ))}
              <span className={styles.readout} data-part="readout">
                {readout}
              </span>
            </div>
          ) : (
            <div className={styles.lock} data-part="lock">
              <span className={styles.cross} data-part="cross">
                <span className={styles.crossX} />
                <span className={styles.crossY} />
              </span>
              {LOCK_TICKS.map((angle) => (
                <span
                  key={angle}
                  className={styles.lockTick}
                  data-part="lock-tick"
                  style={{ '--a': `${angle}deg` } as CSSProperties}
                />
              ))}
            </div>
          )}
        </div>
      </div>

      {controls && (
        <div className={styles.bar} data-part="bar">
          <div className={styles.controls} role="group" aria-label="Reticle" data-part="controls">
            {(['spread', 'lock'] as const).map((kind) => (
              <button
                key={kind}
                type="button"
                className={styles.choice}
                aria-pressed={wanted === kind}
                onClick={() => pick(kind)}
              >
                {kind === 'spread' ? 'Spread' : 'Lock-on'}
              </button>
            ))}
          </div>
          <p className={styles.hint} data-part="hint" aria-hidden="true">
            Hold to {holdWord}
          </p>
        </div>
      )}

      <p id={hintId} className={styles.hidden}>
        Move the pointer or press the arrow keys to turn the view under the sight; the middle holds
        a target. Hold the pointer, Space or Enter to {holdWord}
        {shown === 'spread' ? ', and release to shoot' : ''}.
        {shown === 'spread' ? ` Distance ${readout}.` : isLocked ? ' Locked on.' : ''}
      </p>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### aim-reticle/aim-reticle.module.css

```css
/* Aim reticle (aim-reticle.tsx): the sight alone, in the middle of a see-through aiming area, with
   the reticle chips and the hold hint in a row under it. The reticle is drawn in the drawing's own
   px times --s (the scale prop), from its middle; nothing is scaled by a transform at rest, so
   every edge is drawn at the screen's own resolution.

   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 sets (data-color-scheme). The sights keep their white and cyan on both, as
   a game's HUD does, each with its own dark edge (a crisp outline, then a soft shade behind it) so
   they read on a light stage as on a dark one; only the edge's weight and the chips change.

   Memory. The stage is one layout boundary, sized from outside. The tick radii are two custom
   properties (--r, --lr) on the reticle, so a frame restyles only its few small boxes. */

.root {
  /* The spread sight, its edge and the soft shade behind the edge. */
  --tick: #fffffa;
  --edge: rgb(14 18 24 / 0.78);
  --shade: rgb(14 18 24 / 0.22);
  /* Full draw: the warm flare round the ticks. */
  --flare: rgb(255 184 64 / 0.85);
  /* The lock sight, its edge and its glow. */
  --cyan: #a2e8fa;
  --cyan-edge: rgb(3 38 56 / 0.82);
  --glow: rgb(0 150 210 / 0.6);
  --glow-soft: rgb(0 150 210 / 0.32);
  /* The chips and the hint under the sight. */
  --chip: rgb(20 22 26 / 0.04);
  --chip-line: rgb(20 22 26 / 0.12);
  --chip-ink: #2a2d33;
  --chip-on: #17191d;
  --chip-on-ink: #f6f6f4;
  --muted: #6b6f78;
  --focus: #0a84c6;
  --bar: 0px;
  --o: calc(var(--s, 1) * 1.5px);
  position: relative;
  container-type: inline-size;
  contain: size layout;
  width: 100%;
  aspect-ratio: 16 / 9;
  min-height: 340px;
  max-height: 480px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: transparent;
  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;
  touch-action: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(14 18 24 / 0.78), rgb(0 0 0 / 0.72));
    --shade: light-dark(rgb(14 18 24 / 0.22), rgb(0 0 0 / 0.45));
    --flare: light-dark(rgb(255 168 40 / 0.9), rgb(255 214 140 / 0.8));
    --cyan-edge: light-dark(rgb(3 38 56 / 0.82), rgb(0 18 30 / 0.8));
    --glow: light-dark(rgb(0 140 200 / 0.55), rgb(0 160 220 / 0.7));
    --glow-soft: light-dark(rgb(0 140 200 / 0.28), rgb(0 160 220 / 0.4));
    --chip: light-dark(rgb(20 22 26 / 0.04), rgb(255 255 255 / 0.06));
    --chip-line: light-dark(rgb(20 22 26 / 0.12), rgb(255 255 255 / 0.12));
    --chip-ink: light-dark(#2a2d33, #e6e6e9);
    --chip-on: light-dark(#17191d, #f0f0f2);
    --chip-on-ink: light-dark(#f6f6f4, #141518);
    --muted: light-dark(#6b6f78, #9a9ea7);
    --focus: light-dark(#0a84c6, #7fd6ff);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited 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;
}
/* With the chips, the sight centres in the space above their row. */
.root[data-controls='true'] {
  --bar: 52px;
}

/* The aiming area: the whole stage, under the sight and the chips. */
.field {
  position: absolute;
  inset: 0;
  cursor: crosshair;
  outline: none;
}
.field:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
  border-radius: 18px;
}

/* The reticle: a point in the middle of the stage (above the chips' row). */
.reticle {
  --r: 48;
  --lr: 100;
  position: absolute;
  top: calc((100% - var(--bar)) / 2);
  left: 50%;
  width: 0;
  height: 0;
  pointer-events: none;
}
.sight {
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  transform: scale(1.2);
  transition:
    opacity 120ms ease,
    transform 120ms ease;
}
.reticle[data-raised='true'] .sight {
  opacity: 1;
  transform: none;
  transition:
    opacity 180ms ease,
    transform 220ms cubic-bezier(0.2, 0.9, 0.3, 1.15);
}

/* The spread sight: a 6 px dot, three 6 × 26 ticks, the readout along the lower-right one. Each
   white shape has a crisp dark outline, then a soft shade behind it. */
.dot,
.tick,
.readout {
  position: absolute;
}
.dot,
.tick {
  background: var(--tick);
  box-shadow:
    0 0 0 var(--o) var(--edge),
    0 calc(var(--s, 1) * 1px) calc(var(--s, 1) * 5px) var(--shade);
}
.dot {
  top: calc(var(--s, 1) * -3px);
  left: calc(var(--s, 1) * -3px);
  width: calc(var(--s, 1) * 6px);
  height: calc(var(--s, 1) * 6px);
  border-radius: 50%;
}
.tick {
  top: calc(var(--s, 1) * -13px);
  left: calc(var(--s, 1) * -3px);
  width: calc(var(--s, 1) * 6px);
  height: calc(var(--s, 1) * 26px);
  border-radius: calc(var(--s, 1) * 3px);
  transform: rotate(var(--a)) translateY(calc(var(--r) * var(--s, 1) * -1px));
  transition: box-shadow 260ms ease;
}
/* The number runs outward along the 120° tick, on its upper side, 27° down (a touch flatter than
   the tick). Its outline is a stroke painted under the fill, so the figures stay sharp. */
.readout {
  top: 0;
  left: 0;
  color: var(--tick);
  font-size: calc(var(--s, 1) * 28px);
  font-weight: 650;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.1em;
  white-space: nowrap;
  -webkit-text-stroke: calc(var(--o) * 2) var(--edge);
  paint-order: stroke fill;
  text-shadow: 0 calc(var(--s, 1) * 1px) calc(var(--s, 1) * 5px) var(--shade);
  transform-origin: 0 0;
  transform: rotate(27deg)
    translate(calc((var(--r) - 12) * var(--s, 1) * 1px), calc(var(--s, 1) * -26px)) translateY(-56%);
}
/* Full draw: the dot flashes and the ticks catch a warm flare behind their outline. */
.reticle[data-full='true'] .dot {
  animation: flash 420ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.reticle[data-full='true'] .tick {
  box-shadow:
    0 0 0 var(--o) var(--edge),
    0 0 calc(var(--s, 1) * 9px) calc(var(--s, 1) * 2px) var(--flare);
}
@keyframes flash {
  0%,
  100% {
    transform: none;
  }
  35% {
    transform: scale(1.8);
  }
}

/* The lock sight: a 60 px cross 4 px thick, four 4 × 22 ticks at --lr, all cyan with a dark edge
   and a glow behind; each bar has its own halo, so where the bars cross the light doubles. */
.lock {
  position: absolute;
  top: 0;
  left: 0;
}
.cross {
  position: absolute;
  top: 0;
  left: 0;
}
/* The cross's fills again, over both bars' edges, so the crossing shows no seam. */
.cross::before,
.cross::after {
  content: '';
  position: absolute;
  z-index: 1;
  border-radius: calc(var(--s, 1) * 1px);
  background: var(--cyan);
}
.cross::before {
  top: calc(var(--s, 1) * -2px);
  left: calc(var(--s, 1) * -30px);
  width: calc(var(--s, 1) * 60px);
  height: calc(var(--s, 1) * 4px);
}
.cross::after {
  top: calc(var(--s, 1) * -30px);
  left: calc(var(--s, 1) * -2px);
  width: calc(var(--s, 1) * 4px);
  height: calc(var(--s, 1) * 60px);
}
.crossX,
.crossY,
.lockTick {
  position: absolute;
  background: var(--cyan);
  border-radius: calc(var(--s, 1) * 1px);
  box-shadow:
    0 0 0 var(--o) var(--cyan-edge),
    0 0 calc(var(--s, 1) * 5px) calc(var(--s, 1) * 2px) var(--glow),
    0 0 calc(var(--s, 1) * 16px) calc(var(--s, 1) * 5px) var(--glow-soft);
}
.crossX {
  top: calc(var(--s, 1) * -2px);
  left: calc(var(--s, 1) * -30px);
  width: calc(var(--s, 1) * 60px);
  height: calc(var(--s, 1) * 4px);
}
.crossY {
  top: calc(var(--s, 1) * -30px);
  left: calc(var(--s, 1) * -2px);
  width: calc(var(--s, 1) * 4px);
  height: calc(var(--s, 1) * 60px);
}
.lockTick {
  top: calc(var(--s, 1) * -11px);
  left: calc(var(--s, 1) * -2px);
  width: calc(var(--s, 1) * 4px);
  height: calc(var(--s, 1) * 22px);
  transform: rotate(var(--a)) translateY(calc(var(--lr) * var(--s, 1) * -1px));
}
/* Locked on: the cross pops and the glow behind every bar pulses twice. */
.reticle[data-locked='true'] .cross {
  animation: pop 280ms cubic-bezier(0.2, 1.6, 0.4, 1);
}
.reticle[data-locked='true'] .crossX,
.reticle[data-locked='true'] .crossY,
.reticle[data-locked='true'] .lockTick {
  animation: pulse 460ms ease-in-out 2;
}
@keyframes pop {
  0% {
    transform: scale(0.82);
  }
  100% {
    transform: none;
  }
}
@keyframes pulse {
  50% {
    box-shadow:
      0 0 0 var(--o) var(--cyan-edge),
      0 0 calc(var(--s, 1) * 8px) calc(var(--s, 1) * 4px) var(--glow),
      0 0 calc(var(--s, 1) * 26px) calc(var(--s, 1) * 9px) var(--glow-soft);
  }
}

/* A shot landing: a ring on the dot, and on a target four short marks round it. */
.impact,
.hitMark {
  position: absolute;
  opacity: 0;
}
.impact {
  top: calc(var(--s, 1) * -18px);
  left: calc(var(--s, 1) * -18px);
  width: calc(var(--s, 1) * 36px);
  height: calc(var(--s, 1) * 36px);
  border: calc(var(--s, 1) * 3px) solid var(--tick);
  border-radius: 50%;
  box-sizing: border-box;
  box-shadow:
    0 0 0 var(--o) var(--edge),
    inset 0 0 0 var(--o) var(--edge);
}
.hitMark {
  top: 0;
  left: 0;
}
.hitMark > span {
  position: absolute;
  top: calc(var(--s, 1) * -7px);
  left: calc(var(--s, 1) * -2px);
  width: calc(var(--s, 1) * 4px);
  height: calc(var(--s, 1) * 14px);
  border-radius: calc(var(--s, 1) * 2px);
  background: var(--tick);
  box-shadow: 0 0 0 var(--o) var(--edge);
  transform: rotate(var(--a)) translateY(calc(var(--s, 1) * -26px));
}

/* The row under the sight: the reticle chips, and what holding does. */
.bar {
  position: absolute;
  right: 12px;
  bottom: 12px;
  left: 12px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 6px 12px;
  pointer-events: none;
}
.controls {
  display: flex;
  gap: 2px;
  padding: 2px;
  border: 1px solid var(--chip-line);
  border-radius: 999px;
  background: var(--chip);
  pointer-events: auto;
}
.choice {
  min-height: 28px;
  margin: 0;
  padding: 0 12px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--chip-ink);
  font-family: inherit;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  transition:
    background-color 160ms ease,
    color 160ms ease;
}
.choice[aria-pressed='true'] {
  background: var(--chip-on);
  color: var(--chip-on-ink);
}
.choice:focus {
  outline: none;
}
.choice:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.hint {
  margin: 0;
  color: var(--muted);
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
}
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .sight,
  .reticle[data-raised='true'] .sight,
  .tick,
  .choice {
    transition-duration: 1ms;
  }
  .reticle[data-full='true'] .dot,
  .reticle[data-locked='true'] .cross,
  .reticle[data-locked='true'] .crossX,
  .reticle[data-locked='true'] .crossY,
  .reticle[data-locked='true'] .lockTick {
    animation: none;
  }
}
```

### aim-reticle/use-autoplay.ts

```tsx
'use client';

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

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

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

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

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