# Plan orbs

> A pricing plan picker where each plan carries a glass orb: the chosen one fills with swirling colour as the thumb slides under it.

Three plans sit in a recessed track, each with a small glass marble beside its name. Choose one and a raised thumb slides under it on a spring, and its orb fills with slowly swirling colour as the thumb arrives, while the others stay frosted; every highlight leans towards the pointer. A switch bills monthly or yearly, and the price rolls to its new value digit by digit.

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

Category: Inputs.

## Anatomy

1. **Track**: The recessed well holding the plans: a real `role="radiogroup"` of native radios, so the arrow keys move and select.
2. **Thumb**: The raised piece under the chosen plan. It slides to it on a spring with a little bounce, and at once under reduced motion.
3. **Plan pill**: A label wrapping its radio, orb, name and caption. The radio is heard as “Pro, Most popular”.
4. **Orb**: A glass marble in layers of CSS gradients. Its highlight leans towards the pointer; the chosen one fills with the plan’s colour.
5. **Tag**: An optional word on the pill’s top edge, from the plan’s `tag`. It is decorative: the radio’s label already says it.
6. **Billing switch**: A `role="switch"` labelled “Bill yearly”, between the Monthly and Yearly words, which can be clicked too.
7. **Price**: The month’s price, rolling digit by digit to its new value like an odometer. Screen readers get it as text.
8. **Continue button**: Names the chosen plan and calls `onContinue`; its arrow turns into a check for a moment.

## When to use

- Choosing one of two to four pricing plans at checkout or on an upgrade screen, where the choice deserves a moment of delight.
- Plans that differ by a single price and a short caption, so the whole decision fits in one card.
- Offering monthly or yearly billing on the same screen: the switch shows the saving and the price rolls to it.
- Onboarding and upgrade flows seen once or twice, where a living, colourful pick makes the moment memorable.

## When not to use

- Comparing plans feature by feature. Use a pricing table with a row per feature, and put the choice under it.
- More than four plans: the pills get too narrow for a name and a caption. Use a list of radio cards.
- Settings or filters picked often in a dense form. A plain segmented control is quicker to read.
- Prices that need more than whole dollars, taxes or seats. Show the full total next to the button instead.
- Pages that must stay quiet and still: the orbs turn and glow. Use plain radio cards there.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/plan-orbs.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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
'use client';

import { PlanOrbs } from './plan-orbs';

