# Thinking orbs

> Ten small animated dot orbs that say an assistant is busy, and how, with a gallery of them in pills and a playground.

Loading indicators for an AI interface, drawn in dots on a canvas: a lattice globe that tumbles while solving, a globe swept by a beam while searching, one that squashes to a voice while listening, a wired cloud while connecting, a hollowed shell under a moving waterline while composing, orbits while working, a ring of dashes that breathes, rings that weave, an outline that turns from triangle to square to circle, and a quiet ring for thinking. A change of state morphs: the dots glide to their new places. Below them, a playground tries every state with its size, speed, colour, dot count, dot size and a pull toward the cursor.

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

Category: AI.

## Anatomy

1. **Stage**: A quiet cell the orb sits centred in: at least 586 px tall beside the controls, and 360 px once they wrap under it.
2. **Orb**: The `ThinkingOrb` box: it takes `size` in the layout, square, and holds the ink in its CSS colour.
3. **Canvas**: Half the size bigger than the box, a quarter on each side, so dots pulled toward the cursor are not cut off. It draws only while on screen.
4. **Play and pause**: Holds one still frame (the `paused` prop) or lets it run again; it reads Play while paused.
5. **State tabs**: The ten states as one radio group: arrows move and choose, and a screen reader hears the new state.
6. **Sliders**: Speed, dot count and dot size, each a filled pill with its name and value inside, over a real range input.
7. **Colour swatches**: Mono and four named inks, each with a light and a dark shade; the chosen one is ringed.
8. **Code**: The `ThinkingOrb` line for what is set, only the props that differ from their defaults, with a copy button.

## When to use

- An assistant that is busy for a few seconds and can say how: searching, writing, listening, connecting, solving.
- A status line in a chat, beside a few words: a 20 px orb in a pill reads at text size, a 56 px one heads a turn.
- Work whose kind changes while it runs: a change of `state` morphs the dots in 700 ms instead of swapping icons.
- A voice or agent surface that needs one quiet sign of life, in the page’s ink or a named one, in light or dark.

## When not to use

- Progress you can measure. Use a progress bar or a percentage, so people know how long is left.
- A wait on a button or a form field. Use the button’s own busy state, with a plain spinner at text size.
- Waits under half a second: the orb never settles and reads as a flicker. Show nothing, or the result.
- Many busy rows at once, in a table or a list: a column of moving globes competes for attention. Use a static status per row.
- Errors and stopped work: every state means busy. Say what failed, or that it stopped, in words.

## Installation

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

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

