# Scrub slider

> A compact number control from a design tool’s panel: drag anywhere on the row to scrub, click the value to type.

A slider that fits in a single row: the label on the left, the value on the right, fine ticks between them. Drag anywhere on it to scrub the value (hold Shift for finer steps), click the number to type one, double-click to reset; the fill and its thin handle follow on a soft spring. Here four of them tune a card’s corners, padding, shadow and tilt, live.

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

Category: Inputs.

## Anatomy

1. **Row**: The whole row is the `role="slider"`: a sideways drag anywhere on it scrubs, a double-click resets it to `defaultValue`, and it takes the arrow keys.
2. **Label**: Names the slider for screen readers too. It always sits on the filled side; set `--scrub-label-width` to line labels up in a stack.
3. **Track**: The room between label and value, filled up to the value on a spring and scaled by `ticks` fine marks (20 by default). Its width is the whole range: one drag across it goes from `min` to `max`.
4. **Handle**: A thin bar on the fill’s edge, at the value. It turns blue and grows a little while scrubbing.
5. **Value**: The number in tabular figures, with a true minus sign. Clicking it opens a field to type a value; Enter or a digit key does the same.
6. **Unit**: An optional `unit` after the number, lighter. Screen readers hear `valueText` instead, so “px” can be read as “pixels”.

## When to use

- Tuning a number in a dense properties panel: radius, padding, opacity, angle, as in a design tool’s inspector.
- Values people adjust by feel and watch change live: a drag across the row covers the whole range, Shift makes it ten times finer.
- Numbers that also need to be typed exactly: click the value, type, Enter.
- Stacks of related settings where every row must stay one line high (34 px) and line up under the same label column (`--scrub-label-width`).

## When not to use

- A handful of named steps (Low, Medium, High). Use a stepped slider, like the effort slider, whose stops have names.
- Settings people rarely change in a plain form. A number field with a label is clearer than a gesture to discover.
- Unbounded values (a price, a count with no ceiling). The track needs a `min` and a `max` to mean anything; use a number field.
- Two values at once, like a range from–to. It holds one value; use a two-thumb range slider.
- Touch-first screens where sideways swipes already mean something (carousels, swipe actions): the scrub would fight them.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/scrub-slider.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.

Copy these media files to the same paths under your public folder:

- https://brunodelorence.com/experiments/scrub-slider/coastal-loop-3d-v1.webp

## Usage

```tsx
import { useState } from 'react';
import { ScrubSlider } from './scrub-slider';

export function CornerRadius() {
  const [radius, setRadius] = useState(12);
  return (
    <ScrubSlider
      label="Radius"
      value={radius}
      onChange={setRadius}
      defaultValue={12}
      min={0}
      max={32}
      unit="px"
      valueText={(value) => `${value} pixels`}
    />
  );
}
```

## States

- **Rest**: The label muted, the handle dark: nothing runs at rest.
- **Focused**: A keyboard focus ring outside the row, and the label in full ink.
- **Scrubbing**: While a drag holds it, the handle turns blue and stretches; the label darkens.
- **Typing a value**: A field over the value, outlined in blue. Enter keeps it (snapped to `fineStep` and the range), Escape drops it.
- **At the minimum**: The fill shrinks to the handle’s width.
- **At the maximum**: The fill reaches the value cell; the handle sits at the end.
- **Negative range**: With a `min` below zero the value shows a true minus sign (−6°).
- **Decimals**: A `step` of 0.01 shows as many decimals as the value needs, never trailing zeros.
- **Dark**: With `colorScheme="dark"`, or on a dark page, its light-dark() colours switch.

## How it works

- Scrubbing is relative. A press anywhere on the row notes the value, and each pointer move adds its distance times a rate: a full track’s width covers the whole range, and Shift makes it ten times finer. The unrounded sum is kept, so slow drags add up before snapping to the step.
- Pointer capture keeps a scrub going wherever the pointer goes. It only starts after a few pixels of sideways travel, so a vertical swipe on a phone still scrolls the page and a tap on the value still opens the field.
- The fill is one layer as wide as the track, slid by a CSS variable, --p, from 0 to 1, and its right edge carries the handle. A spring integrated each frame moves --p toward the value without a render per frame, and stops when it settles.
- The label always sits on the filled side and the value on the open side; the ticks are one repeating gradient. The colours are --scrub-* custom properties whose defaults are light-dark() pairs, so the slider follows the page’s color-scheme, or the colorScheme prop, and a parent can still theme it by setting them. The demo’s panel opens in the page’s scheme until its switch is flipped.
- In the demo the card reads the four values as CSS variables. Its cover is a rendered 3D coastal scene with a fantasy-game finish; the image and button take the card’s radius minus its padding, so the corners stay concentric.

## API reference

- **label** (`string`): The visible label, which also names the slider for screen readers. Required.
- **value** (`number`): The value, when the parent holds it (with onChange).
- **defaultValue** (`number`, default `0`): The value to start from when the slider holds its own, and the one a double-click restores.
- **onChange** (`(value: number) => void`): Called with each new value: scrubbed, stepped, typed or reset.
- **min** (`number`, default `0`): The lowest value.
- **max** (`number`, default `100`): The highest value.
- **step** (`number`, default `1`): The step of a drag and of the arrow keys; Page Up and Page Down take ten.
- **fineStep** (`number`, default `step / 10`): The step while Shift is held.
- **unit** (`string`, default `''`): Shown after the value: px, °, %.
- **valueText** (`(value: number) => string`): What screen readers say for a value; by default, the value and its unit.
- **ticks** (`number`, default `20`): How many intervals the ticks divide the track into.
- **defaultState** (`'focus' | 'scrubbing' | 'editing'`): Opens drawn as focused, as scrubbing, or with the value’s field open (not focused), for a still picture. The first press or key goes back to normal.
- **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 slider’s root.

