# Safe triangle

> A menu whose submenus stay open while the pointer cuts across other items to reach them, with the prediction cone drawn as it goes.

A file menu with two submenus. On its way to a submenu, a pointer moving diagonally crosses the items in between, and each of them would take over and snap the submenu shut. A triangle from the pointer to the submenu’s near edge tells a pointer on its way from one that changed its mind; the checkbox draws it.

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

Category: Navigation.

## Anatomy

1. **Menu**: The `role="menu"` card: one tab stop, moved by the arrow keys, Home and End.
2. **Item**: An action: lit by the pointer or by focus, chosen with a click or Enter, then `onSelect` gets its id.
3. **Shortcut**: The key to press instead, drawn in a muted tone; screen readers get it from `aria-keyshortcuts`.
4. **Divider**: A hairline between groups of items: the groups prop draws one between each.
5. **Submenu item**: An item with a chevron. Reaching it opens its submenu; → or Enter opens it from the keyboard and moves focus in.
6. **Submenu**: Level with its item, at the right. Below the width of both side by side, it overlaps the menu’s edge.
7. **Prediction cone**: From where the pointer left the item to the submenu’s near edge. Inside it and heading right, the items crossed stay dark.
8. **Cone switch**: Shows or hides the cone. The menu keeps predicting either way.

## When to use

- Menus with submenus that open on hover to the side: File, Edit, a context menu, a nested settings list.
- Menus where the pointer must cross other items on a diagonal to reach a submenu, and each would steal it on the way.
- Dense desktop tools used with a mouse or a trackpad, where people move fast and expect the submenu to wait for them.
- Teaching the pattern: tick `Show prediction cone` to draw the triangle the menu reasons with.

## When not to use

- Touch-first screens: there is no hover to predict. A tap opens the submenu here, but a drill-in list or a sheet reads better.
- Flat menus with no submenus. A plain menu does the job without tracking the pointer.
- Deep trees of three or more levels. Use a tree view, a breadcrumb list or a command palette instead.
- Submenus that open below or to the left: the cone here aims at a submenu on the right.
- Primary navigation for a site. Hover menus hide where things are; show the links, or use a disclosure.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/safe-triangle.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 { SafeTriangle } from './safe-triangle';
import type { MenuItem } from './menu';

// Groups of items, with a divider between groups; `items` makes a submenu.
const FILE: MenuItem[][] = [
  [{ id: 'new', label: 'New File', shortcut: '⌘N', keys: 'Meta+N' }],
  [
    {
      id: 'share',
      label: 'Share',
      items: [
        { id: 'copy-link', label: 'Copy Link' },
        { id: 'export', label: 'Export…' },
      ],
    },
  ],
  [{ id: 'delete', label: 'Delete', shortcut: '⌫', keys: 'Meta+Backspace', danger: true }],
];

