# Split slider

> A value slider whose dark fill carries its label and value, and whose handle parts around the text it meets.

A slider for a tool’s panel: a dark, rounded fill grows over a light track, with a thin white handle near its end. Where the fill covers the label or the value they turn white, and when the handle reaches one of them it parts into two dots above and below the text. Drag it past an end and it stretches like a rubber band; every step ticks with a soft synthesised click.

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

Category: Inputs.

## Anatomy

1. **Slider**: The whole bar is one `role="slider"`, named by its label: arrows step, Shift or Page Up and Down take ten, Home and End go to the ends.
2. **Track**: A grey well a step off the page. A press anywhere on it brings the fill there; a drag past an end stretches it like a rubber band.
3. **Fill**: A dark rounded bar (a lit grey at night) from the left end to the value, lit along its top edge. It follows on springs, written to CSS variables, without a render per frame.
4. **Label**: Dark on the track and white where the fill covers it: the content is drawn twice, and the white copy is clipped to the fill.
5. **Value**: The number and its unit, in tabular figures, starting where two digits start: a one-digit value keeps a figure space, so it never shifts.
6. **Handle**: A short white line near the fill’s end. Over a text it splits into two dots, above and below it, and closes once past.

## When to use

- Tuning one number by feel, where the effect shows as it moves: blur, radius, opacity, zoom.
- Stacks of settings in an editor or an inspector, where each row needs its name and its value and little else.
- Bounded ranges with a fixed step, from `min` to `max`, read with their unit: `76 px`, `58%`, `−1.5 °C`.
- Narrow panels: the label and the value live inside the bar, so a row costs only its height.

## When not to use

- Values that must be typed exactly, or ranges too wide to drag to one number. Use a number field, or pair one with the slider.
- A low and a high bound at once. Use a range slider with two thumbs.
- A few named steps, like Low, Medium, High. A segmented control or a stepped slider names them.
- Long labels: the label and the value share one bar, so keep the label to a word or two.
- Settings that apply only on Save, where nothing changes as it moves: the drag gives no feedback worth having.

## Installation

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

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

## Usage

```tsx
import { useState } from 'react';
import { SplitSlider, SplitSliderDemo } from './split-slider';

// One slider in your own panel.
export function BlurControl() {
  const [blur, setBlur] = useState(76);
  return <SplitSlider label="Blur" value={blur} onChange={setBlur} unit="px" unitName="pixels" />;
}

// Or the stage of three, without its demo.
export function SliderStage() {
  return <SplitSliderDemo autoplay={false} />;
}
```

## States

- **At rest**: The handle is a closed line between the label and the value. At rest nothing animates.
- **Split by the label**: With the handle over the label, its halves shrink to two dots and the word shows between them.
- **Split by the value**: The same split over the number: the part under the fill is white, the rest grey.
- **At the minimum**: No fill: the handle waits, dark, at its leftmost place before the label.
- **At the maximum**: The fill covers the track and everything on it turns white; the handle, at the value’s end, stays split.
- **Stretched past the end**: Held by a drag beyond `max`: the bar grows to the right and thins. Let go, it springs back with a wobble.
- **Dark**: With `colorScheme="dark"` (or a dark page) the fill is a lit grey over a dark grey track.

## Examples

### Below zero

A frost alert from −10 to 10 °C in half degrees, with a true minus sign below zero.

```tsx
import { SplitSlider } from './split-slider';

export function BelowZero() {
  return (
    <div style={{ width: 'min(100%, 360px)', margin: '0 auto' }}>
      <SplitSlider
        label="Frost alert"
        defaultValue={-1.5}
        min={-10}
        max={10}
        step={0.5}
        unit="°C"
        unitName="degrees Celsius"
      />
    </div>
  );
}
```

### Coarse steps

A wide zoom slider from 25 to 200% in steps of 25: the fill follows the pointer and settles on the nearest step when let go.

```tsx
import { SplitSlider } from './split-slider';

export function CoarseSteps() {
  return (
    <div style={{ width: 'min(100%, 640px)', margin: '0 auto' }}>
      <SplitSlider
        label="Zoom"
        defaultValue={100}
        min={25}
        max={200}
        step={25}
        unit="%"
        unitName="percent"
      />
    </div>
  );
}
```

### Compact

Smaller sliders, 44 px high, for a dense brush panel, with the ticks turned off.

```tsx
import { SplitSlider } from './split-slider';

const BRUSH = [
  { label: 'Size', value: 40, min: 1, unit: 'px', unitName: 'pixels' },
  { label: 'Hardness', value: 75, min: 0, unit: '%', unitName: 'percent' },
  { label: 'Flow', value: 50, min: 0, unit: '%', unitName: 'percent' },
  { label: 'Smoothing', value: 60, min: 0, unit: '%', unitName: 'percent' },
];

export function Compact() {
  return (
    <div style={{ display: 'grid', gap: 8, width: 'min(100%, 300px)', margin: '0 auto' }}>
      {BRUSH.map((setting) => (
        <SplitSlider
          key={setting.label}
          label={setting.label}
          defaultValue={setting.value}
          min={setting.min}
          unit={setting.unit}
          unitName={setting.unitName}
          height={44}
          sound={false}
        />
      ))}
    </div>
  );
}
```

### Colour mixer

Hue, saturation and lightness held by the parent, mixing the swatch above as they move.

```tsx
'use client';

import { useState } from 'react';
import { SplitSlider } from './split-slider';

export function ColourMixer() {
  const [hue, setHue] = useState(204);
  const [saturation, setSaturation] = useState(48);
  const [lightness, setLightness] = useState(56);
  return (
    <div style={{ display: 'grid', gap: 14, width: 'min(100%, 360px)', margin: '0 auto' }}>
      <div
        style={{
          height: 112,
          borderRadius: 20,
          background: `hsl(${hue} ${saturation}% ${lightness}%)`,
        }}
      />
      <SplitSlider label="Hue" value={hue} onChange={setHue} max={360} unit="" unitName="degrees" />
      <SplitSlider
        label="Saturation"
        value={saturation}
        onChange={setSaturation}
        unit="%"
        unitName="percent"
      />
      <SplitSlider
        label="Lightness"
        value={lightness}
        onChange={setLightness}
        unit="%"
        unitName="percent"
      />
    </div>
  );
}
```