## Accessibility

- Each slider is a role="slider" named by its visible label, with aria-valuenow and an aria-valuetext that speaks its unit (“24 pixels”).
- The arrow keys step (Shift for fine steps), Page Up and Page Down take ten steps, and Home and End go to the ends.
- Enter, or typing a digit, opens a labelled text field: Enter keeps the value, Escape drops it, and focus returns to the slider. Everything a drag or a double-click does can be done from the keyboard.
- The demo’s cursor waits while keyboard focus is in the panel. Under reduced motion there is no demo and the fill jumps straight to its value.

## Source

### scrub-slider/scrub-slider.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
  type RefObject,
} from 'react';
import { MoonIcon, ResetIcon, SunIcon } from './icons';
import { PreviewCard, type CardStyle } from './preview';
import { useAutoplay } from './use-autoplay';
import styles from './scrub-slider.module.css';
import panel from './panel.module.css';

/*
 * Scrub slider: a compact number control, as in a design tool's panel. The label sits on the
 * left, the value on the right, and between them a track of fine ticks with a thin handle.
 *
 * - Drag anywhere on the row to scrub: the value follows the pointer, a full track's width for the
 *   whole range, ten times finer while Shift is held. Pointer capture keeps the drag going wherever
 *   the pointer goes; on a phone, a vertical swipe still scrolls the page.
 * - Click the value to type one (Enter keeps it, Escape drops it); double-click the row to reset.
 * - Keys: the arrows step (Shift: fine steps), Page Up and Down take ten steps, Home and End go
 *   to the ends, Enter or a digit starts typing.
 * - The fill and its handle follow the value on a spring, integrated frame by frame while they
 *   move and written to a CSS variable (no render per frame). At rest, nothing runs.
 * - It is a role="slider" with aria-valuetext, named by its visible label.
 * - Its colours are CSS variables (--scrub-*): any ancestor can theme it. Their defaults are
 *   light-dark() pairs, so it is drawn in the page's color-scheme, or its colorScheme prop's.
 *
 * Below it, ScrubSliderPanel, its demo: four sliders tuning a live card.
 */

/** The spring the fill follows, per second: a little under critical damping, to settle softly. */
const STIFFNESS = 340;
const DAMPING = 33;
/** How far a press travels before it scrubs, in px. */
const SCRUB_START = 3;
/** A click or double-click this soon after a scrub ends (ms) belongs to the scrub. */
const AFTER_SCRUB = 300;

/** How many decimals a step has: 0.1 → 1. */
function decimalsOf(step: number) {
  return (String(step).split('.')[1] ?? '').length;
}

/** `value` on the grid of `step` from `min`, inside the range, without floating-point dust. */
function snap(value: number, step: number, min: number, max: number) {
  const snapped = min + Math.round((value - min) / step) * step;
  return Math.min(max, Math.max(min, Number(snapped.toFixed(decimalsOf(step)))));
}

/** 12 → "12", 12.30 → "12.3": as many decimals as the value has, up to the fine step's. */
function plain(value: number, fineStep: number) {
  return String(Number(value.toFixed(decimalsOf(fineStep))));
}

type Scrub = {
  pointerId: number;
  startX: number;
  startY: number;
  lastX: number;
  /** The track's width on screen, in px: dragging across it covers the whole range. */
  width: number;
  /** The value before rounding, so that slow moves add up. */
  raw: number;
  /** Past SCRUB_START: the drag scrubs. */
  active: boolean;
};

