# Orbit loader

> A task card’s loader: a bright ring swings round a slowly turning ring of dashes in depth, then settles into it with a check when the task is done.

A task card whose loader is a ring of dim dashes that turns slowly while a bright, thick ring orbits it in 3D. The bright ring grows as it swings across the front and slips behind the dashed ring’s disc on the way back, while a warm glow breathes along the card’s foot. When the task finishes, the dashes stop, the bright ring comes forward and grows over them, and a check pops up; run the task again and the ring shrinks back out into its orbit.

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

Category: Feedback.

## Anatomy

1. **Card**: A layout boundary of a fixed size, 8 times the loader wide, so nothing that moves makes the page lay out again.
2. **Title**: The task’s name on one line; a long one ends in an ellipsis. It labels the card for screen readers.
3. **Dashed ring**: 24 dim dashes on a disc of the card’s colour, turning slowly anticlockwise, once every seven laps. The disc hides the bright ring as it passes behind.
4. **Bright ring**: A thick accent ring that orbits the dashes in depth every 2 s, growing as it swings in front. Done, it springs into the middle at full size.
5. **Glow**: A warm band along the card’s foot that breathes once every two laps, and fades out when the task is done.
6. **Button**: Only without a `loading` prop: Finish, or Run again once done. A screen reader hears the change announced.

## When to use

- One named task that runs for a few seconds to a minute, shown as a card: a plan being drafted, an import, a sync.
- Work whose end is a clear moment worth marking: the ring grows over the dashes and a check pops in.
- A hero or empty-state moment where the wait itself is on show, and a little character helps it feel shorter.
- A task that can be run again from the same place: the stage’s own button finishes it and starts it over.

## When not to use

- Progress you can measure. Show a bar or a percentage, so people know how long is left.
- Small inline waits, in a button or a table row. Use a plain spinner at text size.
- Several tasks at once: a page of orbiting rings competes for attention. Use one list with a status per row.
- Waits under half a second: the lap never reads, and the finish looks like a flash.
- Errors and partial results: the card only knows running and done. Say what failed in words.

## Installation

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

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