export function FileMenu({ run }: { run: (id: string) => void }) {
  return <SafeTriangle groups={FILE} defaultShowCone={false} onSelect={run} autoplay={false} />;
}
```

## States

- **At rest**: Nothing lit and no submenu: how the menu first opens.
- **Item lit**: The pointer or the focus is on an action: a soft fill marks it.
- **Destructive lit**: An item marked `danger` is red, and its fill turns rose.
- **Submenu open**: On an item with a chevron, its submenu opens level with it, the item stays lit.
- **Submenu item lit**: Inside the submenu, items take a stronger fill than the menu’s.
- **On its way**: The pointer has left Share and crosses Move to inside the cone: Move to stays dark and the submenu stays open.
- **Cone hidden**: The same moment with `defaultShowCone={false}`: nothing is drawn, the submenu still waits.
- **Dark**: With `colorScheme="dark"`: a deep indigo stage and dark grey cards.

## How it works

- Every pointer move goes through one function that reads the boxes of the items and of the open submenu on screen, so the same code follows a mouse and the demo’s pointer, even inside a scaled container.
- While the pointer is on the item that opened the submenu, the cone starts at the pointer; once it leaves the item, the cone stays where it left. Its far corners sit on the submenu’s near edge, a third of an item above and below it.
- Anywhere else, a point-in-triangle test decides: inside the cone and still heading right, the pointer is on its way and the items it crosses stay dark; outside it, or heading back, the item under the pointer takes over at once.
- A pause ends the grace: resting for 300 ms on an item inside the cone hands the menu to that item, so a reader who stops halfway is not left with the wrong submenu.
- The cone is drawn straight into an SVG on every move, and React renders only when the lit item or the open submenu changes. Submenus are laid out while hidden, so the cone reaches one the moment it opens.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop: lavender by day, deep indigo at night. The cone is drawn in SVG with CSS classes the script only moves, so its colours follow too.

## API reference

- **groups** (`MenuItem[][]`, default `FILE_MENU`): The menu’s items, in groups drawn with a divider between them. An item has an id and a label, and either a shortcut (with keys, its aria-keyshortcuts, and danger for a red action) or items, the entries of its submenu.
- **label** (`string`, default `'File'`): The menu’s accessible name.
- **defaultShowCone** (`boolean`, default `true`): Whether the prediction cone is drawn at first. The submenus are protected either way; the checkbox switches the drawing.
- **defaultState** (`SafeTriangleState`): Opens with the menu set, and turns the demo off: the lit item (active), the one with its submenu open (open), the lit submenu item (sub), the cone’s apex and the ghost pointer, by item id and shares of an item’s size.
- **onSelect** (`(id: string) => void`): Called with the id of the item chosen, in the menu or in a submenu.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer travels diagonally to both submenus, across the items in between.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- It is an ARIA menu with a single tab stop: items are menuitem buttons, the two that open submenus carry aria-haspopup, aria-expanded and aria-controls, and the dividers are separators.
- The up and down arrows (and Home, End) move through the items; the right arrow, Enter or Space opens a submenu and moves the focus into it; the left arrow or Escape closes it and brings the focus back. Choices are announced (“Chose Archive from Move to”).
- The cone and the demo pointer are decorative and hidden from screen readers, and shortcuts are exposed as aria-keyshortcuts. On touch, a tap opens or closes a submenu.
- Under reduced motion highlights and submenus change at once, and the demo does not play.

## Source

### safe-triangle/safe-triangle.tsx

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

'use client';

import {
  Fragment,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type MouseEvent,
} from 'react';
import { farCorners, inBox, inTriangle, type Box, type Point } from './cone';
import { GhostPointer, runDemo } from './ghost-pointer';
import { FILE_MENU, type MenuItem } from './menu';
import { useAutoplay } from './use-autoplay';
import styles from './safe-triangle.module.css';

/*
 * Safe triangle: a menu whose submenus stay open while the pointer travels to them.
 *
 * - An item with a submenu opens it to its right as the pointer reaches it. To get there, a
 *   pointer moving diagonally crosses the items in between, and each of them would take over and
 *   close the submenu on the way.
 * - The prediction cone prevents that (cone.ts): a triangle from the pointer to the submenu's near
 *   edge. Once the pointer leaves the item, the cone stays where it left; while the pointer is
 *   inside it and keeps heading for the submenu, the items it crosses are left alone. Outside the
 *   cone, or after a short pause on an item, the item under the pointer takes over.
 * - The pointer is followed on every move: the boxes are read in the handler, the cone is drawn
 *   straight into its SVG, and React renders only when the lit item or the open submenu changes.
 *   The checkbox shows or hides the cone; the behaviour stays on.
 * - Keyboard: the arrow keys move through the items, → or Enter opens a submenu, ← or Escape
 *   closes it, and focus follows. Choices are announced to screen readers. Touch opens and closes
 *   a submenu with a tap.
 * - While on screen and untouched, a ghost pointer travels to both submenus (ghost-pointer.tsx).
 * - defaultState opens it with an item lit, a submenu open, or the cone drawn on the way to it
 *   (the ghost pointer shown where it is), and with no demo: for documentation and tests.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** What is lit: the menu's item, the item whose submenu is open, and the submenu's item. */
type View = { active: string | null; open: string | null; sub: string | null };

/** The cone, in the scene's px: where it starts, and where the pointer was last. */
type Zone = { apex: Point | null; last: Point | null; timer: number };

const REST: View = { active: null, open: null, sub: null };

/** Where the menu opens (defaultState): for documentation and tests. */
export type SafeTriangleState = {
  /** The lit item of the menu, by id. */
  active?: string;
  /** The item whose submenu is open, by id; it is lit too. */
  open?: string;
  /** The lit item of the open submenu, by id. */
  sub?: string;
  /** The cone's start, as shares of the open item's width and height: drawn from there. */
  apex?: { x: number; y: number };
  /** The ghost pointer, shown at shares of an item's width and height: where it is on its way. */
  pointer?: { item: string; x: number; y: number };
};

/** The ghost arrow's tip, in its 20 × 20 box (ghost-pointer.tsx). */
const TIP = { x: 3, y: 2 };

/** How long the pointer may rest on an item inside the cone before that item takes over, in ms. */
const PAUSE = 300;

/** The menu's padding, an item's height and a divider's, in em: they place each submenu. */
const PAD = 0.4615;
const ROW = 2.6923;
const DIVIDER = 0.6923;

export function SafeTriangle({
  groups = FILE_MENU,
  label = 'File',
  defaultShowCone = true,
  onSelect,
  defaultState,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The menu's items, in groups drawn with a divider between them. */
  groups?: MenuItem[][];
  /** The menu's accessible name. */
  label?: string;
  /** Whether the prediction cone is drawn at first. It works either way. */
  defaultShowCone?: boolean;
  /** Called with the id of the item chosen, in the menu or in a submenu. */
  onSelect?: (id: string) => void;
  /** Opens with items lit, a submenu open or the cone drawn, and turns the demo off. */
  defaultState?: SafeTriangleState;
  /** While on screen and untouched, a ghost pointer travels to the submenus on its own. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const items = groups.flat();
  const [first] = useState<View>(() =>
    defaultState
      ? {
          active: defaultState.active ?? defaultState.open ?? null,
          open: defaultState.open ?? null,
          sub: defaultState.sub ?? null,
        }
      : REST,
  );
  const [view, setView] = useState<View>(first);
  const [showCone, setShowCone] = useState(defaultShowCone);
  /** The menu's one tab stop; the arrow keys move it. */
  const [tabStop, setTabStop] = useState(() => items[0]?.id ?? '');
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const baseId = useId();

  const scene = useRef<HTMLDivElement>(null);
  const cone = useRef<SVGSVGElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  /** The view as of the last change, for handlers that run between renders. */
  const live = useRef<View>(first);
  /** The cone and pointer of defaultState are drawn until the reader changes anything. */
  const posed = useRef(!!defaultState?.apex || !!defaultState?.pointer);
  const zone = useRef<Zone>({ apex: null, last: null, timer: 0 });
  /** The item to focus once the next change is on screen. */
  const focusNext = useRef<string | null>(null);
  /** Touch opens and closes a submenu with a tap; the cone is for pointers that hover. */
  const lastPointer = useRef('mouse');
  const stopDemo = useRef<(() => void) | null>(null);

  const itemOf = (id: string | null) => items.find((item) => item.id === id);
  const parentOf = (id: string) => items.find((item) => item.items?.some((sub) => sub.id === id));
  /** An item (`data-item`) or a submenu (`data-submenu`) of the scene, by id. */
  const find = (kind: 'item' | 'submenu', id: string) =>
    scene.current?.querySelector<HTMLElement>(`[data-${kind}="${CSS.escape(id)}"]`) ?? null;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  const set = (next: Partial<View>) => {
    const now = live.current;
    const view = { ...now, ...next };
    if (view.active === now.active && view.open === now.open && view.sub === now.sub) return;
    live.current = view;
    setView(view);
  };

  /** Boxes on screen in the scene's own px (it may sit in a scaled container). */
  const frameOf = (box: HTMLElement) => {
    const frame = box.getBoundingClientRect();
    const scale = frame.width / box.offsetWidth || 1;
    const local = (element: Element): Box => {
      const r = element.getBoundingClientRect();
      return {
        left: (r.left - frame.left) / scale,
        top: (r.top - frame.top) / scale,
        right: (r.right - frame.left) / scale,
        bottom: (r.bottom - frame.top) / scale,
      };
    };
    return { frame, scale, local };
  };

  /** Draws the cone from `apex` to the submenu's near edge, or hides it. */
  const draw = (apex: Point | null, submenu?: Box, itemHeight = 0) => {
    const svg = cone.current;
    if (!svg) return;
    if (!apex || !submenu) {
      svg.removeAttribute('data-shown');
      return;
    }
    const [top, bottom] = farCorners(submenu, itemHeight);
    const [fill, edges, ...dots] = Array.from(svg.children);
    fill.setAttribute('points', `${apex.x},${apex.y} ${top.x},${top.y} ${bottom.x},${bottom.y}`);
    edges.setAttribute(
      'd',
      `M${apex.x} ${apex.y}L${top.x} ${top.y}M${apex.x} ${apex.y}L${bottom.x} ${bottom.y}M${top.x} ${top.y}L${bottom.x} ${bottom.y}`,
    );
    [apex, top, bottom].forEach((point, i) => {
      dots[i]?.setAttribute('cx', String(point.x));
      dots[i]?.setAttribute('cy', String(point.y));
    });
    svg.setAttribute('data-shown', '');
  };

  /** Drops the cone; the next move starts afresh. */
  const endCone = () => {
    if (posed.current) {
      posed.current = false;
      if (pointer.current) {
        pointer.current.style.transition = '';
        pointer.current.style.opacity = '0';
      }
      if (cone.current) cone.current.style.transition = '';
    }
    window.clearTimeout(zone.current.timer);
    zone.current.apex = null;
    draw(null);
  };

  /** Back to rest: nothing lit, no submenu, no cone. */
  const rest = () => {
    endCone();
    zone.current.last = null;
    set(REST);
  };

  /** The item `id` takes over, the pointer at `p` (in the scene's px): it lights up and opens. */
  const takeOver = (p: Point, id: string | null) => {
    const box = scene.current;
    endCone();
    if (!id || !box) {
      // Over no item: an open submenu stays open, a lit action goes out.
      set(live.current.open ? { active: live.current.open, sub: null } : REST);
      return;
    }
    const submenu = itemOf(id)?.items ? find('submenu', id) : null;
    const trigger = find('item', id);
    set({ active: id, open: submenu ? id : null, sub: null });
    if (!submenu || !trigger) return;
    // Its submenu is laid out while hidden: the cone can reach it right away.
    const { local } = frameOf(box);
    const item = local(trigger);
    zone.current.apex = p;
    draw(p, local(submenu), item.bottom - item.top);
  };

  /**
   * Follows the pointer, at (x, y) on screen. On the item whose submenu is open, the cone starts
   * at the pointer; in the submenu, its items light up; anywhere else the pointer is on its way
   * while it is inside the cone and heading right, and otherwise the item under it takes over.
   */
  const track = (x: number, y: number) => {
    const box = scene.current;
    if (!box) return;
    const { frame, scale, local } = frameOf(box);
    const p = { x: (x - frame.left) / scale, y: (y - frame.top) / scale };
    const state = zone.current;
    const last = state.last;
    state.last = p;
    const at = (ids: string[]) =>
      ids.find((id) => {
        const element = find('item', id);
        return element ? inBox(p, local(element)) : false;
      }) ?? null;
    const under = at(items.map((item) => item.id));

    const open = itemOf(live.current.open);
    const trigger = open && find('item', open.id);
    const submenu = open && find('submenu', open.id);
    if (open?.items && trigger && submenu) {
      const item = local(trigger);
      const sub = local(submenu);
      const height = item.bottom - item.top;
      if (inBox(p, sub)) {
        window.clearTimeout(state.timer);
        set({ active: open.id, sub: at(open.items.map((entry) => entry.id)) });
        return;
      }
      if (inBox(p, item)) {
        window.clearTimeout(state.timer);
        state.apex = p;
        set({ active: open.id, sub: null });
        draw(p, sub, height);
        return;
      }
      if (state.apex && last && p.x >= last.x - 1) {
        const [top, bottom] = farCorners(sub, height);
        if (inTriangle(p, state.apex, top, bottom)) {
          // On its way: the items it crosses stay dark, unless it rests on one.
          set({ sub: null });
          window.clearTimeout(state.timer);
          if (under) state.timer = window.setTimeout(() => takeOver(p, under), PAUSE);
          return;
        }
      }
    }
    takeOver(p, under);
  };

  /** Opens `id`'s submenu, without a cone; `focus` moves the focus to its first item. */
  const openSubmenu = (id: string, focus: boolean) => {
    const item = itemOf(id);
    if (!item?.items) return;
    endCone();
    set({ active: id, open: id, sub: focus ? item.items[0].id : null });
    if (focus) focusNext.current = item.items[0].id;
  };

  /** Closes the open submenu; `focus` moves the focus back to the item that opened it. */
  const closeSubmenu = (focus: boolean) => {
    const id = live.current.open;
    if (!id) return;
    endCone();
    set({ active: id, open: null, sub: null });
    if (focus) focusNext.current = id;
  };

  const choose = (id: string) => {
    const parent = parentOf(id);
    const chosen = parent?.items?.find((sub) => sub.id === id) ?? itemOf(id);
    if (!chosen) return;
    onSelect?.(id);
    announce(parent ? `Chose ${chosen.label} from ${parent.label}` : `Chose ${chosen.label}`);
    if (!parent) return;
    // A choice in a submenu closes it, as a menu would; the focus goes back to its item.
    const hadFocus = scene.current?.contains(document.activeElement) ?? false;
    endCone();
    set({ active: hadFocus ? parent.id : null, open: null, sub: null });
    if (hadFocus) focusNext.current = parent.id;
  };

  const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const current = items.findIndex((item) => find('item', item.id) === event.target);
    if (current < 0) return;
    const item = items[current];
    const moves: Record<string, number> = {
      ArrowDown: current + 1,
      ArrowUp: current - 1,
      Home: 0,
      End: items.length - 1,
    };
    if (event.key in moves) {
      event.preventDefault();
      const next = items[(moves[event.key] + items.length) % items.length];
      find('item', next.id)?.focus();
    } else if (event.key === 'ArrowRight' && item.items) {
      event.preventDefault();
      openSubmenu(item.id, true);
    } else if (event.key === 'Escape' && live.current.open) {
      event.preventDefault();
      closeSubmenu(true);
    }
  };

  const onSubmenuKeyDown = (event: KeyboardEvent<HTMLDivElement>, parent: MenuItem) => {
    const subs = parent.items ?? [];
    const current = subs.findIndex((sub) => find('item', sub.id) === event.target);
    const moves: Record<string, number> = {
      ArrowDown: current + 1,
      ArrowUp: current - 1,
      Home: 0,
      End: subs.length - 1,
    };
    if (event.key in moves) {
      event.preventDefault();
      const next = subs[(moves[event.key] + subs.length) % subs.length];
      find('item', next.id)?.focus();
    } else if (event.key === 'ArrowLeft' || event.key === 'Escape') {
      event.preventDefault();
      closeSubmenu(true);
    } else if (event.key === 'Tab') {
      closeSubmenu(false);
    }
  };

  /** Pressing an item with a submenu: the keyboard opens it and goes in; a tap opens or shuts it. */
  const onTrigger = (event: MouseEvent, item: MenuItem) => {
    const byKeyboard = event.detail === 0;
    if (!byKeyboard && lastPointer.current === 'touch' && live.current.open === item.id) {
      closeSubmenu(false);
    } else if (byKeyboard || live.current.open !== item.id) openSubmenu(item.id, byKeyboard);
  };

  // Focus goes where the last change asked, once that change is on screen.
  useLayoutEffect(() => {
    const id = focusNext.current;
    focusNext.current = null;
    if (id) find('item', id)?.focus();
  });

  useEffect(() => () => window.clearTimeout(zone.current.timer), []);

  // defaultState's cone and pointer, drawn again whenever the scene changes size.
  useLayoutEffect(() => {
    const box = scene.current;
    const stage = root.current;
    if (!box || !stage || !posed.current) return;
    const pose = () => {
      if (!posed.current) return;
      const { local } = frameOf(box);
      const opened = live.current.open;
      const trigger = opened ? find('item', opened) : null;
      const submenu = opened ? find('submenu', opened) : null;
      const apex = defaultState?.apex;
      if (apex && trigger && submenu) {
        const item = local(trigger);
        const height = item.bottom - item.top;
        const p = {
          x: item.left + (item.right - item.left) * apex.x,
          y: item.top + height * apex.y,
        };
        // A pose is a still: it is there at once, with no fade.
        if (cone.current) cone.current.style.transition = 'none';
        draw(p, local(submenu), height);
      }
      const at = defaultState?.pointer;
      const ghost = pointer.current;
      const target = at ? find('item', at.item) : null;
      if (at && ghost && target) {
        // The pointer sits in the root, the cone in the scene: both in their own px.
        const frame = stage.getBoundingClientRect();
        const scale = frame.width / stage.offsetWidth || 1;
        const r = target.getBoundingClientRect();
        const x = (r.left + r.width * at.x - frame.left) / scale;
        const y = (r.top + r.height * at.y - frame.top) / scale;
        ghost.style.transition = 'none';
        ghost.style.transform = `translate(${x - TIP.x}px, ${y - TIP.y}px)`;
        ghost.style.opacity = '1';
      }
    };
    pose();
    const observer = new ResizeObserver(pose);
    observer.observe(box);
    observer.observe(stage);
    document.fonts?.ready.then(pose, () => {});
    return () => observer.disconnect();
    // Drawn from the props it opened with, once.
  }, []);

  // The demo calls the latest handlers.
  const actions = useRef({ track, rest, find });
  useLayoutEffect(() => {
    actions.current = { track, rest, find };
  });

  // The demo: the ghost pointer rests on Share and heads diagonally for Export, across Move to;
  // then onto Move to and diagonally down to Archive, across Rename; then down to Delete.
  const demo = autoplay && !defaultState && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !stage || !ghost) return;
    const get = (id: string) => actions.current.find('item', id);
    const stopRun = runDemo(
      stage,
      ghost,
      (x, y) => actions.current.track(x, y),
      async ({ wait, pointOn, glide, leave, jump }) => {
        await wait(700);
        for (;;) {
          // In from the right, level with Share, over nothing but the stage.
          jump(pointOn(get('share'), 2.3, 0.62));
          await glide(pointOn(get('share'), 0.4, 0.5), 820, -0.04);
          await wait(420);
          await glide(pointOn(get('share'), 0.52, 0.6), 380, 0);
          await wait(260);
          await glide(pointOn(get('export'), 0.36, 0.55), 900, 0.03);
          await wait(1000);
          await glide(pointOn(get('move'), 0.78, 0.45), 560, -0.1);
          await glide(pointOn(get('move'), 0.36, 0.62), 420, 0);
          await wait(380);
          await glide(pointOn(get('archive'), 0.3, 0.62), 900, 0.05);
          await wait(1000);
          await glide(pointOn(get('delete'), 0.64, 0.5), 760, -0.12);
          await wait(900);
          await leave();
          actions.current.rest();
          await wait(1400);
        }
      },
    );
    let stopped = false;
    const stop = () => {
      if (stopped) return;
      stopped = true;
      stopRun();
      actions.current.rest();
    };
    stopDemo.current = stop;
    return stop;
  }, [demo, root]);

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

  /** Focus on an item lights it, as the pointer does. */
  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    readerActs();
    setFocused(true);
    const id = event.target.getAttribute('data-item');
    // Focus outside the menus (on the checkbox) leaves them at rest.
    if (!id) {
      rest();
      return;
    }
    if (parentOf(id)) {
      set({ sub: id });
      return;
    }
    setTabStop(id);
    const keep = live.current.open === id;
    if (!keep) endCone();
    set({ active: id, open: keep ? id : null, sub: null });
  };

  const { active, open, sub } = view;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={(event) => {
        lastPointer.current = event.pointerType;
        readerActs();
      }}
      onKeyDownCapture={readerActs}
      onPointerMove={(event) => {
        if (event.pointerType === 'touch') return;
        readerActs();
        track(event.clientX, event.clientY);
      }}
      onPointerLeave={(event) => {
        // The pointer left the stage: what it lit goes out, an open submenu stays.
        if (event.pointerType === 'touch') return;
        endCone();
        zone.current.last = null;
        set(live.current.open ? { active: live.current.open, sub: null } : REST);
      }}
      onPointerDown={(event) => {
        // A tap or a click away from the menu puts it back to rest.
        if (event.target instanceof Element && !event.target.closest('[role="menu"], label'))
          rest();
      }}
      onFocus={onFocus}
      onBlur={(event) => {
        if (event.currentTarget.contains(event.relatedTarget)) return;
        setFocused(false);
        rest();
      }}
    >
      <div className={styles.stage}>
        <div className={styles.block}>
          <div ref={scene} className={styles.scene}>
            <div
              role="menu"
              aria-label={label}
              data-part="menu"
              className={`${styles.panel} ${styles.menu}`}
              onKeyDown={onMenuKeyDown}
            >
              {groups.map((group, g) => (
                <Fragment key={g}>
                  {g > 0 && <div role="separator" className={styles.divider} data-part="divider" />}
                  {group.map((item) => (
                    <button
                      key={item.id}
                      type="button"
                      role="menuitem"
                      className={styles.item}
                      data-part={item.items ? 'trigger' : 'item'}
                      tabIndex={tabStop === item.id ? 0 : -1}
                      data-item={item.id}
                      data-active={active === item.id || undefined}
                      data-danger={item.danger || undefined}
                      aria-haspopup={item.items ? 'menu' : undefined}
                      aria-expanded={item.items ? open === item.id : undefined}
                      aria-controls={item.items ? `${baseId}-${item.id}` : undefined}
                      aria-keyshortcuts={item.keys}
                      onClick={(event) => (item.items ? onTrigger(event, item) : choose(item.id))}
                    >
                      <span className={styles.label}>{item.label}</span>
                      {item.shortcut && (
                        <span className={styles.shortcut} data-part="shortcut" aria-hidden="true">
                          {Array.from(item.shortcut, (key, i) => (
                            <Fragment key={i}>{key === '⌫' ? <BackspaceKey /> : key}</Fragment>
                          ))}
                        </span>
                      )}
                      {item.items && (
                        <svg
                          className={styles.chevron}
                          data-part="chevron"
                          viewBox="0 0 16 16"
                          aria-hidden="true"
                        >
                          <path d="m9.6 4 4 4-4 4" />
                        </svg>
                      )}
                    </button>
                  ))}
                </Fragment>
              ))}
            </div>

            {groups.map((group, g) =>
              group.map((item, i) => {
                if (!item.items) return null;
                // The rows and dividers above its item set it level with that item.
                const rowsAbove = groups.slice(0, g).reduce((sum, above) => sum + above.length, i);
                const top = PAD + rowsAbove * ROW + g * DIVIDER;
                const shown = open === item.id;
                return (
                  <div
                    key={item.id}
                    id={`${baseId}-${item.id}`}
                    role="menu"
                    aria-label={item.label}
                    className={`${styles.panel} ${styles.submenu}`}
                    style={{ '--top': `${top}em` } as CSSProperties}
                    data-submenu={item.id}
                    data-part="submenu"
                    data-open={shown || undefined}
                    inert={!shown}
                    onKeyDown={(event) => onSubmenuKeyDown(event, item)}
                  >
                    {item.items.map((entry) => (
                      <button
                        key={entry.id}
                        type="button"
                        role="menuitem"
                        className={styles.item}
                        data-part="subitem"
                        tabIndex={-1}
                        data-item={entry.id}
                        data-active={(shown && sub === entry.id) || undefined}
                        onClick={() => choose(entry.id)}
                      >
                        <span className={styles.label}>{entry.label}</span>
                      </button>
                    ))}
                  </div>
                );
              }),
            )}

            <svg
              ref={cone}
              className={styles.cone}
              data-hidden={!showCone || undefined}
              aria-hidden="true"
            >
              <polygon className={styles.fill} data-part="cone" />
              <path className={styles.edges} />
              <circle className={styles.dot} r="3" />
              <circle className={styles.dot} r="3" />
              <circle className={styles.dot} r="3" />
            </svg>
          </div>

          <label className={styles.toggle} data-part="toggle">
            <input
              type="checkbox"
              className={styles.check}
              checked={showCone}
              onChange={(event) => setShowCone(event.target.checked)}
            />
            <span className={styles.toggleText}>Show prediction cone</span>
          </label>
        </div>
      </div>

      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** The backspace key, ⌫, drawn: fonts draw it in too many ways. */
function BackspaceKey() {
  return (
    <svg className={styles.key} viewBox="0 0 16 16" aria-hidden="true">
      <path d="M5.2 3h8.3A1.5 1.5 0 0 1 15 4.5v7a1.5 1.5 0 0 1-1.5 1.5H5.2L1 8z" />
      <path d="m7.8 6 3.6 4m0-4-3.6 4" />
    </svg>
  );
}
```