export function AssistantStatus({ busy }: { busy: 'searching' | 'composing' | null }) {
  if (!busy) return null;
  return (
    <p className="status">
      <ThinkingOrb state={busy} size={20} label="" />
      {busy === 'searching' ? 'Searching the docs…' : 'Writing…'}
    </p>
  );
}
```

## States

- **Working**: Short trains of nodes run round thirteen tilted orbits, each traced in faint specks.
- **Searching**: A globe of latitude rings; a beam picks a new spot on its front every 0.72 s and lights the dots under it.
- **Solving**: The lattice tumbles a quarter turn every 1.2 s, its dots scattering a little mid-turn.
- **Listening**: The globe squashes and stretches to a voice level while a bright band runs up and down its rings.
- **Connecting**: About seventy nodes, each wired to its three nearest, turning about a tilted axis.
- **Composing**: A dense shell under a rippling waterline: lit above it, only the dim inside below, so it reads as a bowl.
- **Breathing**: A ring of 56 short dashes on a blob whose edge drifts in and out.
- **Weaving**: Three rings of dots turning against each other, each dot swinging across its neighbours.
- **Shaping**: 24 dots walk a triangle, a square and a circle, about a second each, morphing between them.
- **Thinking**: The default: a quiet ring of 20 dots on a drifting blob.
- **Paused**: `paused` holds one still frame. Under reduced motion every orb is drawn still like this.
- **Text size**: `size={20}`: globes thin to 60% of their rings so they stay legible, and sub-pixel dots draw as squares.
- **Named ink**: `color="sky"`: each named ink has a light and a dark shade, picked by the colour scheme.
- **Any CSS colour**: `color="#7c5cff"`, or `currentColor` to follow the text around it.
- **Denser, bigger dots**: `density={1.5}` and `dotSize={1.5}`: half as many dots again, half as big again.
- **Dark**: In a dark colour scheme mono turns near-white; the orb reads its colour every frame, so a theme switch shows at once.

## How it works

- Each state is a painter: a function of time that writes the frame’s dots (x, y, size, alpha, in units of the orb’s radius) into flat typed arrays reused every frame. Painters keep no state, so any moment can be drawn: a still frame under reduced motion, or the old state while it morphs into the new one.
- The globes are points on latitude rings, as many on a ring as its girth allows, turned by a yaw and a pitch with a light perspective; the far side fades or hides, and nearer dots draw bigger and brighter.
- Solving tumbles the lattice a quarter turn at a time, every 1.2 s with an ease in and out: twelve turns about x, y or z and then their undoing, so it comes back to where it began. Mid-turn the dots scatter a little and settle back on the lattice.
- Searching lights one patch at a time: a beam picks a new spot on the front every 0.72 s and rises and falls there, the dots under it growing and brightening. Listening squashes and stretches the globe with a voice level made of three sines, while a bright band runs up and down its rings.
- Composing is a dense shell of meridians stopped short of the poles. A waterline across the view, high in the middle and low at the sides, ripples with two sines: above it the near side is lit, below it only the inside of the far side shows, dim, so the shell reads as a bowl.
- Connecting wires each of about seventy nodes on a sphere to its three nearest, once, and turns the whole cloud about a tilted axis; lines and nodes fade with depth. Working runs short trains of nodes round thirteen tilted orbits, each orbit traced in faint specks.
- Breathing is a ring of 56 short dashes on a blob whose radius is three sines of the angle drifting in time; thinking is the same idea in 20 dots; weaving turns three rings of dots against each other, each dot swinging in and out across its neighbours; shaping walks 24 dots round a triangle, a square and a circle, 1.07 s each, and morphs between them in 0.36 s.
- A change of state runs both painters for 700 ms: dot i of the old one glides to dot i of the new one, size and alpha blending too, and dots without a partner fly from (or to) one while fading. Lines cross-fade.
- The canvas is half the orb’s size bigger than its box on each side, so dots pulled toward the cursor (gravity: within 2.5 sizes, by up to 0.3 of a size, eased in and out) are not cut off; the box still takes only `size` in the layout. It is sharp at up to twice the pixel ratio, and smaller orbs thin their globes out so they stay legible.
- The dots are drawn in the canvas’s CSS colour, read back each frame, so the colour prop, a theme or a scheme change shows at once, transition included. Each orb draws only while on screen.
- The pills’ words are dim, with a band of light that runs through them every 2 s: a copy of the text on top, clipped to a gradient four times its width that slides from right to left.
- While on screen and untouched, the playground walks through the states, 3.2 s on each; any press or key in it hands it to the reader for 8 s.

## API reference

- **state** (`'working' | 'searching' | 'solving' | 'listening' | 'connecting' | 'composing' | 'breathing' | 'weaving' | 'shaping' | 'thinking'`, default `'thinking'`): ThinkingOrb: what the orb shows. A change morphs from the old state to the new one in 700 ms.
- **size** (`number`, default `64`): ThinkingOrb: its width and height in px. Tuned at 64, 32 and 20; smaller orbs draw fewer dots on their globes.
- **color** (`'mono' | 'sky' | 'amber' | 'rose' | 'mint' | string`, default `'mono'`): ThinkingOrb: a named ink, each with a light and a dark shade, or any CSS colour. The showcase’s color prop sets it for every orb.
- **speed** (`number`, default `1`): ThinkingOrb: how fast it moves; 1 is the tuned pace.
- **dotSize** (`number`, default `1`): ThinkingOrb: how big its dots are; 1 is the tuned size.
- **density** (`number`, default `1`): ThinkingOrb: how many dots, as a share of the state’s own count.
- **gravity** (`boolean`, default `false`): ThinkingOrb: whether the dots lean toward a nearby cursor.
- **paused** (`boolean`, default `false`): ThinkingOrb: hold a still frame.
- **label** (`string`): ThinkingOrb: what screen readers hear (the state’s name by default); an empty string hides the orb from them, for when words next to it say the same.
- **defaultState** (`OrbState`, default `'listening'`): ThinkingOrbs (the showcase): the state the playground starts on.
- **gallery** (`boolean`, default `true`): ThinkingOrbs: whether to show the gallery of pills above the playground.
- **autoplay** (`boolean`, default `true`): ThinkingOrbs: whether the playground walks through the states on its own, while on screen and untouched.
- **colorScheme** (`'light' | 'dark' | 'system'`): ThinkingOrbs: the colour scheme; without it, the page’s.
- **className** (`string`): A class for the root, on either component.

## Accessibility

- An orb is an image named for its state (“Searching…”), or hidden from screen readers with label="" when the words beside it already say it; the canvas itself is always hidden.
- The playground’s choices are radio groups of buttons: Tab reaches the chosen one, the arrows (and Home, End) move and choose. Speed, dots amount and dot size are real range inputs with their values spoken; the colours are a radio group of named swatches.
- A state the reader picks is announced politely; the demo’s own changes are not, so it never chatters.
- Focus rings show for keyboard use. The play button says whether the orb is paused (aria-pressed).
- Under reduced motion the orbs hold a still frame, states change without a morph, the words’ light stops and the demo never plays.

## Source

### thinking-orbs/thinking-orbs.tsx

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

'use client';

import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { ThinkingOrb, type OrbColor } from './orb';
import { STATE_LABELS, STATES, type OrbState } from './states';
import { useAutoplay } from './use-autoplay';
import styles from './thinking-orbs.module.css';

export { ThinkingOrb, type OrbColor, type ThinkingOrbProps } from './orb';
export { STATES, type OrbState } from './states';

/*
 * Thinking orbs: small animated dot drawings that say an assistant is busy, and how: working,
 * searching, solving, listening, connecting, composing, breathing, weaving, shaping or thinking.
 * Each is a canvas of dots (orb.tsx) that a painter (states.ts) lays out frame by frame: globes
 * of latitude rings that turn, squash or tumble, a wired cloud, orbits, rings and outlines.
 *
 * - This file is the showcase: a gallery of orbs in pills, as they would sit in a chat, their
 *   words lit by a band of light that runs through them; and a playground, where the state, the
 *   size, the cursor's pull, the speed, the colour, the number and size of the dots can be tried,
 *   with the code to match.
 * - A change of state morphs: the old dots glide to the new ones' places in 700 ms.
 * - While on screen and untouched, the playground walks through the states, 3.2 s on each. Any
 *   press or key in it hands it to the reader.
 * - Light or dark: every colour is a light-dark() token, so it follows the page's color-scheme, or
 *   the colorScheme prop. The orbs draw in their CSS colour, so they follow too.
 */

type Cell = { state: OrbState; words: string; size: 'big' | 'small' };

/** The gallery's two columns, as tall as each other. */
const COLUMNS: Array<Array<Cell & { tall?: boolean }>> = [
  [
    { state: 'solving', words: 'Solving…', size: 'big' },
    { state: 'listening', words: 'Listening to you…', size: 'small' },
    { state: 'searching', words: 'Searching…', size: 'big' },
    { state: 'weaving', words: 'Drafting a plan…', size: 'small' },
    { state: 'thinking', words: 'Thinking it over…', size: 'small' },
  ],
  [
    { state: 'composing', words: 'Writing…', size: 'big' },
    { state: 'working', words: 'Working…', size: 'big' },
    { state: 'connecting', words: 'Linking ideas…', size: 'big' },
    { state: 'shaping', words: 'Shaping the layout…', size: 'small' },
  ],
];

const SIZES = [64, 20, 32] as const;
const COLORS: Array<{ id: OrbColor; name: string }> = [
  { id: 'mono', name: 'Mono' },
  { id: 'sky', name: 'Sky' },
  { id: 'amber', name: 'Amber' },
  { id: 'rose', name: 'Rose' },
  { id: 'mint', name: 'Mint' },
];
const DEMO_STEP = 3200;

/** A row of tabs that acts as one radio group: arrows move and choose, Tab leaves. */
function Choice<T extends string | number>({
  label,
  options,
  value,
  onChange,
  name,
  part,
}: {
  label: string;
  options: readonly T[];
  value: T;
  onChange: (value: T) => void;
  name: (value: T) => string;
  part: string;
}) {
  const buttons = useRef<Array<HTMLButtonElement | null>>([]);
  const onKey = (event: ReactKeyboardEvent, index: number) => {
    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
    let next = step === undefined ? -1 : (index + step + options.length) % options.length;
    if (event.key === 'Home') next = 0;
    if (event.key === 'End') next = options.length - 1;
    if (next < 0) return;
    event.preventDefault();
    onChange(options[next]);
    buttons.current[next]?.focus();
  };
  return (
    <div className={styles.field} role="radiogroup" aria-label={label} data-part={part}>
      <span className={styles.label} aria-hidden="true">
        {label}
      </span>
      <div className={styles.tabs}>
        {options.map((option, index) => (
          <button
            key={option}
            ref={(element) => {
              buttons.current[index] = element;
            }}
            type="button"
            role="radio"
            className={styles.tab}
            aria-checked={option === value}
            tabIndex={option === value ? 0 : -1}
            onClick={() => onChange(option)}
            onKeyDown={(event) => onKey(event, index)}
          >
            {name(option)}
          </button>
        ))}
      </div>
    </div>
  );
}

/** A slider drawn as a filled pill, its name and value inside; a real range input does the work. */
function Slider({
  label,
  min,
  max,
  step,
  value,
  onChange,
  format,
  part,
}: {
  label: string;
  min: number;
  max: number;
  step: number;
  value: number;
  onChange: (value: number) => void;
  format: (value: number) => string;
  part: string;
}) {
  const share = ((value - min) / (max - min)) * 100;
  return (
    <div className={styles.field} data-part={part}>
      <span className={styles.label} aria-hidden="true">
        {label}
      </span>
      <div className={styles.slider}>
        <span className={styles.sliderFill} style={{ width: `${share}%` }} />
        <span className={styles.sliderName} aria-hidden="true">
          {label}
        </span>
        <span className={styles.sliderValue} aria-hidden="true">
          {format(value)}
        </span>
        <input
          className={styles.range}
          type="range"
          aria-label={label}
          aria-valuetext={format(value)}
          min={min}
          max={max}
          step={step}
          value={value}
          onChange={(event) => onChange(Number(event.target.value))}
        />
      </div>
    </div>
  );
}

const times = (value: number) => `${Number(value.toFixed(2))}×`;

export function ThinkingOrbs({
  defaultState = 'listening',
  size: sizeProp = 64,
  color: colorProp = 'mono',
  gallery = true,
  autoplay = true,
  colorScheme,
  className,
}: {
  /** The state the playground starts on. */
  defaultState?: OrbState;
  /** The playground orb's size at first: 64, 20 or 32. */
  size?: 64 | 20 | 32;
  /** The orbs' colour at first. */
  color?: OrbColor;
  /** Whether to show the gallery above the playground. */
  gallery?: boolean;
  /** Whether the playground walks through the states on its own until the reader takes over. */
  autoplay?: boolean;
  /** The colour scheme; without it, the page's. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref, take, playing } = useAutoplay<HTMLDivElement>(8000);
  const [state, setState] = useState<OrbState>(defaultState);
  const [size, setSize] = useState<number>(sizeProp);
  const [gravity, setGravity] = useState(false);
  const [speed, setSpeed] = useState(1);
  const [color, setColor] = useState<OrbColor>(colorProp);
  const [density, setDensity] = useState(1);
  const [dotSize, setDotSize] = useState(1);
  const [paused, setPaused] = useState(false);
  const [said, setSaid] = useState('');
  const [copied, setCopied] = useState(false);
  const demo = autoplay && playing && !paused;

  // The demo: one state after another.
  useEffect(() => {
    if (!demo) return;
    const timer = window.setInterval(() => {
      setState((now) => STATES[(STATES.indexOf(now) + 1) % STATES.length]);
    }, DEMO_STEP);
    return () => window.clearInterval(timer);
  }, [demo]);

  const choose = (next: OrbState) => {
    setState(next);
    setSaid(`${STATE_LABELS[next]} state`);
  };

  const attributes = [
    `state="${state}"`,
    `size={${size}}`,
    color !== 'mono' && `color="${color}"`,
    speed !== 1 && `speed={${speed}}`,
    density !== 1 && `density={${density}}`,
    dotSize !== 1 && `dotSize={${dotSize}}`,
    gravity && 'gravity',
  ].filter(Boolean);
  const code = `import { ThinkingOrb } from './thinking-orbs';\n\n<ThinkingOrb ${attributes.join(' ')} />`;

  const copy = () => {
    navigator.clipboard?.writeText(code).then(
      () => {
        setCopied(true);
        window.setTimeout(() => setCopied(false), 1400);
      },
      () => {},
    );
  };

  return (
    <div
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-part="root"
    >
      {gallery && (
        <div className={styles.gallery} data-part="gallery">
          {COLUMNS.map((column, index) => (
            <div key={index} className={styles.column}>
              {column.map((cell) => (
                <div
                  key={cell.state}
                  className={styles.cell}
                  data-size={cell.size}
                  data-part="cell"
                >
                  <span className={styles.pill} data-size={cell.size} data-part="pill">
                    <ThinkingOrb
                      state={cell.state}
                      size={cell.size === 'big' ? 56 : 20}
                      color={color}
                      label=""
                    />
                    <span className={styles.words} data-text={cell.words} data-part="words">
                      {cell.words}
                    </span>
                  </span>
                </div>
              ))}
            </div>
          ))}
        </div>
      )}

      <h3 className={styles.heading} data-part="heading">
        Playground
      </h3>
      <div
        ref={ref}
        className={styles.playground}
        data-part="playground"
        onPointerDown={take}
        onKeyDown={take}
      >
        <div className={styles.stage} data-part="stage">
          <ThinkingOrb
            state={state}
            size={size}
            color={color}
            speed={speed}
            density={density}
            dotSize={dotSize}
            gravity={gravity}
            paused={paused}
          />
          <button
            type="button"
            className={styles.play}
            aria-pressed={paused}
            data-part="play"
            onClick={() => setPaused((now) => !now)}
          >
            {paused ? 'Play' : 'Pause'}
          </button>
        </div>

        <div className={styles.controls} data-part="controls">
          <Choice
            label="State"
            part="states"
            options={STATES}
            value={state}
            onChange={choose}
            name={(value) => STATE_LABELS[value]}
          />
          <Choice
            label="Size"
            part="sizes"
            options={SIZES}
            value={size as (typeof SIZES)[number]}
            onChange={setSize}
            name={(value) => `${value}px`}
          />
          <Choice
            label="Cursor gravity"
            part="gravity"
            options={['Off', 'On'] as const}
            value={gravity ? 'On' : 'Off'}
            onChange={(value) => setGravity(value === 'On')}
            name={(value) => value}
          />
          <Slider
            label="Speed"
            part="speed"
            min={0.25}
            max={3}
            step={0.25}
            value={speed}
            onChange={setSpeed}
            format={times}
          />
          <div className={styles.field} role="radiogroup" aria-label="Color" data-part="colors">
            <span className={styles.label} aria-hidden="true">
              Color
            </span>
            <div className={styles.swatches}>
              {COLORS.map((swatch, index) => (
                <button
                  key={swatch.id}
                  type="button"
                  role="radio"
                  className={styles.swatch}
                  aria-checked={swatch.id === color}
                  aria-label={swatch.name}
                  tabIndex={swatch.id === color ? 0 : -1}
                  data-color={swatch.id}
                  onClick={() => setColor(swatch.id)}
                  onKeyDown={(event) => {
                    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[
                      event.key
                    ];
                    if (step === undefined) return;
                    event.preventDefault();
                    const next = COLORS[(index + step + COLORS.length) % COLORS.length];
                    setColor(next.id);
                    const group = event.currentTarget.parentElement;
                    (group?.children[COLORS.indexOf(next)] as HTMLElement | undefined)?.focus();
                  }}
                />
              ))}
            </div>
          </div>
          <Slider
            label="Dots amount"
            part="density"
            min={0.5}
            max={1.5}
            step={0.1}
            value={density}
            onChange={setDensity}
            format={(value) => `${Math.round(value * 100)}%`}
          />
          <Slider
            label="Dot size"
            part="dot-size"
            min={0.5}
            max={2}
            step={0.1}
            value={dotSize}
            onChange={setDotSize}
            format={times}
          />
        </div>
      </div>

      <div className={styles.code} data-part="code">
        <pre>{code}</pre>
        <button
          type="button"
          className={styles.copy}
          aria-label={copied ? 'Copied' : 'Copy code'}
          onClick={copy}
        >
          {copied ? (
            <svg
              viewBox="0 0 16 16"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
              aria-hidden="true"
            >
              <path d="M3.5 8.5l3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          ) : (
            <svg
              viewBox="0 0 16 16"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.5"
              aria-hidden="true"
            >
              <rect x="5.5" y="5.5" width="8" height="8" rx="2" />
              <path d="M10.5 3.5v-.5a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" />
            </svg>
          )}
        </button>
      </div>
      <span className={styles.sr} aria-live="polite">
        {said}
      </span>
    </div>
  );
}
```