// The card follows your task's state: it orbits while the task runs and settles with a check when it's done.
export function TaskCard({ running }: { running: boolean }) {
  return <OrbitLoader title="Weekly report" loading={running} autoplay={false} />;
}
```

## States

- **Running**: The stage’s own state. Held still here; live, the ring laps every 2 s and the glow breathes.
- **Done**: The ring covers the dashes, the check pops in, and the button reads Run again.
- **Controlled, running**: With `loading` set the card has no button: the app says when it is done. `aria-busy` is on.
- **Controlled, done**: `loading={false}`: the loader’s label reads Done.
- **Dark**: `colorScheme="dark"`, whatever the page. Every colour is a light-dark() token: the dashes turn a faint accent and the shadows go.
- **Long title**: One line, cut with an ellipsis: the card never grows.

## How it works

- The orbit is a circle in depth seen from the front. The ring’s place along a 45° line follows the sine of its angle, so it slows at both ends, and its scale follows perspective, 1 / (1 − 0.265 × cos angle): about 1.36 × in front and 0.79 × behind. orbit.ts samples this into 24 keyframes for a 2-second lap.
- The Web Animations API plays the keyframes on the compositor, with no work frame by frame. The bright ring has two copies, one drawn under the dashed ring’s disc and one over it; each shows for its half of the lap, and they swap at the ends of the swing, where the ring is clear of the disc.
- The dashed ring is a small layer of its own that turns anticlockwise once every seven laps, on the same clock as the laps. It holds its angle when the task finishes and turns again when the task runs.
- The bright ring is drawn at the dashed ring’s size and scaled down to orbit. Finishing reads where it is drawn now, then grows it on a spring over the dashes, and a check pops up. Running again shrinks it back out to the upper right end of the swing, where a lap starts from standing still.
- The glow along the card’s foot is a gradient on a small layer whose opacity breathes once every two laps. Everything pauses while the card is off screen, and the card is a fixed-size layout boundary, so the browser does no layout or style work while the loader orbits.
- Every colour is a light-dark() token, so the stage and the card follow the page’s color-scheme, or the colorScheme prop: a white card on a pale grey stage by day, a dark one at night. On white the dim dashes turn grey, while the bright ring, its check and the warm glow keep their colour.

## API reference

- **title** (`string`, default `'Start of week planning'`): The task’s name, along the top of the card.
- **loading** (`boolean`): Whether the task is still running. Leave it out and the stage holds its own state, with a button to finish the task and run it again.
- **defaultLoading** (`boolean`, default `true`): Without loading: whether the stage’s own task starts running, or done with its button reading Run again. Starting done turns the demo off.
- **still** (`boolean`, default `false`): Holds every part still, as under reduced motion: the ring stands at the upper right of its swing, and finishing shows the full ring and its check at once. Turns the demo off.
- **onLoadingChange** (`(loading: boolean) => void`): Called when the stage’s own button, or the demo pointer, finishes the task or runs it again.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer finishes the task and runs it again, when the stage holds its own state.
- **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

- The card is a group named by its title. It has aria-busy while loading, and the loader is an image labelled “In progress” or “Done”.
- Finish and Run again is a real button with a visible focus ring. When the reader presses it, “Start of week planning is done.” or “…is running again.” is announced in a polite live region; the demo’s presses stay silent.
- When keyboard focus is on the stage, the demo leaves it alone, and the reader’s first press or key stops the demo until they leave it alone.
- Under reduced motion the ring stands still at the top of its swing and the dashes don’t turn; finishing shows the full ring and its check at once, and the demo never plays. Without JavaScript the resting frame, glow included, is drawn on the server.

## Source

### orbit-loader/orbit-loader.tsx

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

'use client';

import { useEffect, useId, useRef, useState, type FocusEvent } from 'react';
import { DemoPointer, runDemo } from './demo-pointer';
import { GLOW_START, SETTLED, START, transformOf } from './orbit';
import { useAutoplay } from './use-autoplay';
import { useOrbit } from './use-orbit';
import styles from './orbit-loader.module.css';

/*
 * Orbit loader: a task card whose loading ring has a moon. A ring of short, dim dashes sits in
 * the card's lower left corner, turning slowly, and a bright, thick ring circles it in depth: it
 * swings across the front from the upper right, growing as it comes closer, and goes back round
 * behind, smaller, hidden by the dashed ring's disc. A warm glow breathes along the card's foot.
 *
 * - The orbit is a sine and a perspective scale sampled into keyframes (orbit.ts), played by the
 *   Web Animations API on the compositor (use-orbit.ts): no work frame by frame. The bright ring
 *   has two copies, one under the dashed ring's disc and one over it; each shows for its half of
 *   the lap, and they swap where the ring is clear of the disc. Off screen, the laps pause.
 * - Finished, the ring leaves its orbit from wherever it is, comes forward and grows on a spring
 *   until it covers the dashes, and a check pops up in its middle. Running again, the check goes
 *   and the ring shrinks back out to the upper right, where a lap starts from standing still.
 * - The card is a layout boundary of a fixed size, and each moving part is a small layer of its
 *   own, so nothing that moves makes the browser lay out or paint the page again.
 * - Under reduced motion the ring stands at the top of its swing, and finishing shows the full
 *   ring and its check at once.
 * - 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. On white the dashes are grey; the bright
 *   ring keeps its colour.
 *
 * Without a `loading` prop the stage holds its own state, with a button to finish the task and
 * run it again; while on screen and untouched, a demo pointer presses it now and then.
 */

export function OrbitLoader({
  title = 'Start of week planning',
  loading: controlled,
  autoplay = true,
  defaultLoading = true,
  still = false,
  onLoadingChange,
  colorScheme,
  className = '',
}: {
  /** The task's name, along the top of the card. */
  title?: string;
  /**
   * Whether the task still runs. Leave it out and the stage holds its own state, with a button
   * to finish the task and run it again.
   */
  loading?: boolean;
  /** While on screen and untouched, a demo pointer finishes the task and runs it again. */
  autoplay?: boolean;
  /**
   * Without `loading`: whether the stage's own task starts running (true) or done, with its
   * button reading Run again. Starting done turns the demo pointer off.
   */
  defaultLoading?: boolean;
  /**
   * Holds every part still, as under reduced motion: the ring stands at the upper right end of
   * its swing, and finishing shows the full ring and its check at once. Turns the demo off.
   */
  still?: boolean;
  /** Called when the stage's own button (or the demo pointer) finishes the task or runs it again. */
  onLoadingChange?: (loading: boolean) => void;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Extra class for the root, a stage of its own. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [own, setOwn] = useState(defaultLoading);
  const loading = controlled ?? own;
  // The pose the server draws; from then on the motion sets the parts' styles.
  const [initial] = useState(loading);
  const [message, setMessage] = useState('');
  /** Keyboard focus is on the stage: the demo leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const titleId = useId();
  const parts = useOrbit(loading, still);
  const button = useRef<HTMLButtonElement>(null);
  const aside = useRef<HTMLSpanElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** The stage's own state, for the demo's script. */
  const running = useRef(own);

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

  const toggle = (byReader: boolean) => {
    const next = !running.current;
    running.current = next;
    setOwn(next);
    onLoadingChange?.(next);
    if (byReader) announce(next ? `${title} is running again.` : `${title} is done.`);
  };

  // The demo: while on screen and untouched, the pointer finishes the task now and then, and
  // runs it again a moment later.
  const demo =
    autoplay && defaultLoading && !still && playing && controlled === undefined && !keyboard;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    return runDemo(stage, arrow, async ({ wait, moveTo, press }) => {
      await wait(900);
      await moveTo(aside.current);
      for (;;) {
        await wait(running.current ? 3800 : 1700);
        await moveTo(button.current);
        await wait(240);
        await press(() => toggle(false));
        await wait(420);
        await moveTo(aside.current);
      }
    });
    // The script reads the state through a ref, and `toggle` sets it the same way.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div className={styles.scene}>
        <div
          className={styles.card}
          data-part="card"
          role="group"
          aria-labelledby={titleId}
          aria-busy={loading}
        >
          <span
            ref={parts.glow}
            className={styles.glow}
            data-part="glow"
            style={{ opacity: initial ? GLOW_START : 0 }}
            aria-hidden="true"
          />
          <p id={titleId} className={styles.title} data-part="title">
            {title}
          </p>
          <div
            ref={parts.loader}
            className={styles.loader}
            data-part="loader"
            role="img"
            aria-label={loading ? 'In progress' : 'Done'}
          >
            <span
              ref={parts.back}
              className={styles.orbiter}
              data-part="ring-back"
              style={{ transform: transformOf(START), opacity: 0 }}
            />
            <span ref={parts.dashes} className={styles.dashes} data-part="dashes">
              <Dashes />
            </span>
            <span
              ref={parts.front}
              className={styles.orbiter}
              data-part="ring"
              style={{ transform: transformOf(initial ? START : SETTLED) }}
            />
            <span
              ref={parts.check}
              className={styles.check}
              data-part="check"
              style={{ transform: `scale(${initial ? 0.5 : 1})`, opacity: initial ? 0 : 1 }}
            >
              <svg viewBox="0 0 100 100" aria-hidden="true">
                <path d="M36 51.5 46 61.5l18.5-21" />
              </svg>
            </span>
          </div>
        </div>
        {controlled === undefined && (
          <div className={styles.controls}>
            <button
              ref={button}
              type="button"
              className={styles.button}
              data-part="button"
              onClick={() => toggle(true)}
            >
              {own ? 'Finish' : 'Run again'}
            </button>
            <span ref={aside} className={styles.aside} aria-hidden="true" />
          </div>
        )}
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
      <DemoPointer ref={pointer} />
    </div>
  );
}

/** The dashes, 24 of them, from 0.415 to 0.5 of the loader's size out of its middle. */
const DASHES = Array.from({ length: 24 }, (_, i) => {
  const angle = (i / 24) * 2 * Math.PI - Math.PI / 2;
  const [x, y] = [Math.cos(angle), Math.sin(angle)];
  const at = (r: number) => `${(r * x).toFixed(2)} ${(r * y).toFixed(2)}`;
  return `M${at(41.5)}L${at(50)}`;
}).join('');

/**
 * The ring of dashes, on a disc of the card's colour a little wider than itself, which hides the
 * orbit behind it.
 */
function Dashes() {
  return (
    <svg viewBox="-50 -50 100 100" aria-hidden="true">
      <circle r="58.5" />
      <path d={DASHES} />
    </svg>
  );
}
```