### safe-triangle/safe-triangle.module.css

```css
/* Safe triangle (safe-triangle.tsx): a pale lavender stage with the menu in its middle, or at
   night a deep indigo one. Sizes are in em of the menu's text, which follows the stage's width (a
   container): 13px from about 540px wide, down to 11px on a phone. Below the width of the menu
   and a submenu side by side, the submenus overlap the menu's right edge. The menu's padding, an
   item's height and a divider's match the constants the component places the submenus with.

   Colours are the tokens below, light-dark(light, dark): 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). */

.root {
  /* The stage, and the menus on it, a step up. */
  --surface: #f2f4ff;
  --surface-2: #fff;
  /* Items; the checkbox's words; shortcuts; the drawn keys and the chevrons. */
  --ink: #30354c;
  --soft-ink: #4b5064;
  --ink-muted: #9b9ba4;
  --key: #bdbec7;
  --chevron: #cfd0db;
  /* The menus' hairlines and dividers, and the stage's edge. */
  --line: rgb(0 0 0 / 0.06);
  --edge: rgb(0 0 0 / 0.06);
  /* The menus' soft shadow, far and near. */
  --shadow: rgb(16 24 64 / 0.08);
  --shadow-near: rgb(16 24 64 / 0.04);
  /* A lit item in the menu, then in a submenu; Delete, and Delete lit. */
  --hover: #eff1fe;
  --hover-strong: #cdd2f3;
  --danger: #cc4c4c;
  --danger-hover: #faecec;
  /* The prediction cone: its fill, its dashed edges and its dots. */
  --cone-fill: rgb(129 140 248 / 0.1);
  --cone-edge: rgb(129 140 248 / 0.65);
  --cone-dot: rgb(129 140 248);
  /* The checkbox: ticked (its tick, white on both, is drawn in its image); unticked, its box and
     edge. */
  --accent: #5079ea;
  --check-box: #fff;
  --check-edge: #d4d4dc;
  --focus: #6366f1;
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge. */
  --pointer: #111;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#f2f4ff, #141522);
    --surface-2: light-dark(#fff, #1e2030);
    --ink: light-dark(#30354c, #e4e6f3);
    --soft-ink: light-dark(#4b5064, #b4b8cc);
    --ink-muted: light-dark(#9b9ba4, #9497ad);
    --key: light-dark(#bdbec7, #73778d);
    --chevron: light-dark(#cfd0db, #73778d);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --shadow: light-dark(rgb(16 24 64 / 0.08), rgb(0 0 0 / 0.45));
    --shadow-near: light-dark(rgb(16 24 64 / 0.04), rgb(0 0 0 / 0.3));
    --hover: light-dark(#eff1fe, #2a2d42);
    --hover-strong: light-dark(#cdd2f3, #363b66);
    --danger: light-dark(#cc4c4c, #f07070);
    --danger-hover: light-dark(#faecec, #3a2027);
    --cone-fill: light-dark(rgb(129 140 248 / 0.1), rgb(140 150 250 / 0.12));
    --cone-edge: light-dark(rgb(129 140 248 / 0.65), rgb(140 150 250 / 0.7));
    --cone-dot: light-dark(rgb(129 140 248), rgb(140 150 250));
    --accent: light-dark(#5079ea, #5b83f1);
    --check-box: light-dark(#fff, #1e2030);
    --check-edge: light-dark(#d4d4dc, #666a82);
    --focus: light-dark(#6366f1, #8c90f9);
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.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;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
/* The buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button,
:where(.root) input {
  margin: 0;
  font: inherit;
  color: inherit;
}

.stage {
  display: grid;
  place-items: center;
  min-height: clamp(400px, 54cqi, 560px);
  padding: 40px 12px;
  font-size: clamp(11px, 7.6px + 1cqi, 13px);
  line-height: 1.25;
}
/* The menu, the room its submenus open into, and the checkbox under them. */
.block {
  width: min(100%, 30.31em);
}
.scene {
  position: relative;
}

/* The menu and its submenus: white cards (dark grey ones at night) with a hairline and a soft
   shadow. */
.panel {
  padding: 0.4615em;
  border: 1px solid var(--line);
  border-radius: 0.923em;
  background: var(--surface-2);
  box-shadow:
    0 2px 6px -1px var(--shadow),
    0 1px 2px var(--shadow-near);
}
.menu {
  width: 16.154em;
}
/* A submenu sits at the right of the room, so a narrow stage pulls it over the menu's edge. Its
   top is its item's (--top, set by the component), a little higher. */
.submenu {
  position: absolute;
  top: calc(var(--top) + 1px - 0.3077em);
  right: 0;
  z-index: 1;
  width: 13.846em;
  visibility: hidden;
  opacity: 0;
}
.submenu[data-open] {
  visibility: visible;
  opacity: 1;
  transition: opacity 0.12s ease;
}

.item {
  display: flex;
  align-items: center;
  gap: 1em;
  width: 100%;
  height: 2.6923em;
  padding: 0 0.769em;
  border: 0;
  border-radius: 0.4615em;
  background: transparent;
  font-weight: 500;
  text-align: left;
  white-space: nowrap;
  cursor: default;
  transition: background-color 0.15s ease;
}
.item[data-active] {
  background: var(--hover);
}
.submenu .item[data-active] {
  background: var(--hover-strong);
}
.item[data-danger] {
  color: var(--danger);
}
.item[data-danger][data-active] {
  background: var(--danger-hover);
}
.item:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}
.label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.shortcut {
  display: flex;
  align-items: center;
  font-size: 0.846em;
  font-weight: 400;
  color: var(--ink-muted);
}
/* A key drawn as an icon, as wide as the type around it. */
.key {
  width: 1.273em;
  height: 1.273em;
  fill: none;
  stroke: var(--key);
  stroke-width: 1.1;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.chevron {
  flex: none;
  width: 1.231em;
  height: 1.231em;
  fill: none;
  stroke: var(--chevron);
  stroke-width: 1.3;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.divider {
  height: 1px;
  margin: 0.3077em 0.769em;
  background: var(--line);
}

/* The prediction cone, drawn by the component in the scene's px, over the menus. */
.cone {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.cone[data-shown] {
  opacity: 1;
}
.cone[data-hidden] {
  display: none;
}
.fill {
  fill: var(--cone-fill);
}
.edges {
  fill: none;
  stroke: var(--cone-edge);
  stroke-width: 1;
  stroke-dasharray: 4 3;
}
.dot {
  fill: var(--cone-dot);
}

/* The checkbox under the menu. */
.toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.87em;
  margin: 3.1em 0 0 1.82em;
  color: var(--soft-ink);
  cursor: pointer;
}
.toggleText {
  font-size: 0.962em;
  transition: color 0.15s ease;
}
.toggle:hover .toggleText {
  color: var(--ink);
}
.check {
  flex: none;
  width: 1.231em;
  height: 1.231em;
  border: 1px solid var(--check-edge);
  border-radius: 0.3em;
  background: var(--check-box);
  appearance: none;
  cursor: pointer;
}
.check:checked {
  border-color: var(--accent);
  background: var(--accent)
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m4.2 8.3 2.5 2.5 5.1-5.3' fill='none' stroke='%23fff' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
    center / 100% no-repeat;
}
.check:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

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

### safe-triangle/cone.ts

```tsx
/*
 * The prediction cone: a triangle from the pointer to the near edge of the open submenu. While
 * the pointer is on the item that opened the submenu, the cone starts at the pointer; once it
 * leaves the item, the cone stays where it left. A pointer that is still inside the cone and
 * still heading for the submenu is on its way there, so the items it crosses are left alone.
 */

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