export function ScrubSlider({
  label,
  value: controlled,
  defaultValue = 0,
  onChange,
  min = 0,
  max = 100,
  step = 1,
  fineStep = step / 10,
  unit = '',
  valueText,
  ticks = 20,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The visible label, which also names the slider for screen readers. */
  label: string;
  /** The value, when the parent holds it (with `onChange`). */
  value?: number;
  /** The value to start from when the slider holds its own, and the one a double-click restores. */
  defaultValue?: number;
  /** Called with each new value: scrubbed, stepped, typed or reset. */
  onChange?: (value: number) => void;
  /** The lowest value. */
  min?: number;
  /** The highest value. */
  max?: number;
  /** The step of a drag and of the arrow keys. */
  step?: number;
  /** The step while Shift is held. */
  fineStep?: number;
  /** Shown after the value: 'px', '°', '%'. */
  unit?: string;
  /** What screen readers say for a value; by default, the value and its unit. */
  valueText?: (value: number) => string;
  /** How many intervals the ticks divide the track into. */
  ticks?: number;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Opens in a state, for a still picture of it (docs, tests): drawn as focused, as scrubbing, or
   * with the value's field open (not focused). The first press or key goes back to normal.
   */
  defaultState?: 'focus' | 'scrubbing' | 'editing';
  /** Extra class for the slider's root. */
  className?: string;
}) {
  const [own, setOwn] = useState(defaultValue);
  const value = controlled ?? own;
  const [scrubbing, setScrubbing] = useState(defaultState === 'scrubbing');
  /** defaultState 'focus': drawn as focused until the first press or key. */
  const [shownFocus, setShownFocus] = useState(defaultState === 'focus');
  /** The text being typed, or null when the value is not being edited. */
  const [draft, setDraft] = useState<string | null>(() =>
    defaultState === 'editing' ? plain(value, fineStep) : null,
  );
  const editing = draft !== null;
  const labelId = useId();

  const row = useRef<HTMLDivElement>(null);
  const track = useRef<HTMLSpanElement>(null);
  const field = useRef<HTMLInputElement>(null);
  const scrub = useRef<Scrub | null>(null);
  /** When the last scrub ended (an event time stamp). */
  const scrubbedAt = useRef(-Infinity);
  /** The field still has to be committed when it loses the focus (Enter and Escape decide first). */
  const pending = useRef(defaultState === 'editing');
  const selectOnOpen = useRef(false);
  /** Opened by defaultState 'editing': the field is drawn open but takes no focus on mount. */
  const openedByState = useRef(defaultState === 'editing');
  /** The field defaultState opened, until it is focused or closed. */
  const stateField = useRef(defaultState === 'editing');

  const target = Math.min(1, Math.max(0, max > min ? (value - min) / (max - min) : 0));
  // The fill's place as the server draws it; after that, the spring writes `--p` itself.
  const [initial] = useState(target);
  const spring = useRef({ at: target, speed: 0 });
  const frame = useRef(0);

  // The spring: on each new value, run frames until the fill comes to rest on it.
  useEffect(() => {
    const element = row.current;
    if (!element) return;
    const state = spring.current;
    const draw = () =>
      element.style.setProperty('--p', Math.min(1, Math.max(0, state.at)).toFixed(4));
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      state.at = target;
      state.speed = 0;
      draw();
      return;
    }
    let last = performance.now();
    const step = (now: number) => {
      // Two half steps per frame, and never more than two frames' worth after a stall.
      const dt = Math.max(0, Math.min(0.034, (now - last) / 1000)) / 2;
      last = now;
      for (let i = 0; i < 2; i += 1) {
        state.speed += (-STIFFNESS * (state.at - target) - DAMPING * state.speed) * dt;
        state.at += state.speed * dt;
      }
      const settled = Math.abs(state.at - target) < 0.0004 && Math.abs(state.speed) < 0.004;
      if (settled) {
        state.at = target;
        state.speed = 0;
      }
      draw();
      if (!settled) frame.current = requestAnimationFrame(step);
    };
    frame.current = requestAnimationFrame(step);
    return () => cancelAnimationFrame(frame.current);
  }, [target]);

  const set = (next: number) => {
    if (next === value) return;
    if (controlled === undefined) setOwn(next);
    onChange?.(next);
  };

  /** Opens the field over the value, with `text` in it. */
  const edit = (text: string, selectAll: boolean) => {
    pending.current = true;
    selectOnOpen.current = selectAll;
    setDraft(text);
  };

  useEffect(() => {
    if (!editing) return;
    if (openedByState.current) {
      openedByState.current = false;
      return;
    }
    field.current?.focus();
    if (selectOnOpen.current) field.current?.select();
  }, [editing]);

  /** Closes the field, keeping what was typed or not, and hands the focus back if asked. */
  const finish = (keep: boolean, refocus: boolean) => {
    if (!pending.current) return;
    pending.current = false;
    if (keep && draft !== null) {
      const typed = Number.parseFloat(draft.replace('\u2212', '-').replace(',', '.'));
      if (Number.isFinite(typed)) set(snap(typed, fineStep, min, max));
    }
    setDraft(null);
    if (refocus) row.current?.focus();
  };

  /** The first press or key ends a state drawn by defaultState. */
  const leaveDefaultState = () => {
    setShownFocus(false);
    setScrubbing(false);
    if (stateField.current) {
      stateField.current = false;
      finish(false, false);
    }
  };

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    leaveDefaultState();
    if (event.button !== 0) return;
    scrub.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      lastX: event.clientX,
      width: track.current?.getBoundingClientRect().width || 1,
      raw: value,
      active: false,
    };
  };

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const current = scrub.current;
    if (!current || current.pointerId !== event.pointerId) return;
    if (!current.active) {
      const dx = event.clientX - current.startX;
      const dy = event.clientY - current.startY;
      // Up or down first: the page scrolls.
      if (Math.abs(dy) > SCRUB_START && Math.abs(dy) > Math.abs(dx)) {
        scrub.current = null;
        return;
      }
      if (Math.abs(dx) < SCRUB_START) return;
      current.active = true;
      event.currentTarget.setPointerCapture(event.pointerId);
      setScrubbing(true);
    }
    const fine = event.shiftKey;
    const rate = ((max - min) / current.width) * (fine ? fineStep / step : 1);
    current.raw = Math.min(
      max,
      Math.max(min, current.raw + (event.clientX - current.lastX) * rate),
    );
    current.lastX = event.clientX;
    set(snap(current.raw, fine ? fineStep : step, min, max));
  };

  const endScrub = (event: PointerEvent<HTMLDivElement>) => {
    const current = scrub.current;
    if (!current || current.pointerId !== event.pointerId) return;
    scrub.current = null;
    if (!current.active) return;
    setScrubbing(false);
    scrubbedAt.current = event.timeStamp;
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    leaveDefaultState();
    const small = event.shiftKey ? fineStep : step;
    const moves: Partial<Record<string, number>> = {
      ArrowRight: value + small,
      ArrowUp: value + small,
      ArrowLeft: value - small,
      ArrowDown: value - small,
      PageUp: value + step * 10,
      PageDown: value - step * 10,
      Home: min,
      End: max,
    };
    const next = moves[event.key];
    if (next !== undefined) {
      event.preventDefault();
      set(snap(next, small, min, max));
    } else if (event.key === 'Enter') {
      event.preventDefault();
      edit(plain(value, fineStep), true);
    } else if (/^[\d.,-]$/.test(event.key) && !event.metaKey && !event.ctrlKey && !event.altKey) {
      // Typing a number starts the field with it.
      event.preventDefault();
      edit(event.key, false);
    }
  };

  const shown = plain(value, fineStep);

  return (
    <div className={`${styles.slider} ${className}`} data-color-scheme={colorScheme}>
      <div
        ref={row}
        className={styles.row}
        role="slider"
        tabIndex={0}
        aria-labelledby={labelId}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={valueText ? valueText(value) : unit ? `${shown} ${unit}` : shown}
        data-scrubbing={scrubbing || undefined}
        data-editing={editing || undefined}
        data-focus={shownFocus || undefined}
        style={{ '--p': initial, '--ticks': ticks } as CSSProperties}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endScrub}
        onPointerCancel={endScrub}
        onLostPointerCapture={(event) => {
          // A touch is first captured by the element under the finger: its loss, which bubbles
          // here when the row takes the capture, is not the end of the scrub.
          if (event.target === event.currentTarget) endScrub(event);
        }}
        onKeyDown={onKeyDown}
        onDoubleClick={(event) => {
          if (event.timeStamp - scrubbedAt.current > AFTER_SCRUB) {
            set(snap(defaultValue, fineStep, min, max));
          }
        }}
      >
        <span id={labelId} className={styles.label} data-part="label">
          {label}
        </span>
        <span ref={track} className={styles.track} data-track="" data-part="track">
          <span className={styles.fill} data-part="fill">
            <span className={styles.handle} data-part="handle" />
          </span>
          <span className={styles.ticks} data-part="ticks" />
        </span>
        <span
          className={styles.value}
          data-part="value"
          onClick={(event) => {
            if (event.timeStamp - scrubbedAt.current > AFTER_SCRUB) edit(shown, true);
          }}
        >
          {/* A true minus sign on screen; the field and screen readers get the plain one. */}
          {shown.replace('-', '\u2212')}
          {unit && (
            <span className={styles.unit} data-part="unit">
              {unit}
            </span>
          )}
        </span>
      </div>
      {editing && (
        <input
          ref={field}
          className={styles.editor}
          data-part="editor"
          type="text"
          inputMode={min < 0 ? 'text' : 'decimal'}
          enterKeyHint="done"
          autoComplete="off"
          spellCheck={false}
          aria-label={label}
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          onFocus={() => {
            stateField.current = false;
          }}
          onKeyDown={(event) => {
            if (event.key !== 'Enter' && event.key !== 'Escape') return;
            event.preventDefault();
            finish(event.key === 'Enter', true);
          }}
          onBlur={() => finish(true, false)}
        />
      )}
    </div>
  );
}