### orbit-loader/orbit-loader.module.css

```css
/* Orbit loader (orbit-loader.tsx): a light stage with a white task card in its middle, or at night
   a dark stage and card, and under the card the button that finishes the task and runs it again.
   Everything on the card is sized from --d, the loader's size (the dashed ring's outer diameter),
   which follows the stage's width.

   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).

   Memory. A phone keeps a page's pixels in tiles the width of the screen, and a tile painted again
   gets a second buffer. So what moves stays off the page's tiles: the dashed ring, the two copies
   of the bright ring, the check and the glow are small layers of their own, moved by transforms
   and opacity, and the card is a layout boundary of a fixed size. */

.stage {
  /* The stage, and the card on it (also the discs that hide the ring behind, and the button). */
  --surface: #f0f0f2;
  --surface-2: #fff;
  /* The hairline round the card and the button; the card's and the button's shadows (none at
     night). */
  --line: rgb(0 0 0 / 0.08);
  --shadow: rgb(0 0 0 / 0.08);
  --shadow-near: rgb(0 0 0 / 0.05);
  /* The title and the button's label; the button under the pointer, and its label. */
  --ink: #5e5e63;
  --hover: #f4f4f6;
  --ink-hover: #1c1c1e;
  /* The bright ring, the check, the glow and the focus ring: the same on both. */
  --accent: #ed5330;
  /* The dim dashes: grey on white; at night, the accent, faint. */
  --dash: #8a8a8e;
  position: relative;
  container-type: inline-size;
  height: 418px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.06);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#f0f0f2, #101010);
    --surface-2: light-dark(#fff, #1a1a1c);
    --line: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.09));
    --shadow: light-dark(rgb(0 0 0 / 0.08), transparent);
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), transparent);
    --ink: light-dark(#5e5e63, #9d9da1);
    --hover: light-dark(#f4f4f6, #232326);
    --ink-hover: light-dark(#1c1c1e, #d4d4d8);
    --dash: light-dark(#8a8a8e, rgb(237 83 48 / 0.4));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}

.scene {
  --d: clamp(24px, 10.2cqi, 48px);
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 22px;
}

/* The card: its title along the top, the loader in its lower left corner, and a warm glow along
   its foot. A fixed size, and a layout boundary. */
.card {
  position: relative;
  flex: none;
  width: calc(var(--d) * 8);
  height: calc(var(--d) * 3.833);
  overflow: hidden;
  overflow: clip;
  contain: strict;
  border-radius: calc(var(--d) * 0.15);
  background: var(--surface-2);
  box-shadow:
    0 1px 2px var(--shadow-near),
    0 10px 28px -8px var(--shadow);
}
/* Its hairline edge, over the glow. */
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--line);
  pointer-events: none;
}

.title {
  position: absolute;
  top: calc(var(--d) * 0.563);
  right: calc(var(--d) * 0.68);
  left: calc(var(--d) * 0.68);
  margin: 0;
  overflow: hidden;
  font-size: calc(var(--d) * 0.524);
  font-weight: 300;
  line-height: 1.2;
  word-spacing: 0.16em;
  color: var(--ink);
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Strongest towards the right, fading out to the left and upwards; brightest a little above the
   card's edge. */
.glow {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: calc(var(--d) * 0.27);
  background: linear-gradient(
    to right,
    rgb(237 83 48 / 0) 13%,
    rgb(237 83 48 / 0.33) 70%,
    rgb(237 83 48 / 0.28)
  );
  -webkit-mask-image: linear-gradient(
    to top,
    rgb(0 0 0 / 0.62),
    rgb(0 0 0 / 0.85) 20%,
    #000 34%,
    rgb(0 0 0 / 0.92) 49%,
    rgb(0 0 0 / 0.8) 59%,
    rgb(0 0 0 / 0.5) 68%,
    rgb(0 0 0 / 0.35) 78%,
    rgb(0 0 0 / 0.15) 88%,
    transparent
  );
  mask-image: linear-gradient(
    to top,
    rgb(0 0 0 / 0.62),
    rgb(0 0 0 / 0.85) 20%,
    #000 34%,
    rgb(0 0 0 / 0.92) 49%,
    rgb(0 0 0 / 0.8) 59%,
    rgb(0 0 0 / 0.5) 68%,
    rgb(0 0 0 / 0.35) 78%,
    rgb(0 0 0 / 0.15) 88%,
    transparent
  );
  pointer-events: none;
  will-change: opacity;
}

/* The loader: a box of the dashed ring's size, its middle 1.04 --d in from the left and the foot. */
.loader {
  position: absolute;
  bottom: calc(var(--d) * 0.54);
  left: calc(var(--d) * 0.54);
  width: var(--d);
  height: var(--d);
}

/* The bright ring, drawn at the loader's full size (finished) and scaled down to orbit. It sits
   on a disc of the card's colour, a little wider than itself, which hides the dashes under and
   around it as it passes in front. */
.orbiter {
  position: absolute;
  inset: 0;
  border: calc(var(--d) * 0.198) solid var(--accent);
  border-radius: 50%;
  background: var(--surface-2);
  box-shadow: 0 0 0 calc(var(--d) * 0.19) var(--surface-2);
  will-change: transform, opacity;
}

/* The dashed ring, on a layer of its own that turns slowly. */
.dashes {
  position: absolute;
  inset: 0;
  will-change: transform;
}
.dashes svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.dashes circle {
  fill: var(--surface-2);
}
.dashes path {
  fill: none;
  stroke: var(--dash);
  stroke-width: 3;
}

.check {
  position: absolute;
  inset: 0;
  will-change: transform, opacity;
}
.check svg {
  display: block;
  width: 100%;
  height: 100%;
}
.check path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 8.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The stage's own button, with a spot beside it where the demo pointer rests. */
.controls {
  position: relative;
}
.button {
  height: 34px;
  margin: 0;
  padding: 0 16px;
  border: 0;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow:
    inset 0 0 0 1px var(--line),
    0 1px 2px var(--shadow-near);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 34px;
  color: var(--ink);
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
@media (hover: hover) {
  .button:hover {
    background: var(--hover);
    color: var(--ink-hover);
  }
}
.button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.aside {
  position: absolute;
  top: 46px;
  left: calc(100% + 26px);
  width: 1px;
  height: 1px;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

### orbit-loader/orbit.ts

```tsx
/*
 * The orbit, as numbers. The bright ring travels a circle in depth around the dashed ring, seen
 * from the front: across the front it swings from the upper right to the lower left and grows as
 * it comes closer; behind, it goes back up, smaller, and the dashed ring's disc hides it.
 *
 * Seen from the front, a circle in depth is a line: the ring's place along that line goes as the
 * sine of its angle around the orbit, so it slows at both ends and is quickest as it passes in
 * front. Its size goes with its depth, as perspective makes it: 1 / (1 - DEPTH × cos angle).
 *
 * Everything is in shares of the loader's size (the dashed ring's outer diameter), so the same
 * keyframes serve every size. The ring is drawn at the loader's full size (its finished state)
 * and scaled down to orbit.
 */