export type Box = { left: number; top: number; right: number; bottom: number };

/** How far the cone reaches past the submenu's top and bottom, as a share of an item's height. */
const BLEED = 0.32;

export const inBox = (p: Point, box: Box) =>
  p.x >= box.left && p.x < box.right && p.y >= box.top && p.y < box.bottom;

/**
 * The cone's far corners, on the submenu's left edge: above its top and below its bottom by a
 * third of an item's height, so a pointer aiming at the first or the last item stays inside.
 */
export function farCorners(submenu: Box, itemHeight: number): [Point, Point] {
  const x = submenu.left + 1;
  const bleed = itemHeight * BLEED;
  return [
    { x, y: submenu.top - bleed },
    { x, y: submenu.bottom + bleed },
  ];
}

/** Whether `p` lies in the triangle a, b, c, edges included. */
export function inTriangle(p: Point, a: Point, b: Point, c: Point) {
  const side = (u: Point, v: Point) => (v.x - u.x) * (p.y - u.y) - (v.y - u.y) * (p.x - u.x);
  const ab = side(a, b);
  const bc = side(b, c);
  const ca = side(c, a);
  return !((ab < 0 || bc < 0 || ca < 0) && (ab > 0 || bc > 0 || ca > 0));
}
```

### safe-triangle/menu.ts

```tsx
/*
 * The menu's content: groups of items, drawn with a divider between groups. An item with `items`
 * opens a submenu to its right; the others are actions.
 */