## How it works

- The slider is drawn twice: its label, value and handle once in dark on the light track, and once in white on top, clipped with clip-path to the fill’s rounded shape. Wherever the fill reaches, the text turns white, down to half a letter.
- The fill’s end, its stretch past either end and a slight thinning are CSS variables. A spring integrated each frame moves them toward the value and stops once it settles, so React renders only when the value changes.
- The handle is two halves, each a short bar seen through a clip-path. Near the label or the value the clips shrink to dots at their outer ends: the handle splits a little ahead of a text in the direction it travels, and closes as soon as it is past. The texts’ places are measured once, and again when the slider or its font changes.
- Dragged past an end, the slider stretches along a rubber-band curve that gives less and less, up to a third of its height; let go, a lightly damped spring brings it back with a small wobble.
- Each step ticks with a click made by the Web Audio API: a short sine that drops in pitch over a burst of filtered noise, lower and quieter as the value falls. One AudioContext serves the page; it is created on the reader’s first press or key, suspended when the ticks stop and closed with the last slider.
- Every colour is a light-dark() pair on the root, so the slider takes the page’s color-scheme, or the colorScheme prop. The pairs sit in @supports, where builds that rewrite light-dark() leave them as written, so a page that sets no color-scheme stays light, and so do browsers without light-dark(). At night the fill is a lit grey over a dark grey track.

## 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 `50`): The value to start from when the slider holds its own.
- **onChange** (`(value: number) => void`): Called with each new value, as it is dragged or stepped.
- **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.
- **unit** (`string`, default `'px'`): Shown after the value; a percent sign sits against the number.
- **unitName** (`string`, default `'pixels'`): The unit as screen readers say it: “76 pixels”.
- **sound** (`boolean`, default `true`): Ticks on each step, once the reader has pressed or keyed the slider.
- **height** (`number`, default `58`): Its height in px; its text, corners and handle scale with it.
- **ref** (`Ref<SplitSliderHandle>`): Drives the slider as a pointer would (press, drag, release); the demo uses it.
- **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.
- **autoplay** (`boolean`, default `true`): On SplitSliderDemo, the stage of three sliders (which takes className too): while on screen and untouched, a ghost pointer drags the sliders now and then.

## Accessibility

- Each slider is a role="slider" named by its label, with aria-valuenow and an aria-valuetext that speaks its unit (“76 pixels”). The two drawn copies of the text and the handle are aria-hidden, so the value is heard once.
- The arrow keys step (Shift for ten steps), Page Up and Page Down take ten, Home and End go to the ends, and the focus ring follows the slider’s rounded shape.
- The click is never the only feedback. It starts only after the reader’s own press or key, never plays for the demo, and the mute button (a toggle with aria-pressed) silences it.
- On a phone a vertical swipe still scrolls the page. Under reduced motion there is no demo, no spring and no stretch: the fill moves straight to its value.

## Source

### split-slider/split-slider.tsx

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

'use client';

import {
  useEffect,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
  type Ref,
} from 'react';
import { clickSound, type ClickSound } from './click-sound';
import { GhostPointer, runDemo, type Point } from './ghost-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './split-slider.module.css';

/*
 * Split slider: a value slider whose dark fill carries its label, a thin handle and, as it grows,
 * its value.
 *
 * - The fill is a rounded bar that grows from the left, with the handle, a short white line, near
 *   its right end. The label and the value sit on the track; where the fill covers them they turn
 *   white. The slider's content is drawn twice, dark on the track and light on top, and the light
 *   copy is clipped to the fill's shape.
 * - When the handle comes to a text, it splits: its halves shrink into two dots, above and below
 *   the text, and let it show between them. It splits a little ahead of the text in the direction
 *   it moves, and closes as soon as it is past.
 * - Dragged past either end, the slider stretches like a rubber band, and springs back when let go.
 * - Each step ticks: a soft click synthesised with the Web Audio API (click-sound.ts), lower and
 *   quieter as the value falls. Sound starts with the reader's first press or key.
 * - The fill follows the value on springs integrated frame by frame and written to CSS variables,
 *   without a render per frame. At rest, nothing runs.
 * - It is a role="slider": arrow keys, Page Up and Down, Home and End.
 * - Its colours are light-dark() pairs in the stylesheet: it follows the page's color-scheme, or
 *   the one its colorScheme prop sets. At night the fill is a lit grey over a dark grey track.
 *
 * Below it, SplitSliderDemo: three of them on a white stage (near-black at night), dragged now and
 * then by a ghost pointer.
 */

/** Where the handle sits, as shares of the slider's height (the stylesheet gets them as variables). */
const GEOMETRY = {
  /** From the fill's end back to the handle. */
  inset: 0.31,
  /** The handle's leftmost place, where it stays as the fill runs out. */
  start: 0.217,
};
/** How near a text the handle splits, as shares of the height: ahead of its move, and behind. */
const AHEAD = 0.25;
const BEHIND = 0.08;
/** The most a drag past an end stretches the slider, as a share of its height. */
const STRETCH = 0.32;
/** How much a stretch thins the slider, for its share of the width. */
const THIN = 0.6;
/** The springs, as [stiffness, damping] per second. */
const FOLLOW = [1400, 75] as const; // under a pointer: close behind it
const GLIDE = [380, 39] as const; // to a value set by a key or a tap
const SNAP = [520, 17] as const; // a stretch let go: a rubber band's wobble
/** How far a touch moves sideways before it drags, in px. Up or down first, the page scrolls. */
const DRAG_START = 4;