/** One lap, in ms. */
export const PERIOD = 2000;
/** The dashed ring turns slowly anticlockwise meanwhile, once every seven laps. */
export const SPIN = PERIOD * 7;
/** How far the ring swings from the middle, as a share of the loader's size. */
const REACH = 0.847;
/** The ring's diameter at either end of its swing, as a share of the loader's size. */
const SIZE = 0.432;
/** How strongly depth scales it: about 1.36 × in front, 0.79 × behind. */
const DEPTH = 0.265;
/** The orbit's middle, a touch above and left of the dashed ring's, as shares of the size. */
const CENTRE = { x: -0.035, y: -0.081 };
/** The line it swings along: 45° from the horizontal, upper right to lower left. */
const SLOPE = Math.SQRT1_2;
/** Keyframes per lap: a sampled sine, smooth to the eye at linear easing. */
const STEPS = 24;

export type Pose = {
  /** Its offset from the loader's middle, in % of the loader's size. */
  x: number;
  y: number;
  /** Its scale, 1 being the loader's full size. */
  scale: number;
};

/**
 * Where the ring is at `share` of a lap (0 to 1). A lap starts at the upper right end of the
 * swing, where it stands still for an instant, and passes in front first.
 */
export function poseAt(share: number): Pose {
  // The angle around the orbit: 0 in front, ±90° at the ends of the swing.
  const angle = 2 * Math.PI * (share - 0.25);
  const along = -Math.sin(angle) * REACH;
  return {
    x: (CENTRE.x + along * SLOPE) * 100,
    y: (CENTRE.y - along * SLOPE) * 100,
    scale: SIZE / (1 - DEPTH * Math.cos(angle)),
  };
}