export function Upgrade() {
  return (
    <PlanOrbs
      defaultPlan="pro"
      defaultYearly
      autoplay={false}
      onContinue={({ plan, yearly }) => console.log(plan.id, yearly ? 'yearly' : 'monthly')}
    />
  );
}
```

## States

- **First plan**: The thumb at the left; the chosen orb fills with teal, the others stay frosted.
- **Default**: The middle plan, as it opens with `defaultPlan="pro"`.
- **Tagged plan**: The plan with a `tag` chosen: its word sits on the pill’s top edge either way.
- **Billed yearly**: The switch on, “Save 20%” lit, a lower price and the year’s total under it.
- **Dark**: With `colorScheme="dark"`, or a dark page: every colour round the orbs is a light-dark() token.
- **Two plans**: A shorter `plans` list: the pills share the track and the thumb takes its share.

## How it works

- Each orb is a stack of CSS gradients, with no image and no blur filter: a contact shadow, a frosted ball, the coloured liquid, two pools of light, a caustic, a rim and a specular highlight. Unchosen, the liquid sinks to a faint tint at the bottom of the ball; chosen, it rises into it, the rim deepens into the colour and a glow drawn as a radial gradient lights up around it.
- The colour swirls because the two pools turn in opposite directions at different speeds, with CSS transform animations. They run only in the chosen orb and the one under the pointer, and only while the stage is on screen, so at rest it is compositor work alone.
- The light follows the pointer as if a lamp hung just above the screen at its tip. For each orb the component works out where the ball faces it and moves its highlight and caustic with transforms; the orbs are measured once as the pointer comes in, and a light is only written when it has moved a step.
- The plans are native radio buttons labelled by their pills. The raised thumb is one fixed-size layer slid by a translate on a spring sampled into a CSS linear() easing, and the new orb fills after a delay taken from the same spring, as the thumb arrives.
- The price is an odometer: each digit is a reel of 0 to 9 in a window one line tall, slid to its digit on a softer spring a beat after the digit to its left, so $20 rolls to $45 through every digit in between.
- The stage and the orbs’ glass are light-dark() tokens on the root, so it follows the page’s color-scheme, or the colorScheme prop; the orbs’ liquid, glow and highlight keep the plan’s colours, while the frosted ball becomes smoked glass at night.

## API reference

- **plans** (`readonly Plan[]`, default `PLANS`): The plans, in order (three fit the track best): an id, a name, a caption, dollars a month billed monthly and billed yearly, the orb’s three colours and an optional tag for the pill’s top edge.
- **defaultPlan** (`string`, default `'pro'`): The id of the plan chosen at first.
- **defaultYearly** (`boolean`, default `false`): Whether it starts billed yearly.
- **onChange** (`(choice: Choice) => void`): Called with the plan and the period whenever either changes.
- **onContinue** (`(choice: Choice) => void`): Called with the plan and the period when the reader presses Continue.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer moves between the plans and flips the period.
- **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 plans are a radiogroup of native radio buttons, each named by its plan, caption and tag (“Team, For groups, Best value”). Tab reaches the chosen plan, and the arrow keys move and select.
- The period is a switch, “Bill yearly”, described by “Save 20%”. The price reaches screen readers as text (“$16 a month”), not as rolling digits.
- Changes the reader makes are announced politely (“Pro, $16 a month, billed yearly.”), and so is Continue. The demo’s changes are not, and it stops at the reader’s first press, key or pointer move over the stage.
- Under reduced motion the colour does not swirl, the digits change at once, the thumb jumps to its plan, and the demo does not play.

## Source

### plan-orbs/plan-orbs.tsx

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

'use client';

import { useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties } from 'react';
import { GhostPointer, runDemo } from './ghost-pointer';
import { Odometer } from './odometer';
import { Orb } from './orb';
import { PLANS, priceOf, type Plan } from './plans';
import { spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './plan-orbs.module.css';

/*
 * Plan orbs: a pricing plan picker where each plan carries a small glass orb.
 *
 * - The plans are native radio buttons in a recessed track, so the arrow keys move and select. A
 *   raised thumb slides under the chosen plan on a spring, and the orb in it fills with colour as
 *   the thumb arrives; the other orbs are frosted, nearly colourless (orb.tsx).
 * - The orbs are layers of CSS gradients, and their light follows the pointer: the component
 *   reads where it is and sets two custom properties on each orb's highlight and caustic, which
 *   move on transforms. The colour turns slowly inside them, with transforms too, only while the
 *   stage is on screen, and only in the chosen orb and the one under the pointer.
 * - A switch bills monthly or yearly (20% less), and the price rolls to its new value digit by
 *   digit (odometer.tsx). The reader's changes are announced: "Pro, $16 a month, billed yearly."
 * - While on screen and untouched, a ghost pointer moves between the plans and flips the period.
 *   Under reduced motion nothing turns or rolls, and the thumb moves at once.
 * - Light or dark: every colour round the orbs' liquid is a light-dark() token in the CSS, so the
 *   scheme is the page's color-scheme, or the one the colorScheme prop sets.
 */

/** The thumb's slide, a spring with a little bounce; the digits roll on a softer one. */
const SLIDE = spring(0.22, 460);
const ROLL = spring(0.14, 560);

/** A plan, and how it is billed. */
export type Choice = { plan: Plan; yearly: boolean };

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

const spoken = ({ plan, yearly }: Choice) =>
  `${plan.name}, $${priceOf(plan, yearly)} a month, billed ${yearly ? 'yearly' : 'monthly'}.`;

export function PlanOrbs({
  plans = PLANS,
  defaultPlan = 'pro',
  defaultYearly = false,
  onChange,
  onContinue,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The plans, in order: three fit the track best. */
  plans?: readonly Plan[];
  /** The id of the plan chosen at first. */
  defaultPlan?: string;
  /** Whether it starts billed yearly. */
  defaultYearly?: boolean;
  /** Called when the plan or the period changes. */
  onChange?: (choice: Choice) => void;
  /** Called when the reader presses Continue. */
  onContinue?: (choice: Choice) => void;
  /** While on screen and untouched, a ghost pointer moves between plans and flips the period. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [index, setIndex] = useState(() => {
    const found = plans.findIndex((plan) => plan.id === defaultPlan);
    return found < 0 ? 0 : found;
  });
  const [yearly, setYearly] = useState(defaultYearly);
  /** The plan under the reader's pointer, and under the demo's: its orb stirs. */
  const [hover, setHover] = useState<number | null>(null);
  const [demoHover, setDemoHover] = useState<number | null>(null);
  /** On screen, with the tab visible: the colour may turn. */
  const [live, setLive] = useState(false);
  /** Continue was just pressed: its arrow shows a check for a moment. */
  const [confirmed, setConfirmed] = useState(false);
  const [message, setMessage] = useState('');
  const titleId = useId();
  const saveId = useId();
  const group = useId();

  const pills = useRef<(HTMLLabelElement | null)[]>([]);
  const orbs = useRef<(HTMLSpanElement | null)[]>([]);
  const toggle = useRef<HTMLButtonElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  /** Where the light should come from, on the screen; null for its place at rest. */
  const aim = useRef<Point | null>(null);
  /** The demo is aiming the light, not the reader. */
  const demoAiming = useRef(false);
  /** Each orb's centre, width and lit pieces, measured as the pointer comes in. */
  const centres = useRef<(Point & { size: number; lights: HTMLElement[] })[]>([]);
  const frame = useRef(0);
  const confirmTimer = useRef(0);

  const plan = plans[index] ?? plans[0];
  const price = priceOf(plan, yearly);
  const billing = yearly ? `$${price * 12} billed yearly` : 'Billed monthly';

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

  const choose = (next: number, byReader: boolean) => {
    if (next === index || !plans[next]) return;
    const choice = { plan: plans[next], yearly };
    setIndex(next);
    onChange?.(choice);
    if (byReader) announce(spoken(choice));
  };

  const bill = (nextYearly: boolean, byReader: boolean) => {
    if (nextYearly === yearly) return;
    const choice = { plan, yearly: nextYearly };
    setYearly(nextYearly);
    onChange?.(choice);
    if (byReader) announce(spoken(choice));
  };

  const proceed = () => {
    onContinue?.({ plan, yearly });
    announce(`Continuing with ${plan.name}, billed ${yearly ? 'yearly' : 'monthly'}.`);
    setConfirmed(true);
    window.clearTimeout(confirmTimer.current);
    confirmTimer.current = window.setTimeout(() => setConfirmed(false), 1600);
  };
  useEffect(() => () => window.clearTimeout(confirmTimer.current), []);

  /** Measures the orbs, once as the pointer comes in, so that no frame has to read the layout. */
  const measure = () => {
    centres.current = orbs.current.map((orb) => {
      const box = orb?.getBoundingClientRect();
      const lights = Array.from(orb?.querySelectorAll<HTMLElement>('[data-light]') ?? []);
      return box
        ? { x: box.left + box.width / 2, y: box.top + box.height / 2, size: box.width, lights }
        : { x: 0, y: 0, size: 0, lights };
    });
  };

  /**
   * The light, as if a lamp hung a little above the screen at the pointer: each orb's highlight
   * moves to where the ball faces it. Once per frame at most, and only when the pointer moves.
   */
  const shine = () => {
    frame.current = 0;
    const at = aim.current;
    for (const { x, y, size, lights } of centres.current) {
      // Without a pointer, the orb's own light (up and to the left). Otherwise in steps of
      // 0.05, which the highlight's transition smooths over: a light that has not moved a step
      // is left alone, and so is its transition.
      let lx = '';
      let ly = '';
      if (at) {
        const reach = Math.hypot(at.x - x, at.y - y, size * 2.2);
        lx = String(Math.round(((at.x - x) / reach) * 20) / 20);
        ly = String(Math.round(((at.y - y) / reach) * 20) / 20);
      }
      for (const light of lights) {
        if (light.style.getPropertyValue('--lx') !== lx) light.style.setProperty('--lx', lx);
        if (light.style.getPropertyValue('--ly') !== ly) light.style.setProperty('--ly', ly);
      }
    }
  };
  const aimAt = (point: Point | null, byDemo = false) => {
    aim.current = point;
    demoAiming.current = byDemo && !!point;
    if (point && !centres.current.length) measure();
    if (!frame.current) frame.current = requestAnimationFrame(shine);
  };
  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  // On screen, with the tab visible: the orbs' colour may turn. Off screen it stops.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setLive(onScreen && !document.hidden);
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
    };
  }, [root]);

  // The demo: the pointer visits a plan and chooses it, flips the period, and so on. Its light
  // follows the pointer, and nothing it does is announced.
  const demoAct = useEffectEvent((act: number | 'period') =>
    act === 'period' ? bill(!yearly, false) : choose(act, false),
  );
  const demoAim = useEffectEvent((point: Point | null) => {
    // Once the reader has the light, the demo leaves it to them.
    if (point || demoAiming.current) aimAt(point, true);
  });
  const demoMeasure = useEffectEvent(() => measure());
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!autoplay || !playing || !stage || !ghost) return;
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      /** Glides to `target`, at a share of its size, as the orbs' light follows. */
      const glide = async (target: Element | null, at: Point) => {
        if (!target) return;
        const box = target.getBoundingClientRect();
        demoMeasure();
        demoAim({ x: box.left + box.width * at.x, y: box.top + box.height * at.y });
        await moveTo(target, at);
      };
      const tour: (number | 'period')[] = [2, 'period', 0, 1, 'period'];
      await wait(900);
      for (let step = 0; ; step = (step + 1) % tour.length) {
        const act = tour[step];
        if (act === 'period') {
          setDemoHover(null);
          await glide(toggle.current, { x: 0.5, y: 0.5 });
          await wait(260);
        } else {
          await glide(pills.current[act], { x: 0.3, y: 0.55 });
          setDemoHover(act);
          await wait(420);
        }
        await press(() => demoAct(act));
        await wait(1500);
      }
    });
    return () => {
      stop();
      setDemoHover(null);
      demoAim(null);
    };
  }, [autoplay, playing, root]);

  const vars = {
    '--slide': SLIDE.easing,
    '--slide-ms': `${SLIDE.duration}ms`,
    '--roll': ROLL.easing,
    '--roll-ms': `${ROLL.duration}ms`,
    '--index': index,
    '--count': plans.length,
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      style={vars}
      data-color-scheme={colorScheme}
      data-live={live || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType === 'touch') return;
        aimAt({ x: event.clientX, y: event.clientY });
        // A mouse over the stage pauses the demo: the light is the reader's.
        if (event.pointerType === 'mouse') take();
      }}
      onPointerEnter={(event) => {
        if (event.pointerType !== 'touch') measure();
      }}
      onPointerDown={(event) => {
        if (event.pointerType !== 'touch') return;
        measure();
        aimAt({ x: event.clientX, y: event.clientY });
      }}
      onPointerUp={(event) => {
        if (event.pointerType === 'touch') aimAt(null);
      }}
      onPointerCancel={() => aimAt(null)}
      onPointerLeave={() => aimAt(null)}
    >
      <div className={styles.scene}>
        <div className={styles.card}>
          <h3 id={titleId} className={styles.title}>
            Choose your plan
          </h3>
          <p className={styles.text}>Start small, and switch whenever you like.</p>

          <div
            className={styles.track}
            data-part="track"
            role="radiogroup"
            aria-labelledby={titleId}
          >
            <span className={styles.thumb} data-part="thumb" aria-hidden="true" />
            {plans.map((option, i) => (
              <label
                key={option.id}
                ref={(element) => {
                  pills.current[i] = element;
                }}
                className={styles.pill}
                data-part="pill"
                data-checked={i === index || undefined}
                onPointerEnter={(event) => {
                  if (event.pointerType !== 'touch') setHover(i);
                }}
                onPointerLeave={() => setHover((now) => (now === i ? null : now))}
              >
                <input
                  className={styles.radio}
                  type="radio"
                  name={group}
                  value={option.id}
                  checked={i === index}
                  aria-label={[option.name, option.caption, option.tag].filter(Boolean).join(', ')}
                  onChange={() => choose(i, true)}
                />
                <Orb
                  ref={(element) => {
                    orbs.current[i] = element;
                  }}
                  colors={option.colors}
                  lit={i === index}
                  stir={(hover === i || demoHover === i) && i !== index}
                />
                <span className={styles.copy}>
                  <span className={styles.name}>{option.name}</span>
                  <span className={styles.caption}>{option.caption}</span>
                </span>
                {option.tag && (
                  <span className={styles.tag} data-part="tag" aria-hidden="true">
                    {option.tag}
                  </span>
                )}
              </label>
            ))}
          </div>

          <div className={styles.period} data-part="period">
            <span
              className={styles.option}
              data-on={!yearly || undefined}
              aria-hidden="true"
              onClick={() => bill(false, true)}
            >
              Monthly
            </span>
            <button
              ref={toggle}
              type="button"
              role="switch"
              className={styles.switch}
              data-part="switch"
              aria-checked={yearly}
              aria-label="Bill yearly"
              aria-describedby={saveId}
              onClick={() => bill(!yearly, true)}
            >
              <span className={styles.knob} />
            </button>
            <span
              className={styles.option}
              data-on={yearly || undefined}
              aria-hidden="true"
              onClick={() => bill(true, true)}
            >
              Yearly
            </span>
            <span
              id={saveId}
              className={styles.save}
              data-part="save"
              data-on={yearly || undefined}
            >
              Save 20%
            </span>
          </div>

          <div className={styles.footer}>
            <div className={styles.cost}>
              <p className={styles.price} data-part="price">
                <span className={styles.figure} aria-hidden="true">
                  <span className={styles.currency}>$</span>
                  <Odometer value={price} />
                </span>
                <span className={styles.per} aria-hidden="true">
                  / month
                </span>
                <span className={styles.hidden}>${price} a month</span>
              </p>
              <p key={billing} className={styles.billing}>
                {billing}
              </p>
            </div>
            <button
              type="button"
              className={styles.continue}
              data-part="continue"
              data-confirmed={confirmed || undefined}
              onClick={proceed}
            >
              Continue with {plan.name}
              <span className={styles.icons} aria-hidden="true">
                <svg viewBox="0 0 20 20" className={styles.arrow}>
                  <path d="M4.5 10h10.5M11 5.75 15.25 10 11 14.25" />
                </svg>
                <svg viewBox="0 0 20 20" className={styles.check}>
                  <path d="m4.75 10.5 3.5 3.5 7-7.5" />
                </svg>
              </span>
            </button>
          </div>
        </div>
      </div>

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

### plan-orbs/plan-orbs.module.css

```css
/* Plan orbs (plan-orbs.tsx; the orbs and the price have stylesheets of their own). Sizes are in
   em: the stage sets the font-size from its own width (a container query), up to 16px. --index
   and --count place the thumb; --slide and --roll, springs as linear() easings, and their
   lengths come from the component. The stage passes --arrive (when the thumb gets there) and
   --orb-play (whether the orbs' colour may turn) down to the orbs.

   Colours are the tokens below, light, then light-dark(light, dark) pairs: the component takes the
   color-scheme it inherits from the page (light unless the page says otherwise), or the one its
   colorScheme prop sets (data-color-scheme). The pairs sit in @supports, which a build that
   rewrites light-dark() for older browsers leaves as written; without light-dark(), the light
   colours stay. The orbs have tokens of their own (orb.module.css). */