### thinking-orbs/orb.tsx

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

'use client';

import { useEffect, useRef, type CSSProperties } from 'react';
import { easeInOut, Frame, PAINTERS, STATE_LABELS, type OrbState } from './states';
import styles from './thinking-orbs.module.css';

/*
 * One thinking orb: a canvas that a state's painter (states.ts) fills with dots every frame.
 *
 * - The painter gives dots in the orb's own units (radius 1); the orb maps them to pixels, sized
 *   by the size prop, sharp on a dense screen, in the colour of its CSS `color`, read back each
 *   frame so a theme or scheme change (and its CSS transition) shows at once.
 * - A change of state morphs: for 700 ms both painters run, and dot i of the old one glides to dot
 *   i of the new one, its size and alpha blending too; dots one has and the other lacks fade in or
 *   out from (or to) a partner. Lines cross-fade.
 * - The canvas is bigger than the orb's box by half its size, so dots pulled by the cursor
 *   (gravity) are not cut at the edge; the box still takes only `size` in the layout.
 * - It draws only while on screen, and holds one still frame under reduced motion or when paused.
 */

export type OrbColor = 'mono' | 'sky' | 'amber' | 'rose' | 'mint';

export type ThinkingOrbProps = {
  /** What the orb shows. */
  state?: OrbState;
  /** Its width and height, in px. */
  size?: number;
  /** A named ink, or any CSS colour. */
  color?: OrbColor | (string & {});
  /** How fast it moves, 1 being the tuned pace. */
  speed?: number;
  /** How big its dots are, 1 being the tuned size. */
  dotSize?: number;
  /** How many dots, as a share of the state's own count. */
  density?: number;
  /** Whether the dots lean toward a nearby cursor. */
  gravity?: boolean;
  /** Hold a still frame. */
  paused?: boolean;
  /** What screen readers hear; the state's name by default. Empty hides it from them. */
  label?: string;
  className?: string;
  style?: CSSProperties;
};

const NAMED = new Set(['mono', 'sky', 'amber', 'rose', 'mint']);
const MORPH_MS = 700;
/** The sphere's radius, as a share of the orb's size. */
const RADIUS = 0.39;