/** Drives a slider as a pointer would, without a sound or an announcement (the demo uses it). */
export type SplitSliderHandle = {
  /** Where the handle is on the screen. */
  handlePoint: () => Point;
  /** Where a pointer holding the handle would be with the slider at `value` (past an end too). */
  pointFor: (value: number) => Point;
  /** Presses at `clientX`, a point on the screen. */
  press: (clientX: number) => void;
  /** Moves the press to `clientX`. */
  drag: (clientX: number) => void;
  /** Lets go. */
  release: () => void;
};

type Span = { left: number; right: number };

/** The slider's size and its texts' places, in its own px (before any scaling). */
type Box = { width: number; height: number; label: Span; value: Span };

type Press = {
  pointerId: number;
  startX: number;
  startY: number;
  /** From the press to the fill's end, kept while it drags: a press on the handle takes it there. */
  offset: number;
  /** The slider's place on the screen when pressed. */
  rect: DOMRect;
  /** Past DRAG_START (at once, for a mouse): the press drags. */
  dragging: boolean;
  /** The demo's press: silent. */
  demo: boolean;
};

/** Where the fill and its stretches are, and how fast they move. */
type Motion = {
  /** The fill's end, as a share of the width, and where it goes. */
  p: number;
  pSpeed: number;
  target: number;
  /** The stretch past the right end and past the left one, in px, and what the pointer asks. */
  over: number;
  overSpeed: number;
  overPull: number;
  under: number;
  underSpeed: number;
  underPull: number;
  /** A pointer holds the fill. */
  held: boolean;
  /** Which way the value last moved the fill (a stretch springing back does not count). */
  heading: 1 | -1;
  /** The fill's end when last drawn, for the heading. */
  drawnP: number;
  /** The handle's place, in px from the left end, and whether it is split. */
  handleX: number;
  split: boolean;
  time: number;
};

/** A drag's stretch past an end: it gives less and less, up to STRETCH of the height. */
const rubber = (distance: number, height: number) => {
  const most = STRETCH * height;
  return distance <= 0 ? 0 : (most * distance) / (distance + most * 2.2);
};

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