/** A pose as a CSS transform. */
export const transformOf = ({ x, y, scale }: Pose) =>
  `translate(${x.toFixed(3)}%, ${y.toFixed(3)}%) scale(${scale.toFixed(4)})`;

/** The pose a lap starts from, and the one the finished ring rests in, in the middle. */
export const START = poseAt(0);
export const SETTLED: Pose = { x: 0, y: 0, scale: 1 };

/**
 * One lap of keyframes for one of the ring's two copies: the copy drawn over the disc shows for
 * the front half of the lap, the one drawn under it for the back half. They swap at the lower
 * left end of the swing, and again as the lap starts over at the upper right, where the ring is
 * clear of the disc, so the swap never shows.
 */
export function orbitKeyframes(copy: 'front' | 'back'): Keyframe[] {
  const shown = (front: boolean) => (front === (copy === 'front') ? 1 : 0);
  const frames: Keyframe[] = [];
  for (let i = 0; i <= STEPS; i += 1) {
    const share = i / STEPS;
    const pose = poseAt(share);
    const transform = transformOf(pose);
    if (i === STEPS / 2) {
      // The swap, at the lower left end: two keyframes at the same offset.
      frames.push({ offset: share, transform, opacity: shown(true) });
      frames.push({ offset: share, transform, opacity: shown(false) });
    } else {
      frames.push({ offset: share, transform, opacity: shown(share < 0.5) });
    }
  }
  return frames;
}