/*
 * The demo: a panel of four scrub sliders tuning a card beside it (preview.tsx), light or dark.
 * While it is on screen and untouched, a ghost cursor takes a slider by its handle now and then
 * and scrubs it to a new value. Its colours are light-dark() tokens, so it is drawn in the page's
 * color-scheme (or the colorScheme prop's) from the first frame; the panel's switch shows that
 * scheme, read once mounted, and flipping it sets the panel's own.
 */

type Theme = 'light' | 'dark';

/**
 * The colour scheme `root` is drawn in: the CSS gives it an unseen colour, white in light and
 * black in dark, read once mounted and again whenever the scheme may have changed: an attribute
 * of an ancestor (where pages keep their theme), the system's setting, or `colorScheme`. null
 * until read: the server cannot know it.
 */
function useScheme(root: RefObject<HTMLElement | null>, colorScheme?: string) {
  const [scheme, setScheme] = useState<Theme | null>(null);
  useLayoutEffect(() => {
    const element = root.current;
    if (!element) return;
    const read = () =>
      setScheme(getComputedStyle(element).outlineColor === 'rgb(0, 0, 0)' ? 'dark' : 'light');
    read();
    const ancestors = new MutationObserver(read);
    for (let node = element.parentElement; node; node = node.parentElement) {
      ancestors.observe(node, { attributes: true });
    }
    const system = window.matchMedia('(prefers-color-scheme: dark)');
    system.addEventListener('change', read);
    return () => {
      ancestors.disconnect();
      system.removeEventListener('change', read);
    };
  }, [root, colorScheme]);
  return scheme;
}

/** The sliders, in order: what each one tunes, its range, and its unit, written and spoken. */
const SETTINGS: readonly {
  key: keyof CardStyle;
  label: string;
  min: number;
  max: number;
  unit: string;
  spoken: string;
}[] = [
  { key: 'radius', label: 'Radius', min: 0, max: 32, unit: 'px', spoken: 'pixels' },
  { key: 'padding', label: 'Padding', min: 8, max: 32, unit: 'px', spoken: 'pixels' },
  { key: 'shadow', label: 'Shadow', min: 0, max: 48, unit: 'px', spoken: 'pixels' },
  { key: 'tilt', label: 'Tilt', min: -15, max: 15, unit: '°', spoken: 'degrees' },
];

const DEFAULTS: CardStyle = { radius: 18, padding: 14, shadow: 24, tilt: 0 };

/** The ghost's scrubs, in turn: a slider (its place in SETTINGS) and the value it drags it to. */
const GHOST_SCRUBS: readonly (readonly [number, number])[] = [
  [0, 30],
  [3, -6],
  [1, 22],
  [2, 6],
  [0, 10],
  [3, 5],
  [1, 12],
  [2, 34],
];

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