.root {
  /* The dotted stage and its edge; the card on it, the lift under the card, and the line across
     the card's foot. */
  --stage: #e6e6e9;
  --dots: rgb(0 0 0 / 0.05);
  --stage-edge: rgb(0 0 0 / 0.06);
  --surface: #fff;
  --edge: rgb(0 0 0 / 0.04);
  --shadow-near: rgb(0 0 0 / 0.05);
  --shadow-far: rgb(30 30 40 / 0.28);
  --shadow:
    0 0 0 1px var(--edge), 0 1px 2px var(--shadow-near), 0 1.4em 2.8em -1.4em var(--shadow-far);
  --line: #ececef;
  /* The recessed track and its inner shade and edge; a pill under the pointer. */
  --well: #ebebef;
  --well-shade: rgb(0 0 0 / 0.08);
  --well-edge: rgb(0 0 0 / 0.035);
  --hover: rgb(255 255 255 / 0.45);
  /* The thumb raised on the track (top to bottom: white by day, a step above the card at night),
     its edge, and its lift. */
  --surface-2: #fff;
  --surface-2-foot: #fcfcfd;
  --thumb-edge: rgb(0 0 0 / 0.06);
  --thumb-near: rgb(0 0 0 / 0.08);
  --thumb-far: rgb(20 20 40 / 0.3);
  /* The words: the title, the price and a chosen plan; a plan not chosen; captions and the
     billing line; a caption not chosen, and the period not on. */
  --ink: #1c1c1f;
  --ink-soft: #55555d;
  --ink-muted: #72727a;
  --ink-faint: #a0a0a8;
  /* Continue, and the tag on a pill: dark by day, light at night; Continue under the pointer,
     its lift and its top light. */
  --accent: #1c1c1f;
  --accent-ink: #fff;
  --accent-hover: #34343a;
  --accent-shadow: rgb(0 0 0 / 0.2);
  --accent-gloss: rgb(255 255 255 / 0.12);
  /* The switch: off, its inner shade, on; the knob (white on both) and its lift. */
  --switch: #d6d6dc;
  --switch-shade: rgb(0 0 0 / 0.12);
  --green: #1d8549;
  --knob: #fff;
  --knob-near: rgb(0 0 0 / 0.25);
  --knob-far: rgb(0 0 0 / 0.2);
  /* Save 20%: its words and edge, and its fill once yearly is on. */
  --green-ink: #1d8549;
  --green-edge: rgb(29 133 73 / 0.3);
  --green-tint: #e0f3e7;
  --focus: #0a84ff;
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --arrive: calc(var(--slide-ms) * 0.22);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--stage);
  box-shadow: inset 0 0 0 1px var(--stage-edge);
  color: var(--ink);
  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)) {
  .root {
    --stage: light-dark(#e6e6e9, #0e0e10);
    --dots: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --stage-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --surface: light-dark(#fff, #1c1c1f);
    --edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.07));
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --shadow-far: light-dark(rgb(30 30 40 / 0.28), rgb(0 0 0 / 0.6));
    --line: light-dark(#ececef, #2a2a2f);
    --well: light-dark(#ebebef, #131316);
    --well-shade: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.45));
    --well-edge: light-dark(rgb(0 0 0 / 0.035), rgb(255 255 255 / 0.04));
    --hover: light-dark(rgb(255 255 255 / 0.45), rgb(255 255 255 / 0.04));
    --surface-2: light-dark(#fff, #2c2c31);
    --surface-2-foot: light-dark(#fcfcfd, #28282d);
    --thumb-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --thumb-near: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.35));
    --thumb-far: light-dark(rgb(20 20 40 / 0.3), rgb(0 0 0 / 0.55));
    --ink: light-dark(#1c1c1f, #ececef);
    --ink-soft: light-dark(#55555d, #b0b0b8);
    --ink-muted: light-dark(#72727a, #9d9da6);
    --ink-faint: light-dark(#a0a0a8, #85858e);
    --accent: light-dark(#1c1c1f, #ececef);
    --accent-ink: light-dark(#fff, #1c1c1f);
    --accent-hover: light-dark(#34343a, #d4d4d9);
    --accent-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.5));
    --accent-gloss: light-dark(rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.5));
    --switch: light-dark(#d6d6dc, #3b3b41);
    --switch-shade: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.35));
    --green: light-dark(#1d8549, #22924f);
    --knob-near: light-dark(rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.5));
    --knob-far: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.4));
    --green-ink: light-dark(#1d8549, #4cc27a);
    --green-edge: light-dark(rgb(29 133 73 / 0.3), rgb(76 194 122 / 0.35));
    --green-tint: light-dark(#e0f3e7, #163522);
    --focus: light-dark(#0a84ff, #3594ff);
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root[data-live] {
  --orb-play: running;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
/* The buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}

.scene {
  display: grid;
  place-items: center;
  padding: 2.5em 1em;
  /* The card and the stage's padding: 35.5em, in the stage's width. */
  font-size: clamp(13px, 100cqi / 35.5, 16px);
  line-height: 1.3;
}

.card {
  width: min(33em, 100%);
  padding: 1.5em 1.5em 1.4em;
  border-radius: 1.6em;
  background: var(--surface);
  box-shadow: var(--shadow);
}
.title {
  margin: 0;
  font-size: 1.15em;
  font-weight: 650;
  letter-spacing: -0.02em;
}
.text {
  margin: 0.25em 0 0;
  font-size: 0.86em;
  color: var(--ink-muted);
}

/* The plans: a recessed track, and the raised thumb that slides under the chosen one. */
.track {
  --pad: 0.3em;
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--count), minmax(0, 1fr));
  margin-top: 1.35em;
  padding: var(--pad);
  border-radius: 1.3em;
  background: var(--well);
  box-shadow:
    inset 0 1px 2px var(--well-shade),
    inset 0 0 0 1px var(--well-edge);
}
.thumb {
  position: absolute;
  top: var(--pad);
  bottom: var(--pad);
  left: var(--pad);
  width: calc((100% - 2 * var(--pad)) / var(--count));
  border-radius: 1em;
  background: linear-gradient(var(--surface-2), var(--surface-2-foot));
  box-shadow:
    0 0 0 0.5px var(--thumb-edge),
    0 1px 2px var(--thumb-near),
    0 0.55em 1.1em -0.45em var(--thumb-far);
  translate: calc(var(--index) * 100%) 0;
  transition: translate var(--slide-ms) var(--slide);
}

.pill {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.7em;
  min-width: 0;
  padding: 0.85em 0.75em;
  border-radius: 1em;
  cursor: pointer;
  transition: background-color 0.25s ease;
  -webkit-user-select: none;
  user-select: none;
}
@media (hover: hover) {
  .pill:not([data-checked]):hover {
    background: var(--hover);
  }
}
/* The radio itself is invisible: the pill is its label, and shows its focus. */
.radio {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}
.pill:has(.radio:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
.copy {
  display: grid;
  min-width: 0;
}
.name {
  font-size: 0.95em;
  font-weight: 650;
  letter-spacing: -0.01em;
  color: var(--ink-soft);
  transition: color 0.3s ease;
}
.caption {
  overflow: hidden;
  font-size: 0.74em;
  color: var(--ink-faint);
  white-space: nowrap;
  text-overflow: ellipsis;
  transition: color 0.3s ease;
}
.pill[data-checked] .name {
  color: var(--ink);
}
.pill[data-checked] .caption {
  color: var(--ink-muted);
}
/* A word on the pill's top edge. */
.tag {
  position: absolute;
  top: 0;
  right: 0.6em;
  padding: 0.25em 0.6em;
  border-radius: 999px;
  background: var(--accent);
  box-shadow: 0 0 0 0.2em var(--well);
  font-size: 0.6em;
  font-weight: 650;
  letter-spacing: 0.06em;
  line-height: 1.2;
  text-transform: uppercase;
  color: var(--accent-ink);
  white-space: nowrap;
  translate: 0 -70%;
}

/* The billing period: Monthly, the switch, Yearly, and what yearly saves. */
.period {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.55em;
  margin-top: 1.25em;
  font-size: 0.88em;
}
.option {
  font-weight: 550;
  color: var(--ink-faint);
  cursor: pointer;
  transition: color 0.25s ease;
  -webkit-user-select: none;
  user-select: none;
}
.option[data-on] {
  color: var(--ink);
}
.switch {
  position: relative;
  flex: none;
  width: 2.6em;
  height: 1.5em;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: var(--switch);
  box-shadow: inset 0 1px 2px var(--switch-shade);
  cursor: pointer;
  transition: background-color 0.3s ease;
}
.switch[aria-checked='true'] {
  background: var(--green);
}
.switch:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.knob {
  position: absolute;
  top: 0.18em;
  left: 0.18em;
  width: 1.14em;
  height: 1.14em;
  border-radius: 50%;
  background: var(--knob);
  box-shadow:
    0 1px 2px var(--knob-near),
    0 0.2em 0.4em -0.1em var(--knob-far);
  transition: translate var(--slide-ms) var(--slide);
}
.switch[aria-checked='true'] .knob {
  translate: 1.1em 0;
}
.save {
  padding: 0.2em 0.6em;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--green-edge);
  font-size: 0.86em;
  font-weight: 650;
  color: var(--green-ink);
  transition:
    background-color 0.3s ease,
    box-shadow 0.3s ease;
}
.save[data-on] {
  background: var(--green-tint);
  box-shadow: inset 0 0 0 1px transparent;
}

/* The price, and Continue. */
.footer {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1em;
  margin-top: 1.25em;
  padding-top: 1.2em;
  border-top: 1px solid var(--line);
}
.cost {
  min-width: 0;
}
.price {
  display: flex;
  align-items: baseline;
  gap: 0.35em;
  margin: 0;
}
.figure {
  display: inline-flex;
  align-items: baseline;
  font-size: 2.6em;
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1;
}
.currency {
  align-self: flex-start;
  margin: 0.2em 0.04em 0 0;
  font-size: 0.55em;
  font-weight: 600;
}
.per {
  font-size: 0.9em;
  color: var(--ink-muted);
}
/* A new line of billing slides in. */
.billing {
  margin: 0.25em 0 0;
  font-size: 0.8em;
  color: var(--ink-muted);
  animation: arrive 0.4s var(--ease-out);
}
@keyframes arrive {
  from {
    opacity: 0;
    translate: 0 0.35em;
  }
}
.continue {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 0.5em;
  height: 2.9em;
  padding: 0 1.05em 0 1.25em;
  border: 0;
  border-radius: 0.95em;
  background: var(--accent);
  box-shadow:
    0 1px 2px var(--accent-shadow),
    inset 0 1px 0 var(--accent-gloss);
  font-size: 0.92em;
  font-weight: 600;
  color: var(--accent-ink);
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    scale 0.2s var(--ease-out);
}
@media (hover: hover) {
  .continue:hover {
    background: var(--accent-hover);
  }
  .continue:hover .arrow {
    translate: 0.15em 0;
  }
}
.continue:active {
  scale: 0.97;
}
.continue:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.icons {
  display: grid;
}
.icons svg {
  grid-area: 1 / 1;
  width: 1.15em;
  height: 1.15em;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition:
    opacity 0.2s ease,
    translate 0.3s var(--ease-out),
    scale 0.4s var(--ease-out);
}
.check {
  opacity: 0;
  scale: 0.5;
}
.continue[data-confirmed] .arrow {
  opacity: 0;
  translate: 0.4em 0;
}
.continue[data-confirmed] .check {
  opacity: 1;
  scale: 1;
}

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

/* Narrow: the orb above each plan's name, and Continue under the price. */
@container (max-width: 470px) {
  .scene {
    padding: 1.5em 0.75em;
  }
  .card {
    padding: 1.25em 1em 1.1em;
  }
  .pill {
    flex-direction: column;
    gap: 0.5em;
    padding: 0.95em 0.3em 0.75em;
    text-align: center;
  }
  .caption {
    white-space: normal;
  }
  .tag {
    right: 50%;
    translate: 50% -70%;
  }
  .footer {
    flex-direction: column;
    align-items: stretch;
  }
  .continue {
    justify-content: center;
  }
}

/* Final states at once. */
@media (prefers-reduced-motion: reduce) {
  .billing {
    animation: none;
  }
  .root *,
  .root *::before,
  .root *::after {
    transition: none !important;
  }
}
```

### plan-orbs/orb.tsx

```tsx
import type { CSSProperties, Ref } from 'react';
import styles from './orb.module.css';

/*
 * A glass marble, in layers of CSS gradients (orb.module.css): a contact shadow, a frosted ball,
 * the coloured liquid inside it with two pools of light turning in opposite directions, a soft
 * caustic where light gathers on the far side, a rim, and a specular highlight.
 *
 * - `lit` fills the ball with its colour (the liquid rises into it) and lights a glow round it;
 *   `stir` gives a hint of colour and sets the liquid turning, for a pill under the pointer.
 * - Where the light comes from is two custom properties, --lx and --ly, from -1 to 1 (up and to
 *   the left at rest). The component sets them from the pointer on the two pieces marked
 *   data-light, so that nothing else restyles: the highlight leans towards the pointer and the
 *   caustic away from it, on transforms.
 * - The pools turn with transforms, only while the stage sets --orb-play to running (when it is
 *   on screen), and only in a ball that is lit or stirred.
 */
export function Orb({
  colors,
  lit,
  stir,
  ref,
}: {
  /** Deep, middle and light: the liquid's colours. */
  colors: readonly [string, string, string];
  lit: boolean;
  stir: boolean;
  ref?: Ref<HTMLSpanElement>;
}) {
  const [deep, middle, light] = colors;
  return (
    <span
      ref={ref}
      className={styles.orb}
      data-part="orb"
      style={{ '--c1': deep, '--c2': middle, '--c3': light } as CSSProperties}
      data-lit={lit || undefined}
      data-stir={stir || undefined}
      aria-hidden="true"
    >
      <span className={styles.glow} />
      <span className={styles.contact} />
      <span className={styles.ball}>
        <span className={styles.liquid} />
        <span className={styles.pool} />
        <span className={styles.eddy} />
        <span className={styles.caustic} data-light="" />
        <span className={styles.highlight} data-light="" />
      </span>
    </span>
  );
}
```

### plan-orbs/orb.module.css

```css
/* The orb (orb.tsx): a marble --size wide whose liquid has the colours --c1 (deep) to --c3
   (light). The light comes from (--lx, --ly), set by the component as the pointer moves.
   --arrive delays the fill until the plan's thumb is there, and --orb-play (running or paused)
   lets the pools turn while the stage is on screen. Transforms and opacity do the moving; only
   the rim's colour changes otherwise, once, as a plan is chosen.

   The liquid's colours are the plan's, in both schemes. The glass round them follows the
   color-scheme with light-dark(light, dark) pairs, in @supports as on the stage: pale frosted
   glass by day, smoked glass at night, with a softer rim and a deeper contact shadow. */

.orb {
  /* The ball, from where the light falls to its far edge, and the hairline round it. */
  --glass-1: #fff;
  --glass-2: #f5f5f8;
  --glass-3: #e1e1e8;
  --glass-4: #cdcdd7;
  --glass-edge: rgb(20 20 40 / 0.12);
  /* The rim: its ring of light, its shaded foot and its lit top. */
  --rim: rgb(255 255 255 / 0.5);
  --rim-shade: rgb(15 15 35 / 0.24);
  --rim-light: rgb(255 255 255 / 0.85);
  /* Where the ball meets the surface. */
  --contact: rgb(25 25 40 / 0.3);
  --size: 2.5em;
  --lx: -0.42;
  --ly: -0.52;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  display: block;
  flex: none;
  width: var(--size);
  height: var(--size);
}
@supports (color: light-dark(#fff, #000)) {
  .orb {
    --glass-1: light-dark(#fff, #8e8e99);
    --glass-2: light-dark(#f5f5f8, #5f5f6a);
    --glass-3: light-dark(#e1e1e8, #3b3b44);
    --glass-4: light-dark(#cdcdd7, #2a2a31);
    --glass-edge: light-dark(rgb(20 20 40 / 0.12), rgb(255 255 255 / 0.1));
    --rim: light-dark(rgb(255 255 255 / 0.5), rgb(255 255 255 / 0.2));
    --rim-shade: light-dark(rgb(15 15 35 / 0.24), rgb(0 0 0 / 0.5));
    --rim-light: light-dark(rgb(255 255 255 / 0.85), rgb(255 255 255 / 0.35));
    --contact: light-dark(rgb(25 25 40 / 0.3), rgb(0 0 0 / 0.6));
  }
}
.glow,
.contact,
.ball,
.ball::after,
.liquid,
.pool,
.eddy,
.caustic,
.highlight {
  position: absolute;
  border-radius: 50%;
  pointer-events: none;
}

/* A soft glow of the plan's colour round a lit orb: a radial gradient, no blur. */
.glow {
  inset: -62%;
  background: radial-gradient(
    closest-side,
    color-mix(in srgb, var(--c2) 36%, transparent),
    color-mix(in srgb, var(--c2) 10%, transparent) 58%,
    transparent
  );
  opacity: 0;
  scale: 0.5;
  transition:
    opacity 0.3s ease,
    scale 0.4s ease;
}
.orb[data-lit] .glow {
  opacity: 1;
  scale: 1;
  transition:
    opacity 0.6s ease var(--arrive, 0s),
    scale 0.9s var(--ease-out) var(--arrive, 0s);
}

/* Where the ball meets the surface. */
.contact {
  right: 12%;
  bottom: -9%;
  left: 12%;
  height: 24%;
  background: radial-gradient(closest-side, var(--contact), transparent);
}

/* The ball: frosted glass, pale (smoked at night), with a rim of light. */
.ball {
  inset: 0;
  overflow: hidden;
  background: radial-gradient(
    circle at 34% 26%,
    var(--glass-1) 0,
    var(--glass-2) 28%,
    var(--glass-3) 68%,
    var(--glass-4) 100%
  );
  box-shadow: 0 0 0 0.5px var(--glass-edge);
}
.ball::after {
  content: '';
  inset: 0;
  box-shadow:
    inset 0 0 0 1px var(--rim),
    inset 0 -0.2em 0.32em -0.08em var(--rim-shade),
    inset 0 0.12em 0.22em -0.04em var(--rim-light);
  transition: box-shadow 0.5s ease;
}
/* Lit, the edge deepens into the colour, as glass does where it is seen at a slant. */
.orb[data-lit] .ball::after {
  box-shadow:
    inset 0 0 0 1px rgb(255 255 255 / 0.35),
    inset 0 -0.24em 0.4em -0.08em color-mix(in srgb, var(--c1) 60%, #000),
    inset 0 0.14em 0.26em -0.04em rgb(255 255 255 / 0.7);
  transition: box-shadow 0.6s ease var(--arrive, 0s);
}

/* The colour. Taller than the ball and fading out at its top, it rises into the ball when the
   plan is chosen and sinks back to a tint at the bottom when it is not. */
.liquid {
  top: -55%;
  right: 0;
  left: 0;
  height: 155%;
  border-radius: 0;
  background: radial-gradient(
    circle at 50% 74%,
    color-mix(in srgb, var(--c2) 80%, #fff) 0,
    var(--c2) 22%,
    var(--c1) 58%,
    color-mix(in srgb, var(--c1) 70%, #000) 100%
  );
  -webkit-mask-image: linear-gradient(transparent, #000 34%);
  mask-image: linear-gradient(transparent, #000 34%);
  opacity: 0.18;
  translate: 0 62%;
  transition:
    translate 0.4s ease,
    opacity 0.3s ease;
}
.orb[data-stir] .liquid {
  opacity: 0.34;
  translate: 0 40%;
}
.orb[data-lit] .liquid {
  opacity: 1;
  translate: none;
  transition:
    translate 1s var(--ease-out) var(--arrive, 0s),
    opacity 0.5s ease var(--arrive, 0s);
}

/* Two pools of light in the liquid, turning slowly in opposite directions: they drift past each
   other, so the colour seems to swirl. */
.pool,
.eddy {
  inset: 0;
  opacity: 0;
  transition: opacity 0.4s ease;
  animation: turn 9s linear infinite paused;
}
.pool {
  background:
    radial-gradient(
      circle at 30% 34%,
      color-mix(in srgb, var(--c3) 90%, transparent) 0,
      transparent 40%
    ),
    radial-gradient(
      circle at 72% 70%,
      color-mix(in srgb, var(--c1) 80%, transparent) 0,
      transparent 44%
    );
}
.eddy {
  background:
    radial-gradient(circle at 68% 30%, color-mix(in srgb, var(--c3) 70%, #fff) 0, transparent 32%),
    radial-gradient(
      circle at 26% 72%,
      color-mix(in srgb, var(--c2) 85%, transparent) 0,
      transparent 42%
    );
  animation-duration: 14s;
  animation-direction: reverse;
}
.orb:is([data-lit], [data-stir]) :is(.pool, .eddy) {
  animation-play-state: var(--orb-play, paused);
}
.orb[data-stir] :is(.pool, .eddy) {
  opacity: 0.35;
}
.orb[data-lit] :is(.pool, .eddy) {
  opacity: 1;
  transition: opacity 0.6s ease var(--arrive, 0s);
}
@keyframes turn {
  to {
    transform: rotate(1turn);
  }
}

/* Light gathered on the far side of the ball, away from the highlight. */
.caustic {
  top: 24%;
  left: 24%;
  width: 52%;
  height: 52%;
  background: radial-gradient(closest-side, color-mix(in srgb, var(--c3) 50%, #fff), transparent);
  opacity: 0.35;
  translate: calc(var(--lx) * var(--size) * -0.34) calc(var(--ly) * var(--size) * -0.34);
  transition:
    translate 0.5s var(--ease-out),
    opacity 0.4s ease;
}
.orb[data-lit] .caustic {
  opacity: 0.8;
}

/* The specular highlight, on the side the light comes from, with a sharp glint at its heart. */
.highlight {
  top: 32%;
  left: 24%;
  width: 52%;
  height: 36%;
  background: radial-gradient(
    closest-side,
    rgb(255 255 255 / 0.92),
    rgb(255 255 255 / 0.5) 38%,
    rgb(255 255 255 / 0)
  );
  translate: calc(var(--lx) * var(--size) * 0.4) calc(var(--ly) * var(--size) * 0.4);
  rotate: -30deg;
  transition: translate 0.45s var(--ease-out);
}
.highlight::after {
  content: '';
  position: absolute;
  top: 32%;
  left: 36%;
  width: 22%;
  height: 28%;
  border-radius: 50%;
  background: #fff;
}

@media (prefers-reduced-motion: reduce) {
  .pool,
  .eddy {
    animation: none;
  }
  .orb *,
  .orb *::after {
    transition: none !important;
  }
}
```

### plan-orbs/odometer.tsx

```tsx
import type { CSSProperties } from 'react';
import styles from './odometer.module.css';

/*
 * A whole number that rolls to its new value like an odometer. Each digit is a reel of 0 to 9 in
 * a window one line tall; a CSS transition slides the reel to its digit, on a spring, a beat
 * after the digit to its left. Digits are keyed by their place (units, tens...), so a units
 * reel stays a units reel when the number gets longer. It is decorative: the caller gives screen
 * readers the number as text.
 */

const DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];

export function Odometer({ value }: { value: number }) {
  const digits = String(Math.max(0, Math.round(value))).split('');
  return (
    <span className={styles.odometer} aria-hidden="true">
      {digits.map((digit, i) => (
        <span
          key={digits.length - i}
          className={styles.window}
          style={{ '--digit': digit, '--order': i } as CSSProperties}
        >
          <span className={styles.sizer}>0</span>
          <span className={styles.reel}>
            {DIGITS.map((glyph) => (
              <span key={glyph}>{glyph}</span>
            ))}
          </span>
        </span>
      ))}
    </span>
  );
}
```

### plan-orbs/odometer.module.css

```css
/* The odometer (odometer.tsx). It takes its size from its parent's font; --roll and --roll-ms
   (the spring the reels slide on) come from the component. Each window is one line tall, with
   soft top and bottom edges, and shows the reel's line --digit. */

.odometer {
  --line: 1.2em;
  display: inline-flex;
  font-variant-numeric: tabular-nums;
  line-height: var(--line);
}
.window {
  position: relative;
  display: inline-block;
  height: var(--line);
  overflow: hidden;
  overflow: clip;
  -webkit-mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
  mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
}
/* A digit's room, so the window is as wide as a digit. */
.sizer {
  visibility: hidden;
}
.reel {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  translate: 0 calc(var(--digit) * -1 * var(--line));
  transition: translate var(--roll-ms, 0.7s) var(--roll, ease) calc(var(--order) * 70ms);
}
.reel > span {
  height: var(--line);
}

@media (prefers-reduced-motion: reduce) {
  .reel {
    transition: none;
  }
}
```

### plan-orbs/plans.ts

```tsx
/*
 * The plans to choose from: made up, with their prices in whole dollars a month and the three
 * colours of their orb (deep, middle, light). Billed yearly, a month costs 20% less.
 */

export type Plan = {
  id: string;
  name: string;
  /** One or two words under the name. */
  caption: string;
  /** Dollars a month, billed monthly... */
  monthly: number;
  /** ...and billed yearly. */
  yearly: number;
  /** The orb's colours, from deep to light. */
  colors: readonly [string, string, string];
  /** A word on the pill's top edge. */
  tag?: string;
};

export const PLANS: Plan[] = [
  {
    id: 'basic',
    name: 'Basic',
    caption: 'Personal',
    monthly: 15,
    yearly: 12,
    colors: ['#0b8f86', '#2fcfae', '#b5f5de'],
  },
  {
    id: 'pro',
    name: 'Pro',
    caption: 'Most popular',
    monthly: 20,
    yearly: 16,
    colors: ['#4a32e0', '#9468ff', '#f3b2ff'],
  },
  {
    id: 'team',
    name: 'Team',
    caption: 'For groups',
    monthly: 45,
    yearly: 36,
    colors: ['#e2482c', '#ff9838', '#ffe07a'],
    tag: 'Best value',
  },
];

/** What a month costs on `plan`, billed yearly or monthly. */
export const priceOf = (plan: Plan, yearly: boolean) => (yearly ? plan.yearly : plan.monthly);
```

### plan-orbs/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)';
}
```

### plan-orbs/ghost-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a control, press it and call the component's own handler, as a
 * click 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.
 */

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => 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 {}

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

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

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

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  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.8, y: stage.offsetHeight * 0.9 };

  /** 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);
      }),
    );

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    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(360, 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) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { 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';
  };
}

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

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

### plan-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 };
}
```