export function SplitSlider({
  label,
  value: controlled,
  defaultValue = 50,
  onChange,
  min = 0,
  max = 100,
  step = 1,
  unit = 'px',
  unitName = 'pixels',
  sound = true,
  height,
  colorScheme,
  ref,
  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. */
  defaultValue?: number;
  /** Called with each new value, as it is dragged or stepped. */
  onChange?: (value: number) => void;
  /** The lowest value. */
  min?: number;
  /** The highest value. */
  max?: number;
  /** The step of a drag and of the arrow keys; Page Up and Page Down take ten. */
  step?: number;
  /** Shown after the value: px, %. */
  unit?: string;
  /** The unit as screen readers say it: "76 pixels". */
  unitName?: string;
  /** Ticks on each step, once the reader has pressed or keyed the slider. */
  sound?: boolean;
  /** Its height in px, 58 by default; its text, corners and handle scale with it. */
  height?: number;
  /** Its colours: light, dark, or the reader's system setting. Unset, it follows the page's. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Drives the slider as a pointer would; the demo uses it. */
  ref?: Ref<SplitSliderHandle>;
  /** Extra class for the slider's root. */
  className?: string;
}) {
  const range = max - min || 1;
  const snap = (raw: number) =>
    Math.min(max, Math.max(min, Number((min + Math.round((raw - min) / step) * step).toFixed(6))));
  const shareOf = (n: number) => (n - min) / range;

  const [own, setOwn] = useState(() => snap(defaultValue));
  const value = controlled === undefined ? own : snap(controlled);
  const [dragging, setDragging] = useState(false);
  // The fill's place as the server draws it; after that, the springs write `--p` themselves.
  const [initial] = useState(() => shareOf(value));

  const root = useRef<HTMLDivElement>(null);
  const labelText = useRef<HTMLSpanElement>(null);
  const valueText = useRef<HTMLSpanElement>(null);
  const clicks = useRef<ClickSound | null>(null);
  const press = useRef<Press | null>(null);
  const frame = useRef(0);
  const latest = useRef(value);
  const box = useRef<Box>({
    width: 1,
    height: 1,
    label: { left: 0, right: 0 },
    value: { left: 0, right: 0 },
  });
  const motion = useRef<Motion>({
    p: initial,
    pSpeed: 0,
    target: initial,
    over: 0,
    overSpeed: 0,
    overPull: 0,
    under: 0,
    underSpeed: 0,
    underPull: 0,
    held: false,
    heading: -1,
    drawnP: initial,
    handleX: 0,
    split: false,
    time: 0,
  });

  /** Writes the fill, its stretch and the handle, and splits the handle by a text. */
  const draw = () => {
    const element = root.current;
    if (!element) return;
    const m = motion.current;
    const { width, height, label: around, value: beside } = box.current;
    element.style.setProperty('--p', m.p.toFixed(5));
    element.style.setProperty('--over', `${m.over.toFixed(2)}px`);
    element.style.setProperty('--under', `${m.under.toFixed(2)}px`);
    const thin = ((Math.abs(m.over) + Math.abs(m.under)) / width) * THIN;
    element.style.setProperty('--squash', (1 - thin).toFixed(4));

    // The handle, where the stylesheet draws it, and which way the value moves it.
    const inset = GEOMETRY.inset * height;
    const start = GEOMETRY.start * height;
    m.handleX = Math.max(m.p * width + m.over - inset, start) - m.under;
    if (Math.abs(m.p - m.drawnP) * width > 0.05) m.heading = m.p > m.drawnP ? 1 : -1;
    m.drawnP = m.p;
    // Whether it splits goes by the value alone: a stretch only takes it away from the texts.
    const x = Math.max(m.p * width - inset, start);
    const ahead = AHEAD * height;
    const behind = BEHIND * height;
    const near = ({ left, right }: Span) =>
      right > left &&
      x > left - (m.heading > 0 ? ahead : behind) &&
      x < right + (m.heading > 0 ? behind : ahead);
    const split = near(around) || near(beside);
    if (split !== m.split) {
      m.split = split;
      element.toggleAttribute('data-split', split);
    }
  };

  /** Runs the springs frame by frame until the fill and its stretches are at rest. */
  const animate = () => {
    if (frame.current) return;
    const m = motion.current;
    const resting =
      m.p === m.target &&
      m.over === m.overPull &&
      m.under === m.underPull &&
      !m.pSpeed &&
      !m.overSpeed &&
      !m.underSpeed;
    if (resting) return draw();
    m.time = performance.now();
    const tick = (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 - m.time) / 1000)) / 2;
      m.time = now;
      const [ks, cs] = m.held ? FOLLOW : GLIDE;
      const [kr, cr] = m.held ? FOLLOW : SNAP;
      for (let i = 0; i < 2; i += 1) {
        m.pSpeed += (ks * (m.target - m.p) - cs * m.pSpeed) * dt;
        m.p += m.pSpeed * dt;
        m.overSpeed += (kr * (m.overPull - m.over) - cr * m.overSpeed) * dt;
        m.over += m.overSpeed * dt;
        m.underSpeed += (kr * (m.underPull - m.under) - cr * m.underSpeed) * dt;
        m.under += m.underSpeed * dt;
      }
      let rest = true;
      if (Math.abs(m.target - m.p) < 0.0001 && Math.abs(m.pSpeed) < 0.001) {
        m.p = m.target;
        m.pSpeed = 0;
      } else rest = false;
      if (Math.abs(m.overPull - m.over) < 0.05 && Math.abs(m.overSpeed) < 0.5) {
        m.over = m.overPull;
        m.overSpeed = 0;
      } else rest = false;
      if (Math.abs(m.underPull - m.under) < 0.05 && Math.abs(m.underSpeed) < 0.5) {
        m.under = m.underPull;
        m.underSpeed = 0;
      } else rest = false;
      draw();
      frame.current = rest ? 0 : requestAnimationFrame(tick);
    };
    frame.current = requestAnimationFrame(tick);
  };

  /** Moves the fill to `share` of the width: on its springs, or at once under reduced motion. */
  const moveTo = (share: number, pull: { over: number; under: number } = { over: 0, under: 0 }) => {
    const m = motion.current;
    m.target = share;
    m.overPull = pull.over;
    m.underPull = pull.under;
    if (reducedMotion()) {
      cancelAnimationFrame(frame.current);
      frame.current = 0;
      Object.assign(m, { p: share, pSpeed: 0, over: 0, overSpeed: 0, under: 0, underSpeed: 0 });
      draw();
    } else animate();
  };

  /** Takes a new value; the reader's own steps tick. */
  const commit = (next: number, byReader: boolean) => {
    if (next === latest.current) return;
    latest.current = next;
    if (controlled === undefined) setOwn(next);
    onChange?.(next);
    if (byReader && sound) clicks.current?.play(shareOf(next));
  };

  /** Measures the slider and where its texts are (they set where the handle splits). */
  const measure = () => {
    const element = root.current;
    const around = labelText.current;
    const beside = valueText.current;
    if (!element || !around || !beside) return;
    box.current = {
      width: element.offsetWidth || 1,
      height: element.offsetHeight || 1,
      label: { left: around.offsetLeft, right: around.offsetLeft + around.offsetWidth },
      value: { left: beside.offsetLeft, right: beside.offsetLeft + beside.offsetWidth },
    };
    draw();
  };

  // The press's pointer, on the screen, to the fill's end it asks for and the value there.
  const follow = (clientX: number, byReader: boolean) => {
    const current = press.current;
    if (!current) return;
    const { width, height } = box.current;
    const scale = current.rect.width / width || 1;
    const x = (clientX + current.offset - current.rect.left) / scale;
    const still = reducedMotion();
    moveTo(Math.min(1, Math.max(0, x / width)), {
      over: still ? 0 : rubber(x - width, height),
      under: still ? 0 : rubber(-x, height),
    });
    commit(snap(min + (x / width) * range), byReader);
  };

  /** A press begins: on the handle it takes it where it is, elsewhere the fill comes to it. */
  const begin = (pointerId: number, clientX: number, clientY: number, demo: boolean) => {
    const element = root.current;
    if (!element) return;
    const rect = element.getBoundingClientRect();
    const { width, height } = box.current;
    const scale = rect.width / width || 1;
    const m = motion.current;
    const end = rect.left + (m.p * width + m.over - m.under) * scale;
    const handle = rect.left + m.handleX * scale;
    const onHandle =
      clientX > handle - height * 0.3 * scale && clientX < end + height * 0.2 * scale;
    press.current = {
      pointerId,
      startX: clientX,
      startY: clientY,
      offset: onHandle ? end - clientX : 0,
      rect,
      dragging: false,
      demo,
    };
  };

  /** The press drags from now on: the fill follows it closely. */
  const startDrag = (clientX: number, byReader: boolean) => {
    const current = press.current;
    if (!current) return;
    current.dragging = true;
    motion.current.held = true;
    setDragging(true);
    follow(clientX, byReader);
  };

  /** The press ends: the stretches spring back, the fill settles on the value. */
  const finish = () => {
    if (!press.current) return;
    press.current = null;
    motion.current.held = false;
    setDragging(false);
    moveTo(shareOf(latest.current));
  };

  useImperativeHandle(ref, () => {
    const point = (x: number): Point => {
      const element = root.current;
      if (!element) return { x: 0, y: 0 };
      const rect = element.getBoundingClientRect();
      const scale = rect.width / box.current.width || 1;
      return { x: rect.left + x * scale, y: rect.top + rect.height / 2 };
    };
    return {
      handlePoint: () => point(motion.current.handleX),
      pointFor: (target) => {
        const { width, height } = box.current;
        return point(((target - min) / range) * width - GEOMETRY.inset * height);
      },
      press: (clientX) => {
        if (press.current) return;
        begin(-1, clientX, 0, true);
        startDrag(clientX, false);
      },
      drag: (clientX) => {
        if (press.current?.demo) follow(clientX, false);
      },
      release: () => {
        if (press.current?.demo) finish();
      },
    };
  });

  // The sound, the measures, and their updates when the slider or its font changes.
  useEffect(() => {
    clicks.current = clickSound();
    const element = root.current;
    let alive = true;
    const observer = new ResizeObserver(() => measure());
    if (element) observer.observe(element);
    void document.fonts.ready.then(() => {
      if (alive) measure();
    });
    return () => {
      alive = false;
      observer.disconnect();
      cancelAnimationFrame(frame.current);
      frame.current = 0;
      clicks.current?.dispose();
      clicks.current = null;
    };
    // The measures read the elements, which stay the same.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // A value from the parent (or a key) moves the fill, unless a pointer holds it.
  const shown = String(value).replace('-', '\u2212');
  useLayoutEffect(() => {
    latest.current = value;
    if (!motion.current.held) moveTo(shareOf(value));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value]);
  // A value with more or fewer digits is wider: measure the text again.
  useLayoutEffect(() => {
    measure();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [shown.length]);

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    // A reader's press drops one the demo was making.
    if (press.current?.demo) finish();
    if (press.current) return;
    if (sound) clicks.current?.unlock();
    begin(event.pointerId, event.clientX, event.clientY, false);
    if (event.pointerType === 'mouse') {
      event.currentTarget.setPointerCapture(event.pointerId);
      startDrag(event.clientX, true);
    }
  };

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const current = press.current;
    if (!current || current.pointerId !== event.pointerId) return;
    if (!current.dragging) {
      const dx = event.clientX - current.startX;
      const dy = event.clientY - current.startY;
      // Up or down first: the page scrolls.
      if (Math.abs(dy) > DRAG_START && Math.abs(dy) > Math.abs(dx)) {
        press.current = null;
        return;
      }
      if (Math.abs(dx) < DRAG_START) return;
      event.currentTarget.setPointerCapture(event.pointerId);
      startDrag(event.clientX, true);
      return;
    }
    follow(event.clientX, true);
  };

  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
    const current = press.current;
    if (!current || current.pointerId !== event.pointerId) return;
    // A tap: the fill comes to it.
    if (!current.dragging) follow(event.clientX, true);
    // A touch counts as a gesture that may start sound only once it lifts.
    if (sound) clicks.current?.unlock();
    finish();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const big = step * 10;
    const moves: Partial<Record<string, number>> = {
      ArrowRight: value + (event.shiftKey ? big : step),
      ArrowUp: value + (event.shiftKey ? big : step),
      ArrowLeft: value - (event.shiftKey ? big : step),
      ArrowDown: value - (event.shiftKey ? big : step),
      PageUp: value + big,
      PageDown: value - big,
      Home: min,
      End: max,
    };
    const next = moves[event.key];
    if (next === undefined) return;
    event.preventDefault();
    if (sound) clicks.current?.unlock();
    commit(snap(next), true);
  };

  // A one-digit value keeps a digit's room (a figure space), so it ends where two digits end. Below
  // zero, a true minus sign (a digit wide) takes that room: −5 ends where 5 does.
  const figure = shown.length < 2 ? '\u2007'.repeat(2 - shown.length) + shown : shown;
  const content = (copy: 'base' | 'ink') => (
    <span
      className={`${styles.layer} ${copy === 'ink' ? styles.ink : ''}`}
      aria-hidden="true"
      data-part={copy}
    >
      <span
        ref={copy === 'base' ? labelText : undefined}
        className={styles.label}
        data-part="label"
      >
        {label}
      </span>
      <span
        ref={copy === 'base' ? valueText : undefined}
        className={styles.value}
        data-part="value"
      >
        {figure}
        {/* A percent sign sits against its number; other units come after a space. */}
        {unit && (unit === '%' ? unit : ` ${unit}`)}
      </span>
      <span className={styles.handle} data-part="handle">
        <span className={styles.half} />
        <span className={styles.half} />
      </span>
    </span>
  );

  return (
    <div
      ref={root}
      className={`${styles.slider} ${className}`}
      role="slider"
      tabIndex={0}
      aria-label={label}
      aria-valuemin={min}
      aria-valuemax={max}
      aria-valuenow={value}
      aria-valuetext={`${value} ${unitName}`}
      data-color-scheme={colorScheme}
      data-dragging={dragging || undefined}
      style={
        {
          '--p': initial,
          '--handle-inset': GEOMETRY.inset,
          '--handle-start': GEOMETRY.start,
          '--h': height === undefined ? undefined : `${height}px`,
        } as CSSProperties
      }
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={finish}
      onLostPointerCapture={(event) => {
        // A touch is first captured by the element under the finger: its loss, which bubbles
        // here when the slider takes the capture, is not the end of the press.
        if (event.target === event.currentTarget && press.current?.dragging) finish();
      }}
      onKeyDown={onKeyDown}
    >
      <span className={styles.track} aria-hidden="true" data-part="track" />
      {content('base')}
      <span className={styles.fill} aria-hidden="true" data-part="fill" />
      {content('ink')}
    </div>
  );
}