export function ThinkingOrb({
  state = 'thinking',
  size = 64,
  color = 'mono',
  speed = 1,
  dotSize = 1,
  density,
  gravity = false,
  paused = false,
  label,
  className,
  style,
}: ThinkingOrbProps) {
  const box = useRef<HTMLSpanElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  // Everything the loop reads, kept current without restarting it.
  const live = useRef({ state, size, speed, dotSize, density, gravity, paused });
  const morph = useRef<{ from: OrbState; start: number } | null>(null);
  const shown = useRef(state);
  const redraw = useRef<() => void>(() => {});

  useEffect(() => {
    live.current = { state, size, speed, dotSize, density, gravity, paused };
    if (shown.current !== state) {
      morph.current = { from: shown.current, start: performance.now() };
      shown.current = state;
    }
    redraw.current();
  }, [state, size, speed, dotSize, density, gravity, paused]);

  useEffect(() => {
    const element = canvas.current;
    const holder = box.current;
    if (!element || !holder) return;
    const context = element.getContext('2d');
    if (!context) return;
    const a = new Frame();
    const b = new Frame();
    let clock = 0.6;
    let last = 0;
    let raf = 0;
    let visible = false;
    let reduced = false;
    let pointer: { x: number; y: number } | null = null;
    let pull = 0;
    let lastInk = '';

    const draw = (now: number) => {
      const o = live.current;
      const ratio = Math.min(2, window.devicePixelRatio || 1);
      const full = Math.round(o.size * 1.5);
      if (element.width !== Math.round(full * ratio)) {
        element.width = Math.round(full * ratio);
        element.height = Math.round(full * ratio);
      }
      const detail = o.size <= 24 ? 0.6 : o.size <= 40 ? 0.8 : 1;
      const options = { density: o.density ?? 1, detail };

      // The morph's progress, in real time (not the orb's clock, which runs at `speed`).
      let k = 1;
      const m = morph.current;
      if (m && !reduced) {
        k = easeInOut((now - m.start) / MORPH_MS);
        if (k >= 1) morph.current = null;
      } else morph.current = null;

      b.reset();
      PAINTERS[o.state](b, clock, options);
      const blending = k < 1 && morph.current;
      if (blending) {
        a.reset();
        PAINTERS[morph.current!.from](a, clock, options);
      }

      const ink = getComputedStyle(element).color;
      lastInk = ink;
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      context.clearRect(0, 0, full, full);
      context.fillStyle = ink;
      context.strokeStyle = ink;
      context.lineCap = 'round';
      const center = full / 2;
      const R = o.size * RADIUS;
      const dot = (0.55 + o.size * 0.0075) * o.dotSize;
      const hair = Math.max(0.5, o.size / 110);

      // Gravity: dots within reach lean toward the cursor, more the nearer they are.
      const rect = o.gravity && pointer ? element.getBoundingClientRect() : null;
      const gx = rect && pointer ? pointer.x - rect.left : 0;
      const gy = rect && pointer ? pointer.y - rect.top : 0;
      const reach = o.size * 2.5;
      const bend = o.size * 0.3;
      pull += ((rect ? 1 : 0) - pull) * 0.12;
      const lean = (px: number, py: number, outXY: number[]) => {
        outXY[0] = px;
        outXY[1] = py;
        if (pull < 0.01) return;
        const dx = gx - px;
        const dy = gy - py;
        const d = Math.hypot(dx, dy);
        if (d > reach || d < 0.001) return;
        const f = (1 - d / reach) ** 2;
        const step = Math.min(bend * f * pull, d * 0.85);
        outXY[0] = px + (dx / d) * step;
        outXY[1] = py + (dy / d) * step;
      };
      const p = [0, 0];
      const q = [0, 0];

      const strokeLines = (frame: Frame, fade: number) => {
        for (let i = 0; i < frame.ln; i++) {
          const j = i * 6;
          const alpha = frame.lines[j + 4] * fade;
          if (alpha < 0.004) continue;
          lean(center + frame.lines[j] * R, center + frame.lines[j + 1] * R, p);
          lean(center + frame.lines[j + 2] * R, center + frame.lines[j + 3] * R, q);
          context.globalAlpha = alpha;
          context.lineWidth = frame.lines[j + 5] ? frame.lines[j + 5] * dot * 2 : hair;
          context.beginPath();
          context.moveTo(p[0], p[1]);
          context.lineTo(q[0], q[1]);
          context.stroke();
        }
      };
      const fillDot = (x: number, y: number, r: number, alpha: number) => {
        if (alpha < 0.004 || r <= 0) return;
        lean(center + x * R, center + y * R, p);
        const radius = r * dot;
        if (radius < 0.6) {
          // Too small for a round dot to show: a square of the same area.
          context.globalAlpha = alpha * Math.min(1, (radius / 0.6) ** 2 * 1.4);
          context.fillRect(p[0] - 0.6, p[1] - 0.6, 1.2, 1.2);
          return;
        }
        context.globalAlpha = alpha;
        context.beginPath();
        context.arc(p[0], p[1], radius, 0, Math.PI * 2);
        context.fill();
      };

      if (blending) {
        strokeLines(a, 1 - k);
        strokeLines(b, k);
        const n = Math.max(a.n, b.n);
        for (let i = 0; i < n; i++) {
          if (i < a.n && i < b.n) {
            fillDot(
              a.x[i] + (b.x[i] - a.x[i]) * k,
              a.y[i] + (b.y[i] - a.y[i]) * k,
              a.r[i] + (b.r[i] - a.r[i]) * k,
              a.a[i] + (b.a[i] - a.a[i]) * k,
            );
          } else if (i < b.n) {
            // A new dot: it leaves from an old one and fades in on its way.
            const s = a.n ? i % a.n : -1;
            const sx = s < 0 ? b.x[i] * 0.2 : a.x[s];
            const sy = s < 0 ? b.y[i] * 0.2 : a.y[s];
            fillDot(sx + (b.x[i] - sx) * k, sy + (b.y[i] - sy) * k, b.r[i] * k, b.a[i] * k);
          } else {
            // An old dot with no partner: it heads for a new one and fades out.
            const s = b.n ? i % b.n : -1;
            const tx = s < 0 ? a.x[i] * 0.85 : b.x[s];
            const ty = s < 0 ? a.y[i] * 0.85 : b.y[s];
            fillDot(
              a.x[i] + (tx - a.x[i]) * k,
              a.y[i] + (ty - a.y[i]) * k,
              a.r[i],
              a.a[i] * (1 - k),
            );
          }
        }
      } else {
        strokeLines(b, 1);
        for (let i = 0; i < b.n; i++) fillDot(b.x[i], b.y[i], b.r[i], b.a[i]);
      }
      context.globalAlpha = 1;
    };

    const still = () => reduced || live.current.paused;
    const tick = (now: number) => {
      raf = 0;
      const dt = last ? Math.min(64, now - last) / 1000 : 0;
      last = now;
      if (!still()) clock += dt * live.current.speed;
      draw(now);
      const moving = !still() || morph.current || pull > 0.01;
      if (visible && moving) raf = requestAnimationFrame(tick);
    };
    const start = () => {
      if (!raf && visible) {
        last = 0;
        raf = requestAnimationFrame(tick);
      }
    };
    redraw.current = start;

    const observer = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) start();
      else if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    });
    observer.observe(holder);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => {
      reduced = motion.matches;
      start();
    };
    readMotion();
    motion.addEventListener('change', readMotion);
    const onMove = (event: PointerEvent) => {
      if (!live.current.gravity) return;
      pointer = { x: event.clientX, y: event.clientY };
      start();
    };
    const onLeave = () => {
      pointer = null;
      start();
    };
    window.addEventListener('pointermove', onMove, { passive: true });
    document.documentElement.addEventListener('pointerleave', onLeave);
    // A still orb follows a colour change (scheme, theme) with a fresh frame.
    const inkWatch = window.setInterval(() => {
      if (!visible || raf) return;
      if (getComputedStyle(element).color !== lastInk) start();
    }, 400);

    return () => {
      cancelAnimationFrame(raf);
      observer.disconnect();
      motion.removeEventListener('change', readMotion);
      window.removeEventListener('pointermove', onMove);
      document.documentElement.removeEventListener('pointerleave', onLeave);
      window.clearInterval(inkWatch);
      redraw.current = () => {};
    };
  }, []);

  const named = NAMED.has(color);
  const spoken = label ?? `${STATE_LABELS[state]}…`;
  return (
    <span
      ref={box}
      className={`${styles.orb} ${className ?? ''}`}
      data-part="orb"
      data-color={named ? color : undefined}
      role={spoken ? 'img' : undefined}
      aria-label={spoken || undefined}
      aria-hidden={spoken ? undefined : true}
      style={
        {
          '--orb-size': `${size}px`,
          ...(named ? null : { '--orb-ink': color }),
          ...style,
        } as CSSProperties
      }
    >
      <canvas ref={canvas} className={styles.canvas} data-part="canvas" aria-hidden="true" />
    </span>
  );
}
```

### thinking-orbs/states.ts

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

/*
 * The orb's states, each a painter: given the time, it writes the frame's dots (and, for some,
 * lines) into a Frame. Everything is in the orb's own units: the sphere's radius is 1, the
 * middle is 0,0, y points down. A dot's size is in "dot units" (1 is the base dot, which the
 * orb scales with its size and the dotSize prop) and its alpha runs from 0 to 1.
 *
 * Painters keep no state between frames: the same time draws the same frame, so a state can be
 * drawn still (reduced motion) or morphed from another one (transitions) at any moment.
 */

export const STATES = [
  'working',
  'searching',
  'solving',
  'listening',
  'connecting',
  'composing',
  'breathing',
  'weaving',
  'shaping',
  'thinking',
] as const;
export type OrbState = (typeof STATES)[number];

const MAX_DOTS = 3000;
const MAX_LINES = 600;

/** One frame's dots and lines, in flat typed arrays reused from frame to frame. */
export class Frame {
  n = 0;
  x = new Float32Array(MAX_DOTS);
  y = new Float32Array(MAX_DOTS);
  r = new Float32Array(MAX_DOTS);
  a = new Float32Array(MAX_DOTS);
  /** Lines: x1, y1, x2, y2, alpha, width (in dot units; 0 draws a hairline). */
  lines = new Float32Array(MAX_LINES * 6);
  ln = 0;
  reset() {
    this.n = 0;
    this.ln = 0;
  }
  dot(x: number, y: number, r: number, a: number) {
    if (this.n >= MAX_DOTS || a <= 0.003) return;
    const i = this.n++;
    this.x[i] = x;
    this.y[i] = y;
    this.r[i] = r;
    this.a[i] = a > 1 ? 1 : a;
  }
  line(x1: number, y1: number, x2: number, y2: number, a: number, width = 0) {
    if (this.ln >= MAX_LINES || a <= 0.003) return;
    const i = this.ln++ * 6;
    this.lines[i] = x1;
    this.lines[i + 1] = y1;
    this.lines[i + 2] = x2;
    this.lines[i + 3] = y2;
    this.lines[i + 4] = a;
    this.lines[i + 5] = width;
  }
}

export type PaintOptions = {
  /** How many dots, as a share of the state's own count (0.4 to 1.6). */
  density: number;
  /** How much detail the orb's size allows (1 at 64 px, less when small): globes thin out. */
  detail: number;
};

type Painter = (frame: Frame, t: number, options: PaintOptions) => void;

/* ---------- helpers ---------- */

/** A small seeded random generator, so every orb draws the same cloud. */
function random(seed: number) {
  let s = seed >>> 0;
  return () => {
    s = (s + 0x6d2b79f5) >>> 0;
    let v = s;
    v = Math.imul(v ^ (v >>> 15), v | 1);
    v ^= v + Math.imul(v ^ (v >>> 7), v | 61);
    return ((v ^ (v >>> 14)) >>> 0) / 4294967296;
  };
}

const TAU = Math.PI * 2;
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const smooth = (v: number) => {
  const c = clamp01(v);
  return c * c * (3 - 2 * c);
};
export const easeInOut = (v: number) => {
  const c = clamp01(v);
  return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
};

/** A 3×3 rotation matrix, row-major. */
type Mat = number[];
const IDENTITY: Mat = [1, 0, 0, 0, 1, 0, 0, 0, 1];
function axisAngle(ax: number, ay: number, az: number, angle: number): Mat {
  const c = Math.cos(angle);
  const s = Math.sin(angle);
  const k = 1 - c;
  return [
    c + ax * ax * k,
    ax * ay * k - az * s,
    ax * az * k + ay * s,
    ay * ax * k + az * s,
    c + ay * ay * k,
    ay * az * k - ax * s,
    az * ax * k - ay * s,
    az * ay * k + ax * s,
    c + az * az * k,
  ];
}
function multiply(a: Mat, b: Mat): Mat {
  const out = new Array<number>(9);
  for (let row = 0; row < 3; row++)
    for (let col = 0; col < 3; col++)
      out[row * 3 + col] =
        a[row * 3] * b[col] + a[row * 3 + 1] * b[3 + col] + a[row * 3 + 2] * b[6 + col];
  return out;
}
/** Turn about y (yaw), then tilt about x (pitch): the usual camera on a spinning globe. */
function yawPitch(yaw: number, pitch: number): Mat {
  return multiply(axisAngle(1, 0, 0, pitch), axisAngle(0, 1, 0, yaw));
}

/** Where a turned point lands, and how near it is (z: 1 front, -1 back). */
const out3 = { x: 0, y: 0, z: 0 };
function turn(m: Mat, x: number, y: number, z: number) {
  out3.x = m[0] * x + m[1] * y + m[2] * z;
  out3.y = m[3] * x + m[4] * y + m[5] * z;
  out3.z = m[6] * x + m[7] * y + m[8] * z;
  // A light perspective: the near side a touch bigger.
  const p = 1 + out3.z * 0.07;
  out3.x *= p;
  out3.y *= p;
  return out3;
}

/** Points on latitude rings, as many on a ring as its girth allows. Cached by count. */
type Points = { x: Float32Array; y: Float32Array; z: Float32Array; n: number; ring: Int16Array };
const ringCache = new Map<string, Points>();
function latitudeRings(rings: number, perEquator: number, cap = 0.86): Points {
  const key = `${rings}:${perEquator}:${cap}`;
  const hit = ringCache.get(key);
  if (hit) return hit;
  const xs: number[] = [];
  const ys: number[] = [];
  const zs: number[] = [];
  const ringOf: number[] = [];
  for (let i = 0; i < rings; i++) {
    const lat = (-cap + (2 * cap * i) / (rings - 1)) * (Math.PI / 2);
    const girth = Math.cos(lat);
    const count = Math.max(4, Math.round(perEquator * girth));
    for (let j = 0; j < count; j++) {
      const lon = (j / count) * TAU + (i % 2) * (Math.PI / count);
      xs.push(girth * Math.cos(lon));
      ys.push(Math.sin(lat));
      zs.push(girth * Math.sin(lon));
      ringOf.push(i);
    }
  }
  const points = {
    x: Float32Array.from(xs),
    y: Float32Array.from(ys),
    z: Float32Array.from(zs),
    ring: Int16Array.from(ringOf),
    n: xs.length,
  };
  ringCache.set(key, points);
  return points;
}

/** Dot count scaled by density, rounded. */
const scaled = (base: number, density: number, min = 3) =>
  Math.max(min, Math.round(base * density));

/* ---------- working: nodes running round tilted orbits, the orbits drawn faintly ---------- */

type Orbit = { m: Mat; radius: number; speed: number; phase: number; nodes: number[] };
const orbitCache = new Map<number, Orbit[]>();
function orbits(count: number): Orbit[] {
  const hit = orbitCache.get(count);
  if (hit) return hit;
  const rand = random(7);
  const list: Orbit[] = [];
  for (let i = 0; i < count; i++) {
    const ax = rand() * 2 - 1;
    const ay = rand() * 2 - 1;
    const az = rand() * 2 - 1;
    const length = Math.hypot(ax, ay, az) || 1;
    const m = axisAngle(ax / length, ay / length, az / length, rand() * Math.PI);
    const nodes: number[] = [];
    const cluster = 2 + Math.floor(rand() * 4);
    for (let k = 0; k < cluster; k++) nodes.push(0.45 + rand() * 0.6);
    list.push({
      m,
      radius: 0.62 + rand() * 0.38,
      speed: (0.35 + rand() * 0.75) * (rand() < 0.5 ? -1 : 1),
      phase: rand() * TAU,
      nodes,
    });
  }
  orbitCache.set(count, list);
  return list;
}

const working: Painter = (f, t, options) => {
  const density = options.density * options.detail;
  const list = orbits(scaled(13, density, 5));
  const spin = yawPitch(t * 0.18, 0.3);
  for (const orbit of list) {
    const m = multiply(spin, orbit.m);
    // The path: faint specks all the way round.
    const specks = Math.round(18 + orbit.radius * 26);
    for (let k = 0; k < specks; k++) {
      const angle = (k / specks) * TAU;
      const p = turn(m, orbit.radius * Math.cos(angle), 0, orbit.radius * Math.sin(angle));
      f.dot(p.x, p.y, 0.4, 0.03 + 0.035 * (p.z + 1));
    }
    // The nodes, a short train of them, a step apart.
    for (let k = 0; k < orbit.nodes.length; k++) {
      const angle = orbit.phase + t * orbit.speed + k * 0.32 * Math.sign(orbit.speed);
      const p = turn(m, orbit.radius * Math.cos(angle), 0, orbit.radius * Math.sin(angle));
      const near = (p.z + 1) / 2;
      f.dot(p.x, p.y, orbit.nodes[k] * (0.75 + 0.4 * near), 0.45 + 0.55 * near);
    }
  }
};

/* ---------- searching: a lat-long globe turning, a beam lighting one patch, then another ---------- */

const searching: Painter = (f, t, options) => {
  const density = options.density * options.detail;
  const pts = latitudeRings(scaled(13, density, 6), scaled(26, density, 8));
  const m = yawPitch(t * 0.45, -0.2);
  // The beam: a new spot every 0.85 s, rising and falling in brightness.
  const period = 0.72;
  const k = Math.floor(t / period);
  const phase = (t - k * period) / period;
  const rand = random(k * 97 + 13);
  const bx = (rand() * 2 - 1) * 0.62;
  const by = (rand() * 2 - 1) * 0.25;
  const glow = Math.sin(Math.PI * phase) ** 2;
  for (let i = 0; i < pts.n; i++) {
    const p = turn(m, pts.x[i], pts.y[i], pts.z[i]);
    if (p.z < -0.2) continue;
    const front = smooth((p.z + 0.2) / 0.5);
    const dx = p.x - bx;
    const dy = p.y - by;
    const lit = glow * Math.exp(-(dx * dx) / 0.03 - (dy * dy) / 0.45);
    const rim = 0.55 + 0.45 * p.z;
    f.dot(
      p.x,
      p.y,
      (0.55 + 0.22 * rim) * (1 + 0.9 * lit),
      front * (0.36 + 0.14 * rim + 0.55 * lit),
    );
  }
};

/* ---------- solving: a lattice globe that tumbles a quarter turn at a time ---------- */

/** Twelve quarter turns and then their undoing, so the lattice comes back to where it began. */
const TURN_AXES: Array<[number, number, number]> = [
  [1, 0, 0],
  [0, 1, 0],
  [0, 0, 1],
  [0, 1, 0],
  [1, 0, 0],
  [0, 0, 1],
  [1, 0, 0],
  [0, 0, 1],
  [0, 1, 0],
  [1, 0, 0],
  [0, 1, 0],
  [0, 0, 1],
];
const TURN_STEP = 1.2;
const turnPrefix: Mat[] = (() => {
  const list: Mat[] = [IDENTITY];
  for (let i = 0; i < 24; i++) {
    const [ax, ay, az] = i < 12 ? TURN_AXES[i] : TURN_AXES[23 - i];
    const sign = i < 12 ? 1 : -1;
    list.push(multiply(axisAngle(ax, ay, az, (sign * Math.PI) / 2), list[i]));
  }
  return list;
})();

const solving: Painter = (f, t, options) => {
  const density = options.density * options.detail;
  const pts = latitudeRings(scaled(12, density, 6), scaled(22, density, 8));
  const step = Math.floor(t / TURN_STEP);
  const k = ((step % 24) + 24) % 24;
  const progress = easeInOut(((t - step * TURN_STEP) / TURN_STEP - 0.25) / 0.6);
  const [ax, ay, az] = k < 12 ? TURN_AXES[k] : TURN_AXES[23 - k];
  const sign = k < 12 ? 1 : -1;
  const turning = multiply(axisAngle(ax, ay, az, (sign * progress * Math.PI) / 2), turnPrefix[k]);
  const m = multiply(yawPitch(t * 0.12, 0.18), turning);
  // Mid-turn, the dots scatter a little and settle back on the lattice.
  const scatter = 0.02 + Math.sin(Math.PI * progress) * 0.1;
  const rand = random(3);
  for (let i = 0; i < pts.n; i++) {
    const size = 0.4 + rand() * 0.55;
    const jx = (rand() * 2 - 1) * scatter;
    const jy = (rand() * 2 - 1) * scatter;
    const p = turn(m, pts.x[i], pts.y[i], pts.z[i]);
    const near = (p.z + 1) / 2;
    f.dot(p.x + jx, p.y + jy, size * (0.7 + 0.5 * near), 0.35 + 0.65 * near);
  }
};

/* ---------- listening: a ringed globe that squashes and stretches to a voice ---------- */

const listening: Painter = (f, t, options) => {
  const density = options.density * options.detail;
  const pts = latitudeRings(scaled(11, density, 5), scaled(22, density, 8));
  const level = clamp01(
    0.5 + 0.32 * Math.sin(t * 6.3) + 0.18 * Math.sin(t * 10.7 + 1.3) + 0.12 * Math.sin(t * 2.9),
  );
  const m = yawPitch(t * 0.55, -0.1 + 0.05 * Math.sin(t * 1.7));
  const sy = 0.86 + 0.18 * level;
  const sx = 1.08 - 0.14 * level;
  const band = Math.sin(t * 2.4) * 0.55;
  for (let i = 0; i < pts.n; i++) {
    const p = turn(m, pts.x[i], pts.y[i], pts.z[i]);
    const front = 0.2 + 0.8 * smooth((p.z + 0.25) / 0.5);
    const lit = level * Math.exp(-((pts.y[i] - band) ** 2) / 0.09);
    const rim = 0.5 + 0.5 * p.z;
    f.dot(
      p.x * sx,
      p.y * sy,
      (0.55 + 0.25 * rim) * (1 + 0.6 * lit),
      front * (0.5 + 0.25 * rim + 0.4 * lit),
    );
  }
};

/* ---------- connecting: a cloud of nodes on a sphere, each wired to its nearest ---------- */

type Net = {
  x: Float32Array;
  y: Float32Array;
  z: Float32Array;
  size: Float32Array;
  edges: number[];
};
const netCache = new Map<number, Net>();
function network(count: number): Net {
  const hit = netCache.get(count);
  if (hit) return hit;
  const rand = random(11);
  const x = new Float32Array(count);
  const y = new Float32Array(count);
  const z = new Float32Array(count);
  const size = new Float32Array(count);
  const golden = Math.PI * (3 - Math.sqrt(5));
  for (let i = 0; i < count; i++) {
    const v = 1 - ((i + 0.5) / count) * 2;
    const girth = Math.sqrt(1 - v * v);
    const angle = i * golden + (rand() - 0.5) * 0.9;
    const radius = 0.86 + rand() * 0.14;
    x[i] = Math.cos(angle) * girth * radius;
    y[i] = (v + (rand() - 0.5) * 0.12) * radius;
    z[i] = Math.sin(angle) * girth * radius;
    size[i] = rand() < 0.18 ? 1.3 + rand() * 0.45 : 0.5 + rand() * 0.45;
  }
  const edges: number[] = [];
  const seen = new Set<number>();
  for (let i = 0; i < count; i++) {
    const near: Array<[number, number]> = [];
    for (let j = 0; j < count; j++) {
      if (i === j) continue;
      near.push([(x[i] - x[j]) ** 2 + (y[i] - y[j]) ** 2 + (z[i] - z[j]) ** 2, j]);
    }
    near.sort((a, b) => a[0] - b[0]);
    for (const [, j] of near.slice(0, 3)) {
      const key = i < j ? i * 4096 + j : j * 4096 + i;
      if (seen.has(key)) continue;
      seen.add(key);
      edges.push(i, j);
    }
  }
  const net = { x, y, z, size, edges };
  netCache.set(count, net);
  return net;
}

const connectingProjected = {
  x: new Float32Array(400),
  y: new Float32Array(400),
  z: new Float32Array(400),
};
const connecting: Painter = (f, t, options) => {
  const density = options.density * options.detail;
  const net = network(scaled(70, density, 16));
  const m = multiply(axisAngle(0.26, 0.96, 0.1, t * 0.5), IDENTITY);
  const view = multiply(axisAngle(1, 0, 0, 0.35), m);
  const P = connectingProjected;
  const n = Math.min(net.x.length, 400);
  for (let i = 0; i < n; i++) {
    const p = turn(view, net.x[i], net.y[i], net.z[i]);
    P.x[i] = p.x;
    P.y[i] = p.y;
    P.z[i] = p.z;
  }
  for (let e = 0; e < net.edges.length; e += 2) {
    const i = net.edges[e];
    const j = net.edges[e + 1];
    if (i >= n || j >= n) continue;
    const near = (P.z[i] + P.z[j] + 2) / 4;
    f.line(P.x[i], P.y[i], P.x[j], P.y[j], 0.07 + 0.3 * near * near);
  }
  for (let i = 0; i < n; i++) {
    const near = (P.z[i] + 1) / 2;
    const pulse = net.size[i] > 1.4 ? 1 + 0.15 * Math.sin(t * 3 + i) : 1;
    f.dot(P.x[i], P.y[i], net.size[i] * pulse * (0.7 + 0.45 * near), 0.35 + 0.65 * near);
  }
};

/* ---------- composing: a dense globe of meridians, its lower front hollowed under a wave ---------- */

const composing: Painter = (f, t, options) => {
  const density = options.density * options.detail;
  const meridians = scaled(38, density, 14);
  const perMeridian = scaled(34, density, 12);
  const m = yawPitch(t * 0.32, -0.1);
  const sx = 1.03 + 0.025 * Math.sin(t * 1.3);
  const sy = 0.95 - 0.025 * Math.sin(t * 1.3);
  for (let i = 0; i < meridians; i++) {
    const lon = (i / meridians) * TAU;
    const cl = Math.cos(lon);
    const sl = Math.sin(lon);
    // No caps: the meridians stop short of the poles, so the globe's top reads flat.
    for (let j = 0; j < perMeridian; j++) {
      const lat = (-0.78 + (1.56 * j) / (perMeridian - 1)) * (Math.PI / 2);
      const girth = Math.cos(lat);
      const p = turn(m, girth * cl, Math.sin(lat), girth * sl);
      // The waterline, across the view: high in the middle, low at the sides, and rippling.
      const water =
        -0.3 +
        0.75 * p.x * p.x +
        0.14 * Math.sin(4 * p.x + t * 2.2) +
        0.07 * Math.sin(7 * p.x - t * 1.6 + 1);
      let alpha: number;
      if (p.y < water) alpha = p.z > 0 ? 0.62 + 0.33 * p.z : 0;
      else alpha = p.z > 0 ? 0.02 : 0.07 + 0.13 * -p.z;
      f.dot(p.x * sx, p.y * sy, 0.52 + 0.1 * p.z, alpha);
    }
  }
};

/* ---------- breathing: a ring of short dashes on a slowly changing blob ---------- */

const breathing: Painter = (f, t, { density }) => {
  const count = scaled(56, density, 16);
  const swell = 1 + 0.035 * Math.sin(t * 1.6);
  for (let i = 0; i < count; i++) {
    const angle = (i / count) * TAU - Math.PI / 2;
    const wobble =
      0.07 * Math.sin(3 * angle + t * 1.1) +
      0.045 * Math.sin(5 * angle - t * 1.5 + 1) +
      0.03 * Math.sin(2 * angle + t * 0.7 + 2);
    const radius = 0.9 * swell * (1 + wobble);
    const c = Math.cos(angle);
    const s = Math.sin(angle);
    const alpha = 0.7 + 0.12 * Math.sin(angle * 2 + t * 0.9);
    f.line(
      c * (radius - 0.055),
      s * (radius - 0.055),
      c * (radius + 0.055),
      s * (radius + 0.055),
      alpha,
      0.4,
    );
  }
};

/* ---------- weaving: three rings of dots, turning against each other and swapping places ---------- */

const weaving: Painter = (f, t, { density }) => {
  const rings: Array<[number, number, number, number, number]> = [
    // count, radius, spin, size, alpha
    [3, 0.3, 0.9, 1.5, 1],
    [6, 0.62, -0.6, 1.05, 0.85],
    [scaled(12, density, 6), 0.92, 0.38, 0.75, 0.6],
  ];
  rings.forEach(([count, radius, spin, size, alpha], ring) => {
    for (let i = 0; i < count; i++) {
      const angle = (i / count) * TAU + t * spin + ring * 0.4;
      const weave = 0.13 * Math.sin(t * 2.1 + i * 2.4 + ring * 1.7);
      const r = radius + weave;
      const x = Math.cos(angle) * r;
      const y = Math.sin(angle) * r;
      const breathe = 1 + 0.25 * Math.sin(t * 2.1 + i * 2.4 + ring * 1.7 + 1.2);
      f.dot(x, y, size * breathe, alpha);
      // A faint satellite trails each dot.
      const back = angle - Math.sign(spin) * 0.22;
      f.dot(Math.cos(back) * (r + 0.05), Math.sin(back) * (r + 0.05), size * 0.45, alpha * 0.45);
    }
  });
};

/* ---------- shaping: a dotted outline morphing from triangle to square to circle ---------- */

function outlinePoint(shape: number, u: number, out: { x: number; y: number }) {
  if (shape === 2) {
    const angle = u * TAU - Math.PI / 2;
    out.x = Math.cos(angle) * 0.86;
    out.y = Math.sin(angle) * 0.86;
    return;
  }
  const corners =
    shape === 0
      ? [
          [0, -0.98],
          [0.92, 0.62],
          [-0.92, 0.62],
        ]
      : // The square starts at the middle of its top side, as the others start at the top.
        [
          [0, -0.74],
          [0.74, -0.74],
          [0.74, 0.74],
          [-0.74, 0.74],
          [-0.74, -0.74],
        ];
  const sides = corners.length;
  const lengths: number[] = [];
  let total = 0;
  for (let i = 0; i < sides; i++) {
    const [ax, ay] = corners[i];
    const [bx, by] = corners[(i + 1) % sides];
    const length = Math.hypot(bx - ax, by - ay);
    lengths.push(length);
    total += length;
  }
  let d = u * total;
  for (let i = 0; i < sides; i++) {
    if (d <= lengths[i] || i === sides - 1) {
      const [ax, ay] = corners[i];
      const [bx, by] = corners[(i + 1) % sides];
      const k = Math.min(1, d / lengths[i]);
      out.x = ax + (bx - ax) * k;
      out.y = ay + (by - ay) * k;
      return;
    }
    d -= lengths[i];
  }
}

const SHAPE_STEP = 1.07;
const SHAPE_MORPH = 0.36;
const pa = { x: 0, y: 0 };
const pb = { x: 0, y: 0 };
const shaping: Painter = (f, t, { density }) => {
  const count = scaled(24, density, 9);
  const step = Math.floor(t / SHAPE_STEP);
  const from = ((step % 3) + 3) % 3;
  const to = (from + 1) % 3;
  const local = t - step * SHAPE_STEP;
  const k = easeInOut((local - (SHAPE_STEP - SHAPE_MORPH)) / SHAPE_MORPH);
  for (let i = 0; i < count; i++) {
    const u = i / count;
    outlinePoint(from, u, pa);
    outlinePoint(to, u, pb);
    f.dot(pa.x + (pb.x - pa.x) * k, pa.y + (pb.y - pa.y) * k, 1.12, 0.92);
  }
};

/* ---------- thinking: a small ring of dots, gently pulled out of round ---------- */

const thinking: Painter = (f, t, { density }) => {
  const count = scaled(20, density, 8);
  const spin = t * 0.35;
  for (let i = 0; i < count; i++) {
    const angle = (i / count) * TAU + spin;
    const wobble = 0.08 * Math.sin(3 * angle - t * 1.4) + 0.05 * Math.sin(5 * angle + t * 0.9);
    const radius = 0.84 * (1 + wobble);
    f.dot(Math.cos(angle) * radius, Math.sin(angle) * radius, 0.95, 0.75);
  }
};

export const PAINTERS: Record<OrbState, Painter> = {
  working,
  searching,
  solving,
  listening,
  connecting,
  composing,
  breathing,
  weaving,
  shaping,
  thinking,
};

/** What each state says to a screen reader, and on the playground's tabs. */
export const STATE_LABELS: Record<OrbState, string> = {
  working: 'Working',
  searching: 'Searching',
  solving: 'Solving',
  listening: 'Listening',
  connecting: 'Connecting',
  composing: 'Composing',
  breathing: 'Breathing',
  weaving: 'Weaving',
  shaping: 'Shaping',
  thinking: 'Thinking',
};
```