export type SubmenuItem = {
  /** Unique across the whole menu. */
  id: string;
  label: string;
};

export type MenuItem = {
  /** Unique across the whole menu. */
  id: string;
  label: string;
  /** The shortcut as it is drawn, on the right: "⌘N". */
  shortcut?: string;
  /** The same shortcut for assistive technology, as aria-keyshortcuts spells it: "Meta+N". */
  keys?: string;
  /** A destructive action, drawn in red. */
  danger?: boolean;
  /** The items of its submenu. */
  items?: SubmenuItem[];
};

export const FILE_MENU: MenuItem[][] = [
  [
    { id: 'new', label: 'New File', shortcut: '⌘N', keys: 'Meta+N' },
    { id: 'open', label: 'Open…', shortcut: '⌘O', keys: 'Meta+O' },
  ],
  [
    {
      id: 'share',
      label: 'Share',
      items: [
        { id: 'copy-link', label: 'Copy Link' },
        { id: 'duplicate', label: 'Duplicate' },
        { id: 'export', label: 'Export…' },
      ],
    },
    {
      id: 'move',
      label: 'Move to',
      items: [
        { id: 'favorites', label: 'Favorites' },
        { id: 'archive', label: 'Archive' },
        { id: 'projects', label: 'Projects' },
      ],
    },
  ],
  [
    { id: 'rename', label: 'Rename', shortcut: '⌘R', keys: 'Meta+R' },
    { id: 'delete', label: 'Delete', shortcut: '⌫', keys: 'Meta+Backspace', danger: true },
  ],
];
```

### safe-triangle/ghost-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's ghost pointer, and the timeline that moves it. The menu follows the pointer on every
 * frame, as it follows a mouse, so each glide is a requestAnimationFrame loop that places the
 * arrow and hands the menu its position on screen; nothing runs between glides. Stopping the demo
 * (the reader takes over, or it scrolls away) ends the step in progress at once: its promise
 * rejects, which ends the script right there, and the pointer fades out.
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** The point at a share of `target`'s width and height, in the stage's px. */
  pointOn: (target: Element | null | undefined, x: number, y: number) => Point | null;
  /**
   * Glides the pointer to `to` (in the stage's px) over `ms`, along a path bent sideways by
   * `bend` (a share of its length; 0 is a straight line).
   */
  glide: (to: Point | null, ms: number, bend?: number) => Promise<void>;
  /** Moves the pointer off, down and to the right, and fades it out. */
  leave: () => Promise<void>;
  /** Puts the pointer, out of sight, where the next glide starts. */
  jump: (to: Point | null) => void;
};

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

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

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Slow at both ends, as a hand moves. */
const easeInOut = (k: number) => (k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2);

/**
 * Plays `script` with the pointer over `stage`; `onMove` gets the pointer's position on screen
 * (client px) on every frame it moves. Returns the function that stops the demo.
 */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  onMove: (clientX: number, clientY: number) => void,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let frame = 0;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  let at: Point = { x: stage.offsetWidth, y: stage.offsetHeight / 2 };

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

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

  /** Screen pixels to the stage's own, should the stage sit in a scaled container. */
  const frameOf = () => {
    const box = stage.getBoundingClientRect();
    return { box, scale: box.width / stage.offsetWidth || 1 };
  };

  const pointOn: Demo['pointOn'] = (target, x, y) => {
    if (!target) return null;
    const { box, scale } = frameOf();
    const rect = target.getBoundingClientRect();
    return {
      x: (rect.left + rect.width * x - box.left) / scale,
      y: (rect.top + rect.height * y - box.top) / scale,
    };
  };

  /** Puts the arrow at `point` and tells the menu where it is on screen. */
  const show = (point: Point, tell: boolean) => {
    at = point;
    pointer.style.transform = place(point);
    if (!tell) return;
    const { box, scale } = frameOf();
    onMove(box.left + point.x * scale, box.top + point.y * scale);
  };

  const glide: Demo['glide'] = (to, ms, bend = 0.12) => {
    if (!to) return step(Promise.resolve());
    const from = at;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // The curve's control point sits off the straight line, as a hand drifts.
    const control = { x: (from.x + to.x) / 2 - dy * bend, y: (from.y + to.y) / 2 + dx * bend };
    pointer.style.opacity = '1';
    return step(
      new Promise<void>((resolve) => {
        const began = performance.now();
        const tick = (now: number) => {
          const t = easeInOut(Math.min(1, Math.max(0, (now - began) / ms)));
          const u = 1 - t;
          show(
            {
              x: u * u * from.x + 2 * u * t * control.x + t * t * to.x,
              y: u * u * from.y + 2 * u * t * control.y + t * t * to.y,
            },
            true,
          );
          if (t < 1) frame = requestAnimationFrame(tick);
          else resolve();
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  const leave: Demo['leave'] = async () => {
    pointer.style.opacity = '0';
    const from = at;
    const to = { x: from.x + 40, y: from.y + 56 };
    await step(
      new Promise<void>((resolve) => {
        const began = performance.now();
        const tick = (now: number) => {
          const t = easeInOut(Math.min(1, Math.max(0, (now - began) / 420)));
          // Off the menu now: the arrow drifts away without touching it.
          show({ x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }, false);
          if (t < 1) frame = requestAnimationFrame(tick);
          else resolve();
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  const jump: Demo['jump'] = (to) => {
    if (to) show(to, false);
  };

  script({ wait, pointOn, glide, leave, jump }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

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

/** Its colours are the stage's tokens (safe-triangle.module.css). */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 20,
  height: 20,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
};

/** The arrow the demo moves around. It is decorative: the demo stays silent. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z" />
    </svg>
  );
}
```

### safe-triangle/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 };
}
```