/*
 * The demo: three split sliders on a white stage (near-black at night), and a switch for their
 * sound. While the stage is on screen and untouched, a ghost pointer takes a slider by its handle
 * now and then and drags it: across its value, past its ends, down across its label.
 */

/** The sliders: what each one sets, its unit and the value it starts at. */
const SETTINGS = [
  { label: 'Blur', unit: 'px', unitName: 'pixels', value: 76 },
  { label: 'Radius', unit: 'px', unitName: 'pixels', value: 40 },
  { label: 'Opacity', unit: '%', unitName: 'percent', value: 58 },
] as const;

/**
 * The ghost's drags, in turn: a slider (its place in SETTINGS) and the values it drags it through,
 * with a pause at each. Values past 0 or 100 stretch the slider; it springs back when let go.
 */
const DRAGS: readonly { slider: number; path: readonly number[] }[] = [
  { slider: 0, path: [93] },
  { slider: 2, path: [100, 114] },
  { slider: 1, path: [10, -10] },
  { slider: 0, path: [76] },
  { slider: 1, path: [40] },
  { slider: 2, path: [58] },
];

function SpeakerIcon({ muted }: { muted: boolean }) {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true">
      <path className={styles.speaker} d="M3.5 7.6h2.7l3.9-3.2v11.2l-3.9-3.2H3.5z" />
      {muted ? (
        <path className={styles.waves} d="m13 7.8 4.2 4.4m0-4.4L13 12.2" />
      ) : (
        <path
          className={styles.waves}
          d="M13.2 7.4a3.6 3.6 0 0 1 0 5.2M15.5 5.2a6.8 6.8 0 0 1 0 9.6"
        />
      )}
    </svg>
  );
}