### thinking-orbs/thinking-orbs.module.css

```css
/* Thinking orbs (thinking-orbs.tsx): a gallery of orbs in pills on quiet cells, and a playground
   with a stage and a panel of controls. At night the page is near-black and the cells a step
   lighter; by day the cells are white on a pale grey.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets
   (data-color-scheme). An orb draws its dots in its CSS `color`, read back from the canvas. */

.root {
  /* The page, the cells, the controls' panel. */
  --page: #f6f6f6;
  --cell: #fff;
  --cell-edge: rgb(0 0 0 / 0.05);
  --panel: #fff;
  --panel-lift: rgb(0 0 0 / 0.06);
  --panel-edge: rgb(0 0 0 / 0.07);
  /* The pills an orb sits in, and their words with the light that runs over them. */
  --pill: rgb(0 0 0 / 0.025);
  --pill-edge: rgb(0 0 0 / 0.07);
  --words: rgb(20 20 20 / 0.5);
  --shine: #000;
  /* The controls. */
  --heading: #171717;
  --label: #767676;
  --tab: rgb(0 0 0 / 0.04);
  --tab-ink: #5b5b5b;
  --tab-hover: rgb(0 0 0 / 0.07);
  --tab-on: #fff;
  --tab-on-ink: #111;
  --tab-on-lift: rgb(0 0 0 / 0.12);
  --tab-on-edge: rgb(0 0 0 / 0.06);
  --fill: rgb(0 0 0 / 0.07);
  --code-ink: #2b2b2b;
  --focus: #387bff;
  /* The colour swatches: the orbs' inks (see .orb). */
  --swatch-mono: #262626;
  --swatch-sky: #1d8fd0;
  --swatch-amber: #b86e00;
  --swatch-rose: #cf3f80;
  --swatch-mint: #23944a;
  position: relative;
  container-type: inline-size;
  box-sizing: border-box;
  width: 100%;
  padding: 24px;
  border-radius: 18px;
  background: var(--page);
  color: var(--heading);
  font-family:
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --page: light-dark(#f6f6f6, #121212);
    --cell: light-dark(#fff, #171717);
    --cell-edge: light-dark(rgb(0 0 0 / 0.05), transparent);
    --panel: light-dark(#fff, #1b1b1b);
    --panel-lift: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.24));
    --panel-edge: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.04));
    --pill: light-dark(rgb(0 0 0 / 0.025), rgb(29 29 29 / 0.5));
    --pill-edge: light-dark(rgb(0 0 0 / 0.07), rgb(44 47 54 / 0.22));
    --words: light-dark(rgb(20 20 20 / 0.5), rgb(251 251 251 / 0.5));
    --shine: light-dark(#000, #fff);
    --heading: light-dark(#171717, #fff);
    --label: light-dark(#767676, #979797);
    --tab: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.04));
    --tab-ink: light-dark(#5b5b5b, #b1b1b1);
    --tab-hover: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07));
    --tab-on: light-dark(#fff, rgb(255 255 255 / 0.08));
    --tab-on-ink: light-dark(#111, #fbfbfb);
    --tab-on-lift: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.24));
    --tab-on-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.04));
    --fill: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.08));
    --code-ink: light-dark(#2b2b2b, #e6e6e6);
    --swatch-mono: light-dark(#262626, #ededed);
    --swatch-sky: light-dark(#1d8fd0, #7cd4ff);
    --swatch-amber: light-dark(#b86e00, #ffd28f);
    --swatch-rose: light-dark(#cf3f80, #ff9ec9);
    --swatch-mint: light-dark(#23944a, #9fe8a8);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* ---------- the orb ---------- */

/* Its box takes `size`; the canvas spills a quarter of it on every side, for gravity's pull. */
.orb {
  --orb-ink: #262626;
  --ink-sky: #1d8fd0;
  --ink-amber: #b86e00;
  --ink-rose: #cf3f80;
  --ink-mint: #23944a;
  position: relative;
  display: block;
  flex: none;
  width: var(--orb-size);
  height: var(--orb-size);
  color: var(--orb-ink);
  transition: color 0.3s;
}
@supports (color: light-dark(#fff, #000)) {
  .orb {
    --orb-ink: light-dark(#262626, #ededed);
    --ink-sky: light-dark(#1d8fd0, #7cd4ff);
    --ink-amber: light-dark(#b86e00, #ffd28f);
    --ink-rose: light-dark(#cf3f80, #ff9ec9);
    --ink-mint: light-dark(#23944a, #9fe8a8);
  }
}
.orb[data-color='sky'] {
  color: var(--ink-sky);
}
.orb[data-color='amber'] {
  color: var(--ink-amber);
}
.orb[data-color='rose'] {
  color: var(--ink-rose);
}
.orb[data-color='mint'] {
  color: var(--ink-mint);
}
.canvas {
  position: absolute;
  inset: calc(var(--orb-size) * -0.25);
  width: calc(var(--orb-size) * 1.5);
  height: calc(var(--orb-size) * 1.5);
  pointer-events: none;
}

/* ---------- the gallery: two columns of cells ---------- */

.gallery {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}
.column {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}
.cell {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-width: 0;
  height: 314px;
  padding: 48px 24px;
  border-radius: 16px;
  background: var(--cell);
  box-shadow: inset 0 0 0 1px var(--cell-edge);
}
.cell[data-size='small'] {
  height: 151px;
}

/* An orb and its words in a pill: the big one around a 56 px orb, the small around a 20 px one. */
.pill {
  display: flex;
  align-items: center;
  gap: 12px;
  box-sizing: border-box;
  max-width: 100%;
  height: 74px;
  padding: 0 32px 0 9px;
  border-radius: 50px;
  background: var(--pill);
  box-shadow: inset 0 0 0 1px var(--pill-edge);
  font-size: 18px;
  line-height: 24px;
}
.pill[data-size='small'] {
  gap: 8px;
  height: 36px;
  padding: 0 14px 0 8px;
  font-size: 13px;
  line-height: 14px;
}

/* The words, dim, with a band of light running right to left through them every 2 s. */
.words {
  position: relative;
  display: block;
  overflow: hidden;
  color: var(--words);
  white-space: nowrap;
  text-overflow: ellipsis;
}
.words::before {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  background-image: linear-gradient(
    90deg,
    transparent 0%,
    transparent 40%,
    var(--shine) 50%,
    transparent 60%,
    transparent 100%
  );
  background-size: 400% 100%;
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  animation: shine 2s linear infinite;
}
@keyframes shine {
  from {
    background-position: 100% 0;
  }
  to {
    background-position: 0% 0;
  }
}

/* ---------- the playground ---------- */

.heading {
  margin: 36px 0 0;
  color: var(--heading);
  font-size: 15px;
  font-weight: 500;
  line-height: 20px;
}
.playground {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 244px;
  gap: 12px;
  margin-top: 12px;
}
.stage {
  position: relative;
  display: grid;
  place-items: center;
  min-height: 586px;
  border-radius: 16px;
  background: var(--cell);
  box-shadow: inset 0 0 0 1px var(--cell-edge);
  overflow: hidden;
  overflow: clip;
}
.play {
  position: absolute;
  bottom: 20px;
  left: 50%;
  translate: -50% 0;
}

/* The panel of controls. */
.controls {
  display: flex;
  flex-direction: column;
  gap: 16px;
  box-sizing: border-box;
  padding: 16px;
  border-radius: 16px;
  background: var(--panel);
  box-shadow:
    0 2px 6px rgb(0 0 0 / 0.05),
    0 4px 42px var(--panel-lift),
    inset 0 0 0 1px var(--panel-edge),
    inset 0 1px 0 rgb(255 255 255 / 0.02);
}
.field {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}
.label {
  padding: 0;
  color: var(--label);
  font-size: 13px;
  line-height: 14px;
}
.tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

/* A tab, and the play button, which looks like one. */
.tab,
.play {
  box-sizing: border-box;
  height: 32px;
  padding: 0 12px;
  border: 0;
  border-radius: 36px;
  background: var(--tab);
  color: var(--tab-ink);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 16px;
  cursor: pointer;
  transition:
    background-color 0.15s,
    color 0.15s,
    box-shadow 0.15s;
}
.tab:hover,
.play:hover {
  background: var(--tab-hover);
}
.tab[aria-checked='true'] {
  background: var(--tab-on);
  color: var(--tab-on-ink);
  box-shadow:
    0 1px 1px var(--tab-on-lift),
    inset 0 0 0 1px var(--tab-on-edge),
    inset 0 1px 0 rgb(255 255 255 / 0.06);
}
.play {
  color: var(--tab-on-ink);
}
.tab:focus-visible,
.play:focus-visible,
.swatch:focus-visible,
.copy:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The colour swatches. */
.swatches {
  display: flex;
  gap: 8px;
}
.swatch {
  width: 26px;
  height: 26px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--swatch-mono);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.08);
  cursor: pointer;
  transition: box-shadow 0.15s;
}
.swatch[data-color='sky'] {
  background: var(--swatch-sky);
}
.swatch[data-color='amber'] {
  background: var(--swatch-amber);
}
.swatch[data-color='rose'] {
  background: var(--swatch-rose);
}
.swatch[data-color='mint'] {
  background: var(--swatch-mint);
}
.swatch[aria-checked='true'] {
  box-shadow:
    0 0 0 2px var(--panel),
    0 0 0 3.5px var(--tab-ink);
}

/* A slider: a pill with its name at the left and its value at the right, filled to the value.
   The real range input lies on top, unseen, for the pointer and the keyboard. */
.slider {
  position: relative;
  height: 32px;
  border-radius: 10px;
  background: var(--tab);
  overflow: hidden;
  overflow: clip;
  color: var(--tab-ink);
  font-size: 13px;
  font-weight: 500;
  line-height: 32px;
}
.slider:has(:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.sliderFill {
  position: absolute;
  inset: 0 auto 0 0;
  background: var(--fill);
}
.sliderName {
  position: absolute;
  left: 12px;
  pointer-events: none;
}
.sliderValue {
  position: absolute;
  right: 12px;
  color: var(--tab-on-ink);
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}
.range {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: ew-resize;
}

/* The code for what the playground shows. */
.code {
  position: relative;
  margin-top: 12px;
  padding: 24px 56px 24px 26px;
  border-radius: 16px;
  background: var(--cell);
  box-shadow: inset 0 0 0 1px var(--cell-edge);
  color: var(--code-ink);
  font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  font-size: 13px;
  line-height: 21.5px;
  overflow-x: auto;
}
.code pre {
  margin: 0;
  font: inherit;
  white-space: pre;
}
.copy {
  position: absolute;
  top: 16px;
  right: 16px;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: var(--cell);
  color: var(--label);
  cursor: pointer;
}
.copy:hover {
  background: var(--tab);
  color: var(--tab-on-ink);
}
.copy svg {
  width: 16px;
  height: 16px;
}

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

/* ---------- narrower ---------- */

@container (max-width: 760px) {
  .playground {
    grid-template-columns: minmax(0, 1fr);
  }
  .stage {
    min-height: 360px;
  }
}
@container (max-width: 620px) {
  .root {
    padding: 12px;
  }
  .gallery {
    grid-template-columns: minmax(0, 1fr);
  }
  .cell {
    height: 200px;
    padding: 24px 12px;
  }
  .cell[data-size='small'] {
    height: 120px;
  }
  .pill {
    padding-right: 22px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .words::before {
    animation: none;
    opacity: 0;
  }
  .orb,
  .tab,
  .play,
  .swatch {
    transition: none;
  }
}
```

### thinking-orbs/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 };
}
```