export function ScrubSliderPanel({
  className = '',
  defaultTheme,
  colorScheme,
  autoplay = true,
}: {
  /** Extra class for the root, a stage of its own. */
  className?: string;
  /** The theme to begin with, as if picked on the switch; without it, it follows colorScheme. */
  defaultTheme?: Theme;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Play the demonstration while visible and untouched; false leaves it all to the reader. */
  autoplay?: boolean;
}) {
  const { ref: root, playing: unattended, take } = useAutoplay<HTMLDivElement>(6000);
  // The demonstration plays only when allowed, on screen and untouched.
  const playing = autoplay && unattended;
  const [values, setValues] = useState<CardStyle>(DEFAULTS);
  /** The theme picked on the switch; until then, the stage follows its colour scheme. */
  const [picked, setPicked] = useState<Theme | undefined>(defaultTheme);
  const scheme = useScheme(root, colorScheme);
  const theme = picked ?? scheme ?? (colorScheme === 'dark' ? 'dark' : 'light');
  const [message, setMessage] = useState('');
  /** Keyboard focus is in the panel: the ghost leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const titleId = useId();

  const stage = useRef<HTMLDivElement>(null);
  const cursor = useRef<SVGSVGElement>(null);
  /** The values, for the ghost, which runs outside renders. */
  const latest = useRef(values);
  const stopGhost = useRef<(() => void) | null>(null);

  useEffect(() => {
    latest.current = values;
  }, [values]);

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

  const demo = playing && !keyboard;
  useEffect(() => {
    const box = stage.current;
    const pointer = cursor.current;
    if (!demo || !box || !pointer) return;
    let alive = true;
    let frame = 0;
    let timer = 0;
    /** The slider row the ghost holds, shown as scrubbed. */
    let held: HTMLElement | null = null;
    let at: Point = { x: box.offsetWidth * 0.5, y: box.offsetHeight * 0.88 };

    /** A box on the screen, in the stage's own px (the gallery draws the demo scaled down). */
    const local = (element: Element) => {
      const outer = box.getBoundingClientRect();
      const scale = outer.width / box.offsetWidth || 1;
      const rect = element.getBoundingClientRect();
      return {
        x: (rect.left - outer.left) / scale,
        y: (rect.top - outer.top) / scale,
        width: rect.width / scale,
        height: rect.height / scale,
      };
    };
    const place = (point: Point) => {
      at = point;
      // The arrow's tip is at (3, 2) in its box.
      pointer.style.transform = `translate(${point.x - 3}px, ${point.y - 2}px)`;
    };
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        timer = window.setTimeout(resolve, ms);
      });
    /** Moves the cursor to `to`, eased, passing each position to `each`. */
    const glide = (to: Point, duration: number, each?: (point: Point) => void) =>
      new Promise<void>((resolve) => {
        const from = at;
        let start = -1;
        const step = (now: number) => {
          if (!alive) return;
          if (start < 0) start = now;
          const t = Math.min(1, Math.max(0, (now - start) / duration));
          const e = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
          const point = { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e };
          place(point);
          each?.(point);
          if (t < 1) frame = requestAnimationFrame(step);
          else resolve();
        };
        frame = requestAnimationFrame(step);
      });
    const release = () => {
      pointer.dataset.state = 'shown';
      if (held) delete held.dataset.scrubbing;
      held = null;
    };

    /** Takes a slider by its handle and drags it to a new value, as a scrub would. */
    const scrubOne = async (turn: number) => {
      const [index, to] = GHOST_SCRUBS[turn % GHOST_SCRUBS.length];
      const setting = SETTINGS[index];
      const row = box.querySelectorAll<HTMLElement>('[role="slider"]')[index];
      const bar = row?.querySelector('[data-track]');
      if (!row || !bar) return;
      const track = local(bar);
      const range = setting.max - setting.min;
      const xOf = (value: number) => track.x + ((value - setting.min) / range) * track.width;
      const y = track.y + track.height / 2;
      const from = latest.current[setting.key];
      await glide({ x: xOf(from), y }, 800);
      if (!alive) return;
      pointer.dataset.state = 'pressed';
      held = row;
      row.dataset.scrubbing = '';
      await wait(160);
      if (!alive) return;
      await glide({ x: xOf(to), y }, 1100, (point) => {
        const value = Math.round(setting.min + ((point.x - track.x) / track.width) * range);
        setValues((now) => (now[setting.key] === value ? now : { ...now, [setting.key]: value }));
      });
      if (!alive) return;
      await wait(140);
      release();
    };

    (async () => {
      place(at);
      await wait(900);
      if (!alive) return;
      pointer.dataset.state = 'shown';
      for (let turn = 0; alive; turn += 1) {
        await scrubOne(turn);
        if (!alive) return;
        // Off the row, then a rest.
        await glide({ x: at.x + 26, y: at.y + 30 }, 600);
        await wait(1700);
      }
    })();

    const stop = () => {
      if (!alive) return;
      alive = false;
      cancelAnimationFrame(frame);
      window.clearTimeout(timer);
      release();
      pointer.dataset.state = 'hidden';
    };
    stopGhost.current = stop;
    return stop;
  }, [demo]);

  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={`${panel.root} ${className}`}
      data-color-scheme={picked ?? colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div ref={stage} className={panel.stage}>
        <div className={panel.preview}>
          <PreviewCard {...values} />
        </div>

        <div className={panel.panel} role="group" aria-labelledby={titleId}>
          <div className={panel.header}>
            <p id={titleId} className={panel.title}>
              Card
            </p>
            <button
              type="button"
              className={panel.reset}
              onClick={() => {
                setValues(DEFAULTS);
                setMessage((now) => (now === 'Card reset.' ? 'Card reset.\u00a0' : 'Card reset.'));
              }}
            >
              <ResetIcon />
              Reset
            </button>
            <button
              type="button"
              role="switch"
              aria-checked={theme === 'dark'}
              aria-label="Dark theme"
              className={panel.switch}
              onClick={() => setPicked(theme === 'dark' ? 'light' : 'dark')}
            >
              <span className={panel.knob} aria-hidden="true">
                <SunIcon className={panel.sun} />
                <MoonIcon className={panel.moon} />
              </span>
            </button>
          </div>
          {SETTINGS.map((setting) => (
            <ScrubSlider
              key={setting.key}
              label={setting.label}
              value={values[setting.key]}
              defaultValue={DEFAULTS[setting.key]}
              onChange={(value) => setValues((now) => ({ ...now, [setting.key]: value }))}
              min={setting.min}
              max={setting.max}
              unit={setting.unit}
              valueText={(value) => `${value} ${setting.spoken}`}
            />
          ))}
        </div>

        <svg
          ref={cursor}
          className={panel.cursor}
          viewBox="0 0 20 20"
          data-state="hidden"
          aria-hidden="true"
        >
          <path d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z" />
        </svg>
      </div>

      <p className={panel.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### scrub-slider/scrub-slider.module.css

```css
/* Scrub slider (scrub-slider.tsx): a label, a track and a value in one row. The fill is a layer as
   wide as the track, moved by `--p` (0 to 1), which the component springs towards the value; its
   right edge carries the handle. Colours are custom properties: set the --scrub-* properties on
   any ancestor to theme it. Their defaults, below, are light-dark(light, dark): the slider takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets
   (data-color-scheme). */

.slider {
  --base: var(--scrub-base, #fff);
  --fill: var(--scrub-fill, #ebebed);
  --line: var(--scrub-line, rgb(0 0 0 / 0.06));
  --ink: var(--scrub-ink, #1d1d1f);
  --muted: var(--scrub-muted, #616166);
  --tick: var(--scrub-tick, rgb(0 0 0 / 0.16));
  --handle: var(--scrub-handle, #1d1d1f);
  /* The focus ring, the handle while scrubbing, and the field's outline: the same on both. */
  --accent: var(--scrub-accent, #0a84ff);
  position: relative;
  min-width: 0;
  font:
    500 13px/1 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .slider {
    --base: var(--scrub-base, light-dark(#fff, #101012));
    --fill: var(--scrub-fill, light-dark(#ebebed, #2a2a2e));
    --line: var(--scrub-line, light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06)));
    --ink: var(--scrub-ink, light-dark(#1d1d1f, #f2f2f4));
    --muted: var(--scrub-muted, light-dark(#616166, #a1a1a9));
    --tick: var(--scrub-tick, light-dark(rgb(0 0 0 / 0.16), rgb(255 255 255 / 0.2)));
    --handle: var(--scrub-handle, light-dark(#1d1d1f, #f2f2f4));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.slider[data-color-scheme='light'] {
  color-scheme: light;
}
.slider[data-color-scheme='dark'] {
  color-scheme: dark;
}
.slider[data-color-scheme='system'] {
  color-scheme: light dark;
}

.row {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(24px, 1fr) auto;
  height: 34px;
  overflow: hidden;
  border-radius: 9px;
  background: var(--base);
  color: var(--ink);
  cursor: ew-resize;
  /* A sideways drag scrubs; up and down still scroll the page. */
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
}
/* The outline, over the fill. */
.row::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--line);
  pointer-events: none;
}
.row:focus-visible,
.row[data-focus] {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The label sits on the filled side, always. */
.label {
  display: flex;
  align-items: center;
  min-width: var(--scrub-label-width, 0px);
  padding: 0 8px 0 11px;
  background: var(--fill);
  color: var(--muted);
  white-space: nowrap;
  transition: color 0.15s ease;
}
.row[data-scrubbing] .label,
.row:focus-visible .label,
.row[data-focus] .label {
  color: var(--ink);
}

.track {
  position: relative;
  overflow: hidden;
}
/* As wide as the track and slid left: its right edge is the value's place, from 2px (the
   handle's width) to the end. */
.fill {
  position: absolute;
  inset: 0;
  background: var(--fill);
  transform: translateX(calc((var(--p) - 1) * (100% - 2px)));
}
.handle {
  position: absolute;
  top: 50%;
  right: 0;
  width: 2px;
  height: 16px;
  margin-top: -8px;
  border-radius: 1px;
  background: var(--handle);
  transition:
    transform 0.2s ease,
    background-color 0.15s ease;
}
.row[data-scrubbing] .handle {
  background: var(--accent);
  transform: scaleY(1.3);
}
/* The ticks: a 1px line repeated `--ticks` times along the track, over the fill. */
.ticks {
  position: absolute;
  inset: 0 2px 0 1px;
  background: linear-gradient(to right, var(--tick) 1px, transparent 1px) 0 50% /
    calc((100% - 1px) / var(--ticks)) 5px repeat-x;
  pointer-events: none;
}

.value {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  min-width: 58px;
  padding: 0 11px 0 6px;
  font-variant-numeric: tabular-nums;
  cursor: text;
}
.unit {
  margin-left: 1px;
  color: var(--muted);
  font-weight: 400;
}
.row[data-editing] .value {
  visibility: hidden;
}

/* Typing a value: a field over the value, outlined while it has the focus. */
.editor {
  position: absolute;
  top: 3px;
  right: 3px;
  bottom: 3px;
  width: 66px;
  margin: 0;
  padding: 0 8px;
  border: 0;
  border-radius: 7px;
  background: var(--base);
  box-shadow: inset 0 0 0 1.5px var(--accent);
  color: var(--ink);
  font: inherit;
  font-variant-numeric: tabular-nums;
  text-align: right;
  outline: none;
}
.editor::selection {
  background: color-mix(in srgb, var(--accent) 32%, transparent);
  color: inherit;
}

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

### scrub-slider/preview.tsx

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

/*
 * The panel's live preview: a small route card whose corner radius, padding, shadow and tilt come
 * from the sliders, as CSS variables. The cover and the button take the inner radius (the card's,
 * less its padding), so the corners stay concentric whatever the values. Sample content: hidden
 * from screen readers, which hear the sliders instead.
 */

export type CardStyle = { radius: number; padding: number; shadow: number; tilt: number };

export function PreviewCard({ radius, padding, shadow, tilt }: CardStyle) {
  return (
    <div
      className={styles.card}
      style={
        {
          '--radius': `${radius}px`,
          '--pad': `${padding}px`,
          '--shadow': `${shadow}px`,
          '--tilt': `${tilt}deg`,
        } as CSSProperties
      }
      aria-hidden="true"
    >
      <img
        className={styles.cover}
        src="/experiments/scrub-slider/coastal-loop-3d-v1.webp"
        width={800}
        height={450}
        alt=""
        decoding="async"
        draggable={false}
      />
      <span className={styles.cardText}>
        <span className={styles.cardTitle}>Coastal loop</span>
        <span className={styles.cardMeta}>12.6 km · 48 min · Easy</span>
      </span>
      <span className={styles.cardButton}>Start route</span>
    </div>
  );
}
```

### scrub-slider/panel.module.css

```css
/* The scrub slider's demo (ScrubSliderPanel in scrub-slider.tsx): a stage, light or dark, with a
   live card (preview.tsx) and a panel of four sliders. From 620px wide (a container query) the
   card and the panel stand side by side; narrower, the card is on top.

   Colours are the tokens below, light-dark(light, dark), and the sliders' own defaults
   (scrub-slider.module.css): the stage takes the color-scheme it inherits from the page, or the
   one the colorScheme prop or the panel's switch sets (data-color-scheme on the root). */

.root {
  container-type: inline-size;
  min-width: 0;
  /* For the script: a colour the root never shows (it has no outline), white in light and black
     in dark, which tells the switch the scheme the root is drawn in. */
  outline-color: #fff;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    outline-color: light-dark(#fff, #000);
  }
}
/* The colorScheme prop, or the reader's pick on the switch. Without either, nothing is set:
   color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.stage {
  --stage: #e7e7e8;
  --edge: rgb(0 0 0 / 0.05);
  --surface: #f7f7f8;
  --well: #e4e4e7;
  --ink: #1d1d1f;
  --muted: #616166;
  --hover: rgb(0 0 0 / 0.05);
  /* The lift under the panel: a hairline, a contact shadow and a soft drop, deeper and a little
     lower on dark (each scheme's drop is transparent in the other). */
  --raise:
    0 0 0 1px rgb(0 0 0 / 0.04), 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 32px -14px rgb(0 0 0 / 0.2),
    0 14px 34px -14px transparent;
  --focus: #0a84ff;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --scrub-label-width: 72px;
  /* The card and its picture. */
  --card: #fff;
  --card-ink: #1d1d1f;
  --card-muted: #6e6e73;
  --card-line: rgb(0 0 0 / 0.06);
  --card-shadow: rgb(40 30 60 / 0.24);
  --card-button: #1d1d1f;
  --card-button-ink: #fff;
  --sky-top: #ffd9c6;
  --sky-bottom: #f5ab9c;
  --sun: #fff4e0;
  --far: #e2897f;
  --sea: #7aa9c2;
  --glint: rgb(255 255 255 / 0.75);
  --near: #b05d58;
  position: relative;
  display: grid;
  gap: 16px;
  padding: 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font:
    400 14px/1.4 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  transition: background-color 0.3s ease;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --stage: light-dark(#e7e7e8, #0e0e10);
    --edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --surface: light-dark(#f7f7f8, #19191c);
    --well: light-dark(#e4e4e7, #2a2a2e);
    --ink: light-dark(#1d1d1f, #f2f2f4);
    --muted: light-dark(#616166, #a1a1a9);
    --hover: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --raise:
      0 0 0 1px light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.07)),
      0 1px 2px light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.5)),
      0 12px 32px -14px light-dark(rgb(0 0 0 / 0.2), transparent),
      0 14px 34px -14px light-dark(transparent, rgb(0 0 0 / 0.85));
    --card: light-dark(#fff, #1c1c1f);
    --card-ink: light-dark(#1d1d1f, #f2f2f4);
    --card-muted: light-dark(#6e6e73, #a1a1a9);
    --card-line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --card-shadow: light-dark(rgb(40 30 60 / 0.24), rgb(0 0 0 / 0.7));
    --card-button: light-dark(#1d1d1f, #f2f2f4);
    --card-button-ink: light-dark(#fff, #141416);
    --sky-top: light-dark(#ffd9c6, #24284d);
    --sky-bottom: light-dark(#f5ab9c, #6a4c7d);
    --sun: light-dark(#fff4e0, #f2dcb4);
    --far: light-dark(#e2897f, #3d3768);
    --sea: light-dark(#7aa9c2, #2c4766);
    --glint: light-dark(rgb(255 255 255 / 0.75), rgb(255 255 255 / 0.45));
    --near: light-dark(#b05d58, #1d1a35);
  }
}

/* The live card, centred in its half. */
.preview {
  display: grid;
  place-items: center;
  min-height: 268px;
}

.card {
  display: grid;
  gap: 12px;
  width: 232px;
  padding: var(--pad);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--card-line),
    0 calc(var(--shadow) * 0.4) var(--shadow) calc(var(--shadow) * -0.3) var(--card-shadow);
  transform: rotate(var(--tilt));
  transition:
    padding 0.18s var(--ease-out),
    border-radius 0.18s var(--ease-out),
    box-shadow 0.18s var(--ease-out),
    transform 0.24s var(--ease-out),
    background-color 0.3s ease;
}
/* Inside the card, corners take the card's radius less its padding: concentric. */
.cover,
.cardButton {
  border-radius: max(4px, calc(var(--radius) - var(--pad)));
  transition: border-radius 0.18s var(--ease-out);
}
.cover {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 200 / 116;
  object-fit: cover;
  object-position: center;
  background: linear-gradient(var(--sky-top), var(--sky-bottom));
}
.sun {
  fill: var(--sun);
}
.far {
  fill: var(--far);
}
.sea {
  fill: var(--sea);
}
.glint {
  fill: none;
  stroke: var(--glint);
  stroke-width: 2;
  stroke-linecap: round;
}
.near {
  fill: var(--near);
}
.cardText {
  display: grid;
  gap: 3px;
  padding: 0 2px;
}
.cardTitle {
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--card-ink);
}
.cardMeta {
  font-size: 12.5px;
  color: var(--card-muted);
}
.cardButton {
  display: grid;
  place-items: center;
  height: 36px;
  background: var(--card-button);
  color: var(--card-button-ink);
  font-size: 13px;
  font-weight: 600;
}

/* The panel of sliders. */
.panel {
  display: grid;
  gap: 6px;
  align-content: start;
  padding: 4px 8px 8px;
  border-radius: 16px;
  background: var(--surface);
  box-shadow: var(--raise);
  transition:
    background-color 0.3s ease,
    box-shadow 0.3s ease;
}
.header {
  display: flex;
  align-items: center;
  gap: 4px;
  height: 40px;
  padding-left: 6px;
}
.title {
  flex: 1;
  margin: 0;
  font-size: 13px;
  font-weight: 600;
}
.reset {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 28px;
  padding: 0 10px 0 8px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--muted);
  font-family: inherit;
  font-size: 12.5px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.reset svg {
  width: 14px;
  height: 14px;
}
.reset:hover {
  background: var(--hover);
  color: var(--ink);
}

/* The theme switch: a knob that slides from the sun to the moon. */
.switch {
  display: flex;
  align-items: center;
  width: 46px;
  height: 28px;
  padding: 3px;
  border: 0;
  border-radius: 999px;
  background: var(--well);
  color: var(--ink);
  cursor: pointer;
  transition: background-color 0.3s ease;
}
.knob {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--card);
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.18);
  transition:
    transform 0.32s var(--ease-out),
    background-color 0.3s ease;
}
.switch[aria-checked='true'] .knob {
  transform: translateX(18px);
}
.knob svg {
  grid-area: 1 / 1;
  width: 13px;
  height: 13px;
  transition:
    opacity 0.25s ease,
    transform 0.32s var(--ease-out);
}
.switch[aria-checked='true'] .sun,
.switch[aria-checked='false'] .moon {
  opacity: 0;
  transform: rotate(-60deg) scale(0.6);
}
/* Following the page, the knob is simply where the scheme puts it (also as the page loads, before
   the script has read the scheme); it slides, and its icons turn, when the reader flips it. */
.root:not([data-color-scheme]) .knob,
.root:not([data-color-scheme]) .knob svg {
  transition: none;
}

.reset:focus-visible,
.switch:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The demo's ghost cursor. */
.cursor {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  width: 22px;
  height: 22px;
  overflow: visible;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.cursor path {
  fill: #fff;
  stroke: #111;
  stroke-width: 1.2;
  stroke-linejoin: round;
  transform-origin: 3px 2px;
  transition: transform 0.12s ease;
}
.cursor[data-state='shown'],
.cursor[data-state='pressed'] {
  opacity: 1;
}
.cursor[data-state='pressed'] path {
  transform: scale(0.86);
}

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

@container (min-width: 620px) {
  .stage {
    grid-template-columns: minmax(0, 1fr) 296px;
    align-items: center;
    gap: 24px;
    min-height: 440px;
    padding: 24px;
  }
  .preview {
    min-height: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .stage,
  .card,
  .cover,
  .cardButton,
  .panel,
  .reset,
  .switch,
  .knob,
  .knob svg,
  .cursor,
  .cursor path {
    transition: none;
  }
}
```

### scrub-slider/icons.tsx

```tsx
/*
 * The panel's icons: Reset's arrow, and the sun and moon of the theme switch. Decorative: the
 * buttons carry the words.
 */

export function ResetIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M3 8a5 5 0 1 0 1.5-3.6M3 2.8v2.6h2.6" />
    </svg>
  );
}

export function SunIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <circle cx="8" cy="8" r="2.6" />
      <path d="M8 1.9v1.3M8 12.8v1.3M14.1 8h-1.3M3.2 8H1.9M12.3 3.7l-.9.9M4.6 11.4l-.9.9M12.3 12.3l-.9-.9M4.6 4.6l-.9-.9" />
    </svg>
  );
}

export function MoonIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M13.2 9.7A5.4 5.4 0 0 1 6.3 2.8a5.4 5.4 0 1 0 6.9 6.9Z" />
    </svg>
  );
}
```

### scrub-slider/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 };
}
```