export function SplitSliderDemo({
  className = '',
  autoplay = true,
  colorScheme,
}: {
  /** Extra class for the root, a stage of its own. */
  className?: string;
  /** While on screen and untouched, a ghost pointer drags the sliders now and then. */
  autoplay?: boolean;
  /** The stage's colours, and its sliders': as on SplitSlider. */
  colorScheme?: 'light' | 'dark' | 'system';
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [muted, setMuted] = useState(false);
  /** Keyboard focus is on the stage: the ghost leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const sliders = useRef<(SplitSliderHandle | null)[]>([]);
  const pointer = useRef<SVGSVGElement>(null);
  const stopGhost = useRef<(() => void) | null>(null);

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

  const demo = autoplay && playing && !keyboard;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    /** The slider the ghost holds, let go when it stops. */
    let held: SplitSliderHandle | null = null;
    /** A point on the screen, in the stage's own px (the gallery draws the demo scaled down). */
    const local = ({ x, y }: Point): Point => {
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      return { x: (x - frame.left) / scale, y: (y - frame.top) / scale };
    };
    const stop = runDemo(
      stage,
      arrow,
      async ({ wait, moveTo, press, drag, lift }) => {
        await wait(900);
        for (let turn = 0; ; turn += 1) {
          const { slider, path } = DRAGS[turn % DRAGS.length];
          const handle = sliders.current[slider];
          if (!handle) return;
          await moveTo(local(handle.handlePoint()));
          await press();
          handle.press(handle.handlePoint().x);
          held = handle;
          for (const value of path) {
            const to = local(handle.pointFor(value));
            const from = local(handle.handlePoint());
            const ms = Math.min(1500, Math.max(520, Math.abs(to.x - from.x) * 9));
            // The stage's place, for the way back from its px to the screen's.
            const frame = stage.getBoundingClientRect();
            const scale = frame.width / stage.offsetWidth || 1;
            await drag(to, ms, (point) => handle.drag(frame.left + point.x * scale));
            await wait(value < 0 || value > 100 ? 420 : 260);
          }
          handle.release();
          held = null;
          await lift();
          // Off the slider, then a rest.
          const rest = local(handle.handlePoint());
          await moveTo({ x: rest.x + 24, y: rest.y + 34 });
          await wait(1500);
        }
      },
      () => {
        held?.release();
        held = null;
      },
    );
    stopGhost.current = stop;
    return stop;
  }, [demo, root]);

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

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div className={styles.scene}>
        <button
          type="button"
          className={styles.mute}
          aria-label="Mute clicks"
          aria-pressed={muted}
          title={muted ? 'Sound off' : 'Sound on'}
          onClick={() => setMuted((now) => !now)}
        >
          <SpeakerIcon muted={muted} />
        </button>
        <div className={styles.stack}>
          {SETTINGS.map((setting, index) => (
            <SplitSlider
              key={setting.label}
              ref={(handle) => {
                sliders.current[index] = handle;
              }}
              label={setting.label}
              defaultValue={setting.value}
              unit={setting.unit}
              unitName={setting.unitName}
              sound={!muted}
            />
          ))}
        </div>
      </div>
      <GhostPointer ref={pointer} />
    </div>
  );
}
```

### split-slider/split-slider.module.css

```css
/* Split slider (split-slider.tsx). Its proportions follow its height, --h (the height prop sets it
   inline). The component writes where the fill is as it moves: --p (its end, 0 to 1 of the width),
   --over and --under (how far it is stretched past the right or the left end, in px) and --squash
   (how much a stretch thins it). --handle-inset and --handle-start come from the component's
   GEOMETRY. */

/* The palette, on both roots (the slider and the demo's stage). Each colour is a light-dark() pair,
   so it follows the color-scheme in force: the page's, or the one the colorScheme prop sets. The
   pairs sit in @supports, where a build step that rewrites light-dark() for older browsers leaves
   them as written: rewritten, they would need a color-scheme the same step had rewritten too, and
   on a page that sets none every colour would drop. Without light-dark(), the light colours stay. */