/**
 * The warm glow along the card's foot breathes once every two laps: brightest just before a lap
 * starts, it dies away as the ring swings across the front, stays out for most of a lap, and
 * comes back. GLOW_START is where it stands as a lap starts.
 */
export const GLOW_START = 0.88;
export const GLOW_KEYFRAMES: Keyframe[] = [
  { offset: 0, opacity: GLOW_START },
  { offset: 0.05, opacity: 0.72 },
  { offset: 0.0875, opacity: 0.48 },
  { offset: 0.125, opacity: 0.29 },
  { offset: 0.1625, opacity: 0.1 },
  { offset: 0.2, opacity: 0 },
  { offset: 0.5625, opacity: 0, easing: 'ease-in-out' },
  { offset: 0.7875, opacity: 0.62 },
  { offset: 0.8625, opacity: 0.87 },
  { offset: 0.9, opacity: 1 },
  { offset: 0.9375, opacity: 1 },
  { offset: 1, opacity: GLOW_START },
];
```

### orbit-loader/use-orbit.ts

```tsx
'use client';

import { useEffect, useRef } from 'react';
import {
  GLOW_KEYFRAMES,
  GLOW_START,
  PERIOD,
  SETTLED,
  SPIN,
  START,
  orbitKeyframes,
  transformOf,
} from './orbit';
import { easingOf, spring } from './spring';

/*
 * The loader's motion, with the Web Animations API. While loading, the laps play: both copies of
 * the bright ring and the glow, on one clock, and the dashed ring turns slowly; all of it pauses
 * while the loader is off screen. Finished, the dashed ring holds still, the bright ring leaves
 * its orbit from wherever it is drawn and grows over it, and the check pops up. Running again,
 * the check goes, the ring shrinks back out to where a lap starts, and the laps take over.
 *
 * Each move starts from what is on screen (the computed styles), so a change in the middle of
 * another one carries on from there. Under reduced motion, every part is simply set.
 */

const LAP: KeyframeAnimationOptions = { duration: PERIOD, iterations: Infinity };
/** Growing into the ring, then the check's pop: lively springs. */
const SETTLE = spring(0.22, 430);
const POP = spring(0.38, 320);
/** Shrinking back out: it arrives standing still, as a lap starts. */
const LEAVE: KeyframeAnimationOptions = { duration: 620, easing: 'cubic-bezier(0.5, 0, 0.25, 1)' };

type Parts = {
  /** The bright ring drawn over the dashed ring's disc, and the one drawn under it. */
  front: HTMLElement;
  back: HTMLElement;
  /** The dashed ring, which turns slowly while the laps run. */
  dashes: HTMLElement;
  check: HTMLElement;
  glow: HTMLElement;
};

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** Where each part rests, set at once: the end of every move, and all of reduced motion. */
function rest({ front, back, check, glow }: Parts, done: boolean) {
  front.style.transform = transformOf(done ? SETTLED : START);
  front.style.opacity = '1';
  back.style.transform = transformOf(START);
  back.style.opacity = '0';
  check.style.transform = done ? 'scale(1)' : 'scale(0.5)';
  check.style.opacity = done ? '1' : '0';
  glow.style.opacity = done ? '0' : String(GLOW_START);
}