.stage,
.slider {
  --surface: #fff;
  --surface-2: #eee;
  --hover: #f2f2f4;
  --line: rgb(0 0 0 / 0.06);
  --ink: #111;
  --ink-muted: #7c7c7c;
  --icon: #8e8e93;
  --icon-hover: #3a3a3c;
  --accent: #303030;
  --accent-2: #141414;
  --accent-ink: #fff;
  --accent-ink-muted: #c6c6c6;
  --highlight: rgb(255 255 255 / 0.07);
  --shadow: rgb(0 0 0 / 0.2);
  --shadow-2: rgb(0 0 0 / 0.1);
  --focus: #0a84ff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
}
@supports (color: light-dark(#fff, #000)) {
  .stage,
  .slider {
    /* The stage, the track (a step off it) and the mute button under the pointer. */
    --surface: light-dark(#fff, #1f1f1f);
    --surface-2: light-dark(#eee, #2d2d2d);
    --hover: light-dark(#f2f2f4, #2c2c2e);
    /* The stage's edge. */
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    /* The label and the value on the track, and the mute button's icon. */
    --ink: light-dark(#111, #ccc);
    --ink-muted: light-dark(#7c7c7c, #999);
    --icon: light-dark(#8e8e93, #8e8e93);
    --icon-hover: light-dark(#3a3a3c, #e5e5ea);
    /* The fill, top to foot, and what it carries: the label and handle, and the value. */
    --accent: light-dark(#303030, #5a5a5a);
    --accent-2: light-dark(#141414, #3f3f3f);
    --accent-ink: light-dark(#fff, #fff);
    --accent-ink-muted: light-dark(#c6c6c6, #d9d9d9);
    /* The fill's lit top edge and its shadows. */
    --highlight: light-dark(rgb(255 255 255 / 0.07), rgb(255 255 255 / 0.1));
    --shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.5));
    --shadow-2: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.3));
    /* Focus rings, and the demo's ghost pointer. */
    --focus: light-dark(#0a84ff, #409cff);
    --pointer: light-dark(#1c1c1e, #fff);
    --pointer-edge: light-dark(#fff, #000);
    --pointer-shadow: light-dark(rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.5));
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.stage[data-color-scheme='light'],
.slider[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'],
.slider[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'],
.slider[data-color-scheme='system'] {
  color-scheme: light dark;
}

.slider {
  --h: 58px;
  --r: calc(var(--h) * 0.345);
  --p: 0;
  --over: 0px;
  --under: 0px;
  --squash: 1;
  /* The fill's right end, and the handle near it (never left of its start). */
  --end: calc(var(--p) * 100% + var(--over) - var(--under));
  --handle-x: calc(
    max(
        var(--p) * 100% + var(--over) - var(--handle-inset) * var(--h),
        var(--handle-start) * var(--h)
      ) -
      var(--under)
  );
  /* The handle: its width, its length, and how far its dots move out when it splits. */
  --handle-w: 3px;
  --handle-l: calc(var(--h) * 0.44);
  --handle-out: calc(var(--h) * 0.046);
  position: relative;
  height: var(--h);
  border-radius: var(--r);
  outline: none;
  font:
    500 calc(var(--h) * 0.276) / 1 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  cursor: grab;
  /* A sideways drag moves it; up and down still scroll the page. */
  touch-action: pan-y;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
.slider[data-dragging] {
  cursor: grabbing;
}
.slider:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
.slider > * {
  pointer-events: none;
}

/* The track, which stretches to the left when dragged past its start. */
.track {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: calc(var(--under) * -1);
  border-radius: var(--r);
  background: var(--surface-2);
  transform: scaleY(var(--squash));
}

/* The fill: a dark bar from the left end, past the right one when stretched (lit grey at night). */
.fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--under) * -1);
  width: calc(var(--p) * 100% + var(--over));
  border-radius: var(--r);
  background: linear-gradient(var(--accent), var(--accent-2));
  box-shadow:
    inset 0 1px 0 var(--highlight),
    0 3px 8px var(--shadow),
    0 1px 2px var(--shadow-2);
  transform: scaleY(var(--squash));
}

/* The content, twice: dark on the track, and light on top, clipped to the fill's shape. */
.layer {
  position: absolute;
  inset: 0;
  color: var(--ink);
  transform: scaleY(var(--squash));
}
.ink {
  clip-path: inset(0 calc(100% - var(--end)) 0 calc(var(--under) * -1) round var(--r));
  color: var(--accent-ink);
}

.label,
.value {
  position: absolute;
  top: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  white-space: pre;
}
.label {
  left: calc(var(--h) * 0.453);
}
/* Left-aligned where a two-digit value starts: shorter values keep a digit's room, a third digit
   reaches to the right. */
.value {
  left: calc(100% - var(--h) * 1.285);
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  color: var(--ink-muted);
}
.ink .value {
  color: var(--accent-ink-muted);
}

/* The handle: two halves that meet in a line, and part into two dots by a text. Each half is a
   fixed bar reaching a little past the line's end; a clip shows the line's half of it, or only
   a dot at its outer end. The clip alone moves, so nothing is layered. */
.handle {
  position: absolute;
  top: 50%;
  left: var(--handle-x);
}
.half {
  --round: calc(var(--handle-w) / 2);
  position: absolute;
  left: calc(var(--handle-w) / -2);
  width: var(--handle-w);
  height: calc(var(--handle-l) / 2 + var(--handle-out) + var(--handle-w) / 2);
  background: currentColor;
  transition: clip-path 0.22s cubic-bezier(0.3, 0.7, 0.2, 1);
}
.half:first-child {
  bottom: calc(var(--handle-w) / -2);
  clip-path: inset(var(--handle-out) 0 0 0 round var(--round));
}
.half:last-child {
  top: calc(var(--handle-w) / -2);
  clip-path: inset(0 0 var(--handle-out) 0 round var(--round));
}
.slider[data-split] .half:first-child {
  clip-path: inset(0 0 calc(100% - var(--handle-w)) 0 round var(--round));
}
.slider[data-split] .half:last-child {
  clip-path: inset(calc(100% - var(--handle-w)) 0 0 0 round var(--round));
}

/* The demo's stage: white (near-black at night), with the sliders in its middle and the sound's
   switch in a corner. */
.stage {
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-tap-highlight-color: transparent;
}
.scene {
  display: grid;
  place-items: center;
  min-height: max(320px, 52cqi);
  padding: 56px 24px;
}
.stack {
  display: grid;
  gap: 14px;
  width: min(348px, 100%);
}

.mute {
  position: absolute;
  top: 14px;
  right: 14px;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--icon);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.mute svg {
  width: 20px;
  height: 20px;
  overflow: visible;
}
.speaker {
  fill: currentColor;
}
.waves {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
}
@media (hover: hover) {
  .mute:hover {
    background: var(--hover);
    color: var(--icon-hover);
  }
}
.mute:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

@container (max-width: 420px) {
  .scene {
    padding: 52px 20px;
  }
  .stack .slider {
    --h: 52px;
  }
}

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

### split-slider/click-sound.ts

```tsx
/*
 * The slider's click: a soft, short tick synthesised with the Web Audio API, no audio file. Each
 * tick is a sine blip that drops in pitch, over a breath of filtered noise for its edge, shaped by
 * an envelope a few milliseconds long. Higher values tick higher and louder.
 *
 * Browsers allow sound only after a gesture of the reader's, so nothing plays before their first
 * press or key on a slider. One AudioContext serves every slider on the page: it is created on
 * that first gesture, suspended when the ticks stop (an idle context still runs its audio thread),
 * and closed when the last slider is gone.
 */

/** A tick's length, in seconds. */
const LENGTH = 0.05;
/** Ticks closer than this (ms) are skipped: a fast drag would buzz. */
const MIN_GAP = 22;
/** After this long without a tick (ms), the audio is suspended. */
const REST = 1500;

let context: AudioContext | null = null;
let noise: AudioBuffer | null = null;
let users = 0;
let lastTick = 0;
let restTimer = 0;

/** The page's audio, made on a gesture of the reader's (or null where the API is missing). */
function audio() {
  if (!context && typeof AudioContext !== 'undefined') {
    context = new AudioContext();
    // 20 ms of white noise, shared by every tick.
    noise = context.createBuffer(1, Math.round(context.sampleRate * 0.02), context.sampleRate);
    const samples = noise.getChannelData(0);
    for (let i = 0; i < samples.length; i += 1) samples[i] = Math.random() * 2 - 1;
  }
  return context;
}

/** Suspends the audio once the ticks have stopped for a while. */
function restSoon() {
  window.clearTimeout(restTimer);
  restTimer = window.setTimeout(() => {
    if (context?.state === 'running') void context.suspend();
  }, REST);
}

/** One slider's use of the page's clicks. Call `dispose` when the slider unmounts. */
export function clickSound() {
  users += 1;
  let disposed = false;

  return {
    /** The reader pressed or keyed the slider: from now on, ticks may sound. */
    unlock() {
      const audible = audio();
      if (audible?.state === 'suspended') void audible.resume();
      restSoon();
    },

    /** Ticks once, at `level` of the range (0 to 1): lower and quieter as it falls. */
    play(level: number) {
      // Only once a gesture has made the audio.
      if (disposed || !context || !noise) return;
      const now = performance.now();
      if (now - lastTick < MIN_GAP) return;
      lastTick = now;
      if (context.state === 'suspended') void context.resume();
      restSoon();

      const share = Math.min(1, Math.max(0, level));
      const start = context.currentTime + 0.002;
      const pitch = 520 + 1180 * share;
      const peak = 0.014 + 0.05 * share ** 1.3;

      const out = context.createGain();
      out.gain.setValueAtTime(0, start);
      out.gain.linearRampToValueAtTime(peak, start + 0.0015);
      out.gain.exponentialRampToValueAtTime(0.0001, start + LENGTH);
      out.connect(context.destination);

      // The body: a sine that drops a little, as a tap on something hollow.
      const tone = context.createOscillator();
      tone.type = 'sine';
      tone.frequency.setValueAtTime(pitch, start);
      tone.frequency.exponentialRampToValueAtTime(pitch * 0.62, start + LENGTH * 0.8);
      tone.connect(out);

      // The edge: a few milliseconds of noise around three times the pitch.
      const edge = context.createBufferSource();
      edge.buffer = noise;
      const band = context.createBiquadFilter();
      band.type = 'bandpass';
      band.frequency.value = pitch * 3;
      band.Q.value = 0.9;
      const edgeGain = context.createGain();
      edgeGain.gain.setValueAtTime(0.7, start);
      edgeGain.gain.exponentialRampToValueAtTime(0.001, start + 0.012);
      edge.connect(band).connect(edgeGain).connect(out);

      tone.start(start);
      tone.stop(start + LENGTH);
      edge.start(start);
      edge.stop(start + 0.015);
      tone.onended = () => out.disconnect();
    },

    /** The slider is gone; the last one closes the audio. */
    dispose() {
      if (disposed) return;
      disposed = true;
      users -= 1;
      if (users > 0 || !context) return;
      window.clearTimeout(restTimer);
      void context.close();
      context = null;
      noise = null;
    },
  };
}

export type ClickSound = ReturnType<typeof clickSound>;
```

### split-slider/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 somewhere, press, drag, let go. A glide is a curved flight played by the
 * Web Animations API; a drag runs frame by frame, so that the script can move a slider along with
 * the pointer. Stopping the demo (the reader takes over, or it scrolls away) ends the step in
 * progress at once: its promise rejects, which ends the script right there, and the pointer fades
 * out.
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px. */
  moveTo: (to: Point) => Promise<void>;
  /** Presses and holds: the arrow dips. */
  press: () => Promise<void>;
  /** Drags to `to` over `ms`, eased, handing each point on the way to `each`. */
  drag: (to: Point, ms: number, each: (point: Point) => void) => Promise<void>;
  /** Lets go. */
  lift: () => Promise<void>;
};

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

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

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

/** Eases in and out, gently: a hand's drag. */
const ease = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);

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

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

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

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

  const press: Demo['press'] = () => {
    dip =
      pointer.firstElementChild?.animate([{ transform: 'none' }, { transform: 'scale(0.84)' }], {
        duration: 110,
        easing: 'ease-out',
        fill: 'forwards',
      }) ?? null;
    return wait(140);
  };

  const lift: Demo['lift'] = () => {
    dip?.reverse();
    return wait(120);
  };

  const drag: Demo['drag'] = (to, ms, each) => {
    const from = at;
    return step(
      new Promise<void>((resolve) => {
        let start = -1;
        const move = (now: number) => {
          if (!alive) return;
          if (start < 0) start = now;
          const t = Math.min(1, Math.max(0, (now - start) / ms));
          const e = ease(t);
          at = { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e };
          pointer.style.transform = place(at);
          each(at);
          if (t < 1) frame = requestAnimationFrame(move);
          else resolve();
        };
        frame = requestAnimationFrame(move);
      }),
    );
  };

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

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

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',
  // The stage's palette (split-slider.module.css): dark edged white, or white edged black at night.
  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, and invisible while no demo plays. */
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>
  );
}
```

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