/** Plays the loader's motion for `loading`. Returns the refs to put on its parts. */
export function useOrbit(loading: boolean, still = false) {
  const loader = useRef<HTMLDivElement>(null);
  const front = useRef<HTMLSpanElement>(null);
  const back = useRef<HTMLSpanElement>(null);
  const dashes = useRef<HTMLSpanElement>(null);
  const check = useRef<HTMLSpanElement>(null);
  const glow = useRef<HTMLSpanElement>(null);
  /** Shows `loading` on the parts; set while mounted. */
  const show = useRef<((next: boolean) => void) | null>(null);
  const latest = useRef(loading);
  /** Every part held still, as under reduced motion (the still prop). */
  const held = useRef(still);
  /** Sets the parts afresh, for a change to reduced motion or to `still`; set while mounted. */
  const refresh = useRef<(() => void) | null>(null);

  useEffect(() => {
    let laps: Animation[] = [];
    let moves: Animation[] = [];
    /** The dashed ring's turn: it runs with the laps and holds its angle otherwise. */
    let spin: Animation | null = null;
    let turning = false;
    let onScreen = false;
    /** The server drew the resting pose: the first show starts from it without a move. */
    let fresh = true;
    /** Bumped by each show, so a move that ends late knows it is out of date. */
    let round = 0;

    const parts = (): Parts | null =>
      front.current && back.current && dashes.current && check.current && glow.current
        ? {
            front: front.current,
            back: back.current,
            dashes: dashes.current,
            check: check.current,
            glow: glow.current,
          }
        : null;
    const stop = (list: Animation[]) => list.forEach((animation) => animation.cancel());

    /** Starts or holds the dashed ring's turn. */
    const turn = (all: Parts, on: boolean) => {
      turning = on;
      if (on && !spin) {
        spin = all.dashes.animate(
          [{ transform: 'rotate(0turn)' }, { transform: 'rotate(-1turn)' }],
          {
            duration: SPIN,
            iterations: Infinity,
          },
        );
      }
      if (on && onScreen) spin?.play();
      else spin?.pause();
    };

    /** Laps from the upper right, the copies and the glow on one clock. */
    const startLaps = (all: Parts) => {
      stop(laps);
      laps = [
        all.front.animate(orbitKeyframes('front'), LAP),
        all.back.animate(orbitKeyframes('back'), LAP),
        all.glow.animate(GLOW_KEYFRAMES, { duration: PERIOD * 2, iterations: Infinity }),
      ];
      const now = document.timeline.currentTime;
      laps.forEach((lap) => {
        if (typeof now === 'number') lap.startTime = now;
        if (!onScreen) lap.pause();
      });
      turn(all, true);
    };

    /** Out of the orbit, from wherever the ring is drawn, into the dashed ring; then the check. */
    const settle = (all: Parts) => {
      // The copy on show: from behind the disc, the ring comes forward over it.
      const shown = Number(getComputedStyle(all.back).opacity) > 0.5 ? all.back : all.front;
      const from = getComputedStyle(shown).transform;
      const glowFrom = getComputedStyle(all.glow).opacity;
      stop(laps);
      stop(moves);
      laps = [];
      turn(all, false);
      rest(all, true);
      moves = [
        all.front.animate(
          [
            { transform: from === 'none' ? transformOf(START) : from },
            { transform: transformOf(SETTLED) },
          ],
          { duration: SETTLE.duration, easing: easingOf(SETTLE) },
        ),
        all.check.animate(
          [
            { transform: 'scale(0.5)', opacity: 0 },
            { transform: 'scale(1)', opacity: 1 },
          ],
          { duration: POP.duration, easing: easingOf(POP), delay: 170, fill: 'backwards' },
        ),
        all.glow.animate([{ opacity: glowFrom }, { opacity: 0 }], {
          duration: 420,
          easing: 'ease-out',
        }),
      ];
    };

    /** The check goes, the ring shrinks back out to the upper right, and the laps start. */
    const leave = (all: Parts) => {
      const from = getComputedStyle(all.front).transform;
      const tick = getComputedStyle(all.check);
      const tickFrom = { transform: tick.transform, opacity: tick.opacity };
      const glowFrom = getComputedStyle(all.glow).opacity;
      stop(laps);
      stop(moves);
      laps = [];
      turn(all, true);
      rest(all, false);
      const out = all.front.animate(
        [
          { transform: from === 'none' ? transformOf(SETTLED) : from },
          { transform: transformOf(START) },
        ],
        LEAVE,
      );
      moves = [
        out,
        all.check.animate([tickFrom, { transform: 'scale(0.6)', opacity: 0 }], {
          duration: 160,
          easing: 'ease-in',
        }),
        all.glow.animate([{ opacity: glowFrom }, { opacity: GLOW_START }], LEAVE),
      ];
      const mine = round;
      out.finished.then(
        () => {
          if (mine !== round) return;
          moves = [];
          startLaps(all);
        },
        // Cancelled by the next show.
        () => {},
      );
    };

    show.current = (next) => {
      const all = parts();
      if (!all) return;
      round += 1;
      if (held.current || reducedMotion()) {
        stop(laps);
        stop(moves);
        laps = [];
        moves = [];
        spin?.cancel();
        spin = null;
        turning = false;
        rest(all, !next);
      } else if (fresh) {
        rest(all, !next);
        if (next) startLaps(all);
      } else if (next) leave(all);
      else settle(all);
      fresh = false;
    };
    show.current(latest.current);

    // The laps and the turn pause off screen. A change to reduced motion sets the parts afresh.
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      laps.forEach((lap) => (onScreen ? lap.play() : lap.pause()));
      if (onScreen && turning) spin?.play();
      else spin?.pause();
    });
    if (loader.current) observer.observe(loader.current);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => {
      fresh = true;
      show.current?.(latest.current);
    };
    motion.addEventListener('change', readMotion);
    refresh.current = readMotion;

    return () => {
      refresh.current = null;
      observer.disconnect();
      motion.removeEventListener('change', readMotion);
      round += 1;
      stop(laps);
      stop(moves);
      spin?.cancel();
      show.current = null;
    };
  }, []);

  useEffect(() => {
    if (latest.current === loading) return;
    latest.current = loading;
    show.current?.(loading);
  }, [loading]);

  useEffect(() => {
    if (held.current === still) return;
    held.current = still;
    refresh.current?.();
  }, [still]);

  return { loader, front, back, dashes, check, glow };
}
```

### orbit-loader/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once, and the same curve
 * serves CSS transitions and the Web Animations API.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}

/** The spring's easing for `element.animate()`, or an ease-out where `linear()` is unknown. */
export function easingOf({ easing }: Spring) {
  return CSS.supports('transition-timing-function', easing)
    ? easing
    : 'cubic-bezier(0.2, 0.8, 0.2, 1)';
}
```

### orbit-loader/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer onto a button and press it, calling the component's own handler as a hand
 * would. 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.
 *
 * Nothing the pointer does needs a layout: it moves by animations the compositor plays, and the
 * press scales a box that keeps a transform at rest (a box that gains or loses one is laid out
 * again).
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`, a little below and right of its middle. */
  moveTo: (target: Element | null | undefined) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<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: the point the pointer acts on. */
const TIP: Point = { x: 3, y: 2 };

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

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.82, y: stage.offsetHeight * 0.92 };

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

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

  /** A point of the screen in the stage's own px (the stage may sit in a scaled container). */
  const toStage = (point: Point): Point => {
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    return { x: (point.x - frame.left) / scale, y: (point.y - frame.top) / scale };
  };

  const moveTo: Demo['moveTo'] = (target) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    const to = toStage({ x: box.left + box.width * 0.56, y: box.top + box.height * 0.62 });
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

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

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

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.35))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses, on a layer of its own. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x * 1.1}px ${TIP.y * 1.1}px`,
  willChange: 'transform',
};

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/** The arrow the demo moves around. It is decorative: the demo's effects show on the stage. */
export function DemoPointer({ ref, className }: { ref: Ref<HTMLSpanElement>; className?: string }) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            fill="#1c1c1e"
            stroke="#fff"
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
    </span>
  );
}
```

### orbit-loader/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 };
}
```