# Mood slider

> A check-in card whose slider fills red or green from the middle while faces frown and smile above it, with soft synthesised ticks.

A dark card that asks one question at a time, “How are you feeling mentally today?”, and takes the answer on a slider that starts in the middle. Drag left and the track fills red to yellow, the left face grows and knits its brows; drag right and it fills yellow to green as the happy face beams. Each step ticks; at the left end a red ripple beats around the face with a soft thud, at the right a green ring and sparkles fly out to two chimes. VOTE blurs the question’s changing words away and brings the next one in.

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

Category: Inputs.

## Anatomy

1. **Counter**: Which question of how many, as `1/2`. Its changing digit rolls on a vote; with one question it is hidden but keeps its room.
2. **Question**: The current question, which labels the slider for screen readers. On a vote only the words that differ from the next question blur away.
3. **Faces**: Bad, okay and great, above the places the thumb takes at its ends and its middle. The nearest grows and the others shrink, redrawn every frame.
4. **Track**: A real `role="slider"` from 0 to 100: a press jumps the thumb there, a drag carries it, and arrows, Page Up/Down, Home and End move it.
5. **Fill**: Runs from the middle of the track to the thumb and wraps it: red to yellow on the left, yellow to green on the right.
6. **Thumb**: Rides a spring towards the pointer or the key’s step, so it lands at an end with a small bounce.
7. **End dot**: One at each end of the track, where the thumb’s centre stops, so the ends show before it gets there.
8. **Vote**: Records the answer (`onVote`), sends the thumb back to the middle and brings the next question; after the last, `onComplete` gets every answer.

## When to use

- A short check-in of one to five questions, asked one at a time: “How are you feeling today?”.
- Answers that are a feeling on a scale from bad to great, where a face says it faster than a number.
- A moment worth a little delight: the ends answer with a ripple, sparkles and a sound, so it suits screens seen once a day, not once a minute.
- Mobile-first surveys: one big thumb, one button, and the card scales as one drawing to any width.

## When not to use

- Exact numbers like a price, a volume or a 1–10 rating someone must read back. Use a plain slider with its value shown.
- Questions with named, unordered answers. Use radio buttons or a segmented control.
- Long forms or surveys of many questions: one card at a time and the animations slow them down. Use a form with plain inputs.
- Clinical or legal assessments with a validated scale: the faces and the colour fill lead the answer.
- Several questions that must be seen and compared together: the card only shows the current one.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { MoodSlider } from './mood-slider';

export function DailyCheckIn({ save }: { save: (answers: number[]) => void }) {
  return (
    <MoodSlider
      questions={[
        { title: 'How are you feeling mentally today?' },
        { title: 'How are you feeling about work today?', labels: ['Stuck', 'Fine', 'Flowing'] },
      ]}
      onComplete={(answers) => save(answers)}
      autoplay={false}
    />
  );
}
```

## States

- **Untouched**: Every question starts in the middle (`defaultValue`, 50): no fill, the okay face largest.
- **Leaning bad**: At 25 the fill runs left from the middle, red to yellow, and the bad face grows.
- **Leaning great**: At 75 the fill runs right, yellow to green, and the great face grows as it smiles wider.
- **Bad end**: At 0 a red ripple leaves the bad face, which frowns, glances and blinks: that life loops while it stays there and can’t be stopped.
- **Great end**: At 100 a green ring and a burst of sparkles leave the great face once, as it mounts.
- **Next question**: After a vote: the counter reads `2/2` and only the changed word of the question is new.
- **One question**: With a single question the counter is hidden; voting asks it again.
- **Dark**: With `colorScheme="dark"` (or a dark page) the card turns dark grey; the fill and the faces stay the same.

## How it works

- The thumb rides a damped spring (stiffness 560, damping 26, about 15% of bounce) stepped every 4 ms, so a fast drag into an end lands with a small bounce past it. Once per frame the spring writes a single custom property, --p, from 0 to 1, and stops when it settles.
- The thumb’s transform, the fill’s box and the faces’ sizes are calc()s of --p in the CSS. The fill runs from the middle of the track to the thumb and wraps it 3.75 units around: one box with two gradients, red to yellow and yellow to green, whose opacities cross over in the 2.5% around the middle.
- Each face sits where the thumb’s middle stops (the dots at the ends, and the middle) and scales from 0.66 of its size to full as the thumb comes within half the track of it.
- The faces are SVG drawings whose parts are rewritten from --p’s number: the left face’s mouth turns down and its brows drop in, the middle one looks towards the thumb, the right one’s smile widens.
- At the left end the angry face comes alive: it glances one way and back and blinks every 3.6 s, on its own frame loop that only runs while it can be seen.
- Reaching the left end sends a red ripple out of the face (1 to 1.42 times in 640 ms), again every 470 ms while a finger holds it there. Reaching the right end flashes a green ring out to 1.62 times in 900 ms and flings three four-pointed sparkles off it, with a fourth a moment later.
- The sound is synthesised with the Web Audio API, no files: a tick each time the value crosses a step of 5, a sine that drops in pitch over a breath of filtered noise, higher and louder towards the right. Reaching the left end adds a soft thud that falls (260 to 143 Hz in 160 ms); the right end, two chimes a fifth apart, 75 ms after one another. The vote pops with a short rising blip. One AudioContext serves the page: it is made on the reader’s first press or key on the card, suspended 1.5 s after the last sound and closed with the last card; the demo never sounds.
- VOTE keeps the words the next question shares (“How are you feeling”): the rest squash up into a blur in 180 ms, the counter’s digit rolls up, and the faces shrink away 35 ms apart while the thumb glides back to the middle on a critically damped spring. The next words drop in from a blur and a clip in 320 ms; the new faces pop in with a slight overshoot, left first.
- The question’s white-to-grey sweep is one gradient across the whole title, shared by its words: each word clips its own part of it, so the words can still move one by one.
- While on screen and untouched, the demo plays the questions with a touch mark for a finger: left, then all the way right, then VOTE, without a sound. Any key or press hands it to the reader where it was.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme or the colorScheme prop. By day the card is white on pale grey; the fills and faces are the same on both.

## API reference

- **questions** (`{ title: string; labels?: [string, string, string] }[]`, default `two mood questions`): The questions, asked in turn, and what the slider’s left, middle and right mean to screen readers (Bad, Okay, Great by default). After the last vote it starts again at the first; with one question the counter is hidden.
- **value** (`number`): The current question’s answer, 0 to 100, for a controlled slider.
- **defaultValue** (`number`, default `50`): Where the slider starts for each question.
- **defaultQuestion** (`number`): The index of the question the card opens on. Setting it turns the demo off.
- **onChange** (`(value: number, index: number) => void`): Called with the new answer and the question’s index as the reader moves the slider, and with the starting value and the next index after a vote.
- **onVote** (`(value: number, index: number) => void`): Called when the reader votes, with the answer and the question’s index.
- **onComplete** (`(values: number[]) => void`): Called after the last question’s vote with every answer.
- **voteLabel** (`string`, default `'Vote'`): The button’s words, shown in capitals.
- **sound** (`boolean`, default `true`): Soft synthesised ticks as the value moves, a soft thud at the left end, two chimes at the right one and a pop on VOTE, once the reader has pressed or keyed the card. The demo never makes a sound.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, the card plays both questions by itself, with a touch mark for a finger.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The track is a real slider (role="slider", 0 to 100), named by the question and read as “Bad, 20 of 100”. Arrows move it by 5, Page Up and Page Down by 20, Home and End to the ends, with a visible focus ring.
- A press anywhere on the track moves the thumb there; pressing the thumb itself grabs it where it was held. Vertical swipes on a phone still scroll the page.
- VOTE is a button. The next question is announced in a polite live region (“Question 2 of 2: …”), and so is the end of the round.
- The faces, rings, sparkles and touch mark are decorative and hidden from screen readers; the counter is spoken through the announcement instead.
- Sound only follows the reader’s own press or key, stays quiet (the loudest tick is soft), and can be turned off with sound={false}; nothing the card says depends on it.
- Under reduced motion the thumb goes straight to its place, the question swaps at once, the ends send no ripples or sparkles, the face stays still and the demo never plays.

## Source

### mood-slider/mood-slider.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { clickSound, type ClickSound } from './click-sound';
import { Icon, morphIcon, SMALL, type Life, type Slot } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './mood-slider.module.css';

/*
 * Mood slider: a check-in card that asks one question at a time ("How are you feeling mentally
 * today?") and takes the answer on a slider that starts in the middle. Three faces sit above it,
 * at the places the thumb reaches at its ends and in its middle; VOTE takes the next question.
 *
 * - The thumb rides a spring towards its target (the pointer, a key, the middle again after a
 *   vote), so it lands with a small bounce at an end. The spring writes one custom property, --p
 *   (0 to 1), once per frame; the thumb's transform, the fill's box and the icons' sizes are all
 *   calc()s of it in the CSS.
 * - The fill runs from the middle of the track to the thumb and wraps it: red to yellow on the
 *   left, yellow to green on the right. Both gradients share one box and cross over at the middle.
 * - The face nearest the thumb grows, the others shrink; their features are redrawn from --p's
 *   number (icons.tsx). Reaching the left end sends a red ripple out of the left face (again and
 *   again while it is held there) and wakes it up; reaching the right end, a green ring and a
 *   burst of sparkles.
 * - Sound (click-sound.ts, Web Audio, no files): a soft tick each 5 steps the value crosses,
 *   higher and louder towards the right; a falling thud at the left end, two rising chimes at the
 *   right end, a pop on VOTE. It starts with the reader's first press or key, never from the demo.
 * - VOTE blurs away the words of the question that change and the counter's digit, and the icons
 *   shrink away one after the other; the next question's words and icons come in the same way.
 * - While on screen and untouched, the demo plays the questions with a touch mark for a finger:
 *   left, then all the way right, then VOTE. It makes no sound.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type MoodQuestion = {
  /** The question. Words it shares with the one before stay put when it comes in. */
  title: string;
  /** What the left, middle and right of the slider mean, for screen readers. */
  labels?: [string, string, string];
};

const QUESTIONS: MoodQuestion[] = [
  { title: 'How are you feeling mentally today?' },
  { title: 'How are you feeling emotionally today?' },
];
const LABELS: [string, string, string] = ['Bad', 'Okay', 'Great'];
/** The value ticks each time it crosses a multiple of this. */
const TICK = 5;

/* The card's geometry, in its own units (the card is 382 wide). */
const CARD = 382;
/** The track: 276 wide; the thumb (47 wide) sits 3.75 inside it and travels 221.5. */
const TRACK = 276;
const INSET = 3.75;
const THUMB = 47;
const TRAVEL = TRACK - 2 * INSET - THUMB;
/** The track's left and the middle of its height; the middle of VOTE. */
const TRACK_X = 53;
const TRACK_Y = 223;
const VOTE_Y = 291;

/** The spring that follows a finger or a key: lands with about 15% of bounce. */
const FOLLOW = { stiffness: 560, damping: 26 };
/** The one that brings the thumb back to the middle after a vote, critically damped (~400 ms). */
const SETTLE = { stiffness: 190, damping: 27 };

const clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value));
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

const smooth = (t: number) => t * t * (3 - 2 * t);
/**
 * The angry face's life while the thumb stays at the left end, `t` seconds after it got there: it
 * glances one way and back, and blinks, every 3.6 s.
 */
function lifeAt(t: number): Life {
  const at = t % 3.6;
  const glance =
    at < 0.25
      ? 6 * smooth(at / 0.25)
      : at < 0.9
        ? 6
        : at < 1.2
          ? 6 - 9 * smooth((at - 0.9) / 0.3)
          : at < 3.3
            ? -3
            : -3 + 3 * smooth((at - 3.3) / 0.3);
  return { glance, blink: Math.max(0, 1 - Math.abs(at - 2.3) / 0.11) };
}

/** How many leading words two questions share. */
function sharedWords(a: string, b: string) {
  const left = a.split(' ');
  const right = b.split(' ');
  let count = 0;
  while (count < left.length && count < right.length && left[count] === right[count]) count++;
  // A question that only differs by its end keeps at least one word changing.
  return Math.min(count, right.length - 1);
}

/** The words a value reads as: the nearest of the three labels. */
const describe = (value: number, labels: [string, string, string]) =>
  labels[value < 34 ? 0 : value > 66 ? 2 : 1];

export function MoodSlider({
  questions = QUESTIONS,
  value,
  defaultValue = 50,
  defaultQuestion,
  onChange,
  onVote,
  onComplete,
  voteLabel = 'Vote',
  sound = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The questions, asked in turn; after the last one's vote it starts again at the first. */
  questions?: MoodQuestion[];
  /** The current question's answer, 0 to 100, for a controlled slider. */
  value?: number;
  /** Where the slider starts for each question (the middle). */
  defaultValue?: number;
  /** The question (its index) the card opens on; setting it turns the demo off. */
  defaultQuestion?: number;
  /** Called with the new answer and the question's index as the reader moves the slider. */
  onChange?: (value: number, index: number) => void;
  /** Called when the reader votes, with the answer and the question's index. */
  onVote?: (value: number, index: number) => void;
  /** Called after the last question's vote with every answer. */
  onComplete?: (values: number[]) => void;
  /** The button's words (shown in capitals). */
  voteLabel?: string;
  /** Soft synthesised ticks as the value moves, and sounds at the ends and on VOTE. */
  sound?: boolean;
  /** While on screen and untouched, the card plays the questions by itself, without sound. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(5000);
  const id = useId();
  const [index, setIndex] = useState(() =>
    defaultQuestion === undefined ? 0 : Math.max(0, Math.floor(defaultQuestion)) % questions.length,
  );
  const [own, setOwn] = useState(defaultValue);
  const [message, setMessage] = useState('');
  const current = value ?? own;
  const question = questions[index % questions.length];
  const labels = question.labels ?? LABELS;
  const total = questions.length;

  const card = useRef<HTMLDivElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const title = useRef<HTMLParagraphElement>(null);
  const counter = useRef<HTMLParagraphElement>(null);
  const touch = useRef<HTMLDivElement>(null);
  const slots = useRef<(HTMLDivElement | null)[]>([]);

  /** The spring and what the frame loop needs from the latest render. */
  const m = useRef({
    x: current / 100,
    v: 0,
    target: current / 100,
    spring: FOLLOW,
    frame: 0,
    last: 0,
    /** A finger or the demo's is pressing the slider. */
    held: false,
    /** The touch mark follows the thumb. */
    follow: false,
    /** At an end, until the thumb leaves it again. */
    low: false,
    high: false,
    reduced: false,
    /** The demo is playing: it makes no sound. */
    demo: false,
    /** The reader has moved the card since the demo last played: their moves may sound. */
    reader: false,
    /** The angry face's life, and the frame that plays it. */
    life: { glance: 0, blink: 0 } as Life,
    lifeFrame: 0,
    /** Near the screen: the face only lives while it can be seen. */
    onScreen: true,
  });
  const latest = useRef({ value: current, index });
  latest.current = { value: current, index };
  /** A vote's transition is running; and what it needs once the next question renders. */
  const busy = useRef(false);
  const swap = useRef({ shared: 0, counter: '', entering: false });
  const timers = useRef<number[]>([]);
  const grab = useRef<{ id: number; offset: number } | null>(null);
  /** Each question's answer, for onComplete. */
  const answers = useRef<number[]>([]);
  /** The sounds, and whether they may play. */
  const clicks = useRef<ClickSound | null>(null);
  const audible = useRef(sound);
  audible.current = sound;
  /** The reader is making a sound play: their own gesture, never the demo. */
  const voice = () =>
    audible.current && m.current.reader && !m.current.demo ? clicks.current : null;
  /** The reader's own press or key: it may start the sound, and their moves sound from now on. */
  const heard = () => {
    m.current.reader = true;
    if (audible.current) clicks.current?.unlock();
  };

  const later = (fn: () => void, ms: number) => {
    timers.current.push(window.setTimeout(fn, ms));
  };
  /** The card's unit in px. */
  const unit = () => (card.current ? card.current.offsetWidth / CARD : 1);

  /* The extremes: a red ripple from the left icon, a green ring and sparkles from the right one. */
  const ripple = () => {
    const ring = slots.current[0]?.querySelector<HTMLElement>('[data-ring="low"]');
    if (!ring || m.current.reduced) return;
    const u = unit();
    ring.animate(
      [
        { scale: '1', opacity: 0.95, borderWidth: `${1.5 * u}px` },
        { scale: '1.42', opacity: 0, borderWidth: `${0.8 * u}px` },
      ],
      { duration: 640, easing: 'cubic-bezier(0.2, 0.6, 0.35, 1)' },
    );
    // Held at the end, it beats on.
    later(() => {
      if (m.current.low && m.current.held) ripple();
    }, 470);
  };

  const sparkle = (
    spark: HTMLElement,
    angle: number,
    from: number,
    to: number,
    delay: number,
    duration: number,
  ) => {
    const u = unit();
    const at = (r: number) =>
      `${(Math.cos(angle) * r * u).toFixed(1)}px ${(Math.sin(angle) * r * u).toFixed(1)}px`;
    spark.animate(
      [
        { translate: at(from), scale: '0', opacity: 0, rotate: '-20deg' },
        { translate: at(to * 0.92), scale: '1.15', opacity: 1, rotate: '0deg', offset: 0.22 },
        { translate: at(to), scale: '0.7', opacity: 1, offset: 0.5 },
        { translate: at(to), scale: '1', opacity: 1, offset: 0.72 },
        { translate: at(to), scale: '0', opacity: 0, rotate: '15deg' },
      ],
      { duration, delay, easing: 'ease-out', fill: 'backwards' },
    );
  };

  const burst = () => {
    const slot = slots.current[2];
    if (!slot || m.current.reduced) return;
    const u = unit();
    slot.querySelector<HTMLElement>('[data-ring="high"]')?.animate(
      [
        { scale: '0.94', opacity: 0, borderWidth: `${1 * u}px` },
        { scale: '1.06', opacity: 1, borderWidth: `${2.6 * u}px`, offset: 0.1 },
        { scale: '1.62', opacity: 0, borderWidth: `${0.9 * u}px` },
      ],
      { duration: 900, easing: 'cubic-bezier(0.15, 0.6, 0.3, 1)' },
    );
    const sparks = slot.querySelectorAll<HTMLElement>('[data-spark]');
    const angles = [-140, -22, 128];
    angles.forEach((angle, i) => {
      const spark = sparks[i];
      if (spark) sparkle(spark, (angle * Math.PI) / 180, 20, 33, 60 + i * 70, 1250);
    });
    // One more twinkles a moment later, while the thumb stays at the end.
    later(() => {
      const spark = sparks[3];
      if (spark && m.current.high) sparkle(spark, (35 * Math.PI) / 180, 26, 30, 0, 1100);
    }, 1300);
  };

  /** The angry face comes alive at the left end, until the thumb leaves it. */
  const live = () => {
    const state = m.current;
    if (state.reduced || state.lifeFrame) return;
    const start = performance.now();
    const tick = (now: number) => {
      const svg = slots.current[0]?.querySelector<SVGSVGElement>('[data-icon] svg');
      if (!state.onScreen) {
        state.lifeFrame = 0;
        return;
      }
      if (!state.low || !svg) {
        state.lifeFrame = 0;
        state.life = { glance: 0, blink: 0 };
        if (svg) morphIcon(svg, 0, state.x);
        return;
      }
      state.life = lifeAt((now - start) / 1000);
      morphIcon(svg, 0, state.x, state.life);
      state.lifeFrame = requestAnimationFrame(tick);
    };
    state.lifeFrame = requestAnimationFrame(tick);
  };

  /** Draws one frame for the thumb at `x`. */
  const paint = (x: number) => {
    const state = m.current;
    card.current?.style.setProperty('--p', x.toFixed(4));
    slots.current.forEach((slot, i) => {
      const svg = slot?.querySelector<SVGSVGElement>('[data-icon] svg');
      if (svg) morphIcon(svg, i as Slot, x, i === 0 ? state.life : undefined);
    });
    if (x <= 0.006 && state.target === 0 && !state.low) {
      state.low = true;
      voice()?.low();
      ripple();
      live();
    }
    if (x > 0.06 && state.low) {
      state.low = false;
      state.life = { glance: 0, blink: 0 };
    }
    if (x >= 0.994 && state.target === 1 && !state.high) {
      state.high = true;
      voice()?.high();
      burst();
    }
    if (x < 0.94) state.high = false;
    if (state.follow && touch.current) {
      const u = unit();
      touch.current.style.translate = `${((TRACK_X + INSET + THUMB / 2 + x * TRAVEL) * u).toFixed(1)}px ${(TRACK_Y * u).toFixed(1)}px`;
    }
  };

  const step = (now: number) => {
    const state = m.current;
    const dt = Math.min(0.034, (now - state.last) / 1000 || 0.016);
    state.last = now;
    // Small fixed steps keep the spring steady whatever the frame rate.
    for (let t = 0; t < dt; t += 0.004) {
      const h = Math.min(0.004, dt - t);
      const a = -state.spring.stiffness * (state.x - state.target) - state.spring.damping * state.v;
      state.v += a * h;
      state.x += state.v * h;
    }
    const done = Math.abs(state.x - state.target) < 0.0004 && Math.abs(state.v) < 0.002;
    if (done) {
      state.x = state.target;
      state.v = 0;
    }
    paint(state.x);
    state.frame = done ? 0 : requestAnimationFrame(step);
  };

  /** Sends the thumb to `to` (0 to 1) on a spring; straight there under reduced motion. */
  const aim = (to: number, spring = FOLLOW) => {
    const state = m.current;
    state.target = clamp(to);
    state.spring = spring;
    if (state.reduced) {
      cancelAnimationFrame(state.frame);
      state.frame = 0;
      state.x = state.target;
      state.v = 0;
      paint(state.x);
      return;
    }
    if (!state.frame) {
      state.last = performance.now();
      state.frame = requestAnimationFrame(step);
    }
  };

  // The first frame, before paint, and reduced motion.
  useLayoutEffect(() => {
    const state = m.current;
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const read = () => {
      state.reduced = motion.matches;
    };
    read();
    paint(state.x);
    motion.addEventListener('change', read);
    clicks.current = clickSound();
    const observer = new IntersectionObserver(([entry]) => {
      state.onScreen = entry.isIntersecting;
      if (state.onScreen && state.low) live();
    });
    if (card.current) observer.observe(card.current);
    const pending = timers.current;
    return () => {
      motion.removeEventListener('change', read);
      observer.disconnect();
      cancelAnimationFrame(state.frame);
      cancelAnimationFrame(state.lifeFrame);
      state.frame = 0;
      state.lifeFrame = 0;
      pending.forEach((timer) => window.clearTimeout(timer));
      clicks.current?.dispose();
      clicks.current = null;
    };
  }, []);

  // A new value from outside (or a key) moves the thumb; a drag already did.
  useLayoutEffect(() => {
    if (Math.abs(m.current.target * 100 - current) >= 0.5) aim(current / 100, m.current.spring);
  }, [current]);

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

  /** Takes a new answer; `quiet` for the demo's and the vote's, which neither report nor tick. */
  const commit = (next: number, quiet = false) => {
    const rounded = Math.round(clamp(next, 0, 100));
    const before = latest.current.value;
    if (rounded === before) return;
    latest.current.value = rounded;
    if (value === undefined) setOwn(rounded);
    if (quiet) return;
    onChange?.(rounded, latest.current.index);
    // A tick each time the value crosses a step, at its pitch.
    if (Math.floor(rounded / TICK) !== Math.floor(before / TICK)) voice()?.play(rounded / 100);
  };

  /* The slider by pointer: a press jumps the thumb there (or grabs it), a drag carries it. */
  const fractionAt = (clientX: number) => {
    const element = track.current;
    if (!element) return 0.5;
    const rect = element.getBoundingClientRect();
    const scale = rect.width / TRACK;
    return (clientX - rect.left - (INSET + THUMB / 2) * scale) / (TRAVEL * scale);
  };
  const moveTo = (fraction: number) => {
    const to = clamp(fraction);
    aim(to, FOLLOW);
    commit(to * 100);
  };
  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || grab.current) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    event.currentTarget.focus({ preventScroll: true });
    heard();
    const at = fractionAt(event.clientX);
    const onThumb = Math.abs(at - m.current.x) < THUMB / 2 / TRAVEL;
    grab.current = { id: event.pointerId, offset: onThumb ? at - m.current.x : 0 };
    m.current.held = true;
    moveTo(at - grab.current.offset);
  };
  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (grab.current?.id !== event.pointerId) return;
    moveTo(fractionAt(event.clientX) - grab.current.offset);
  };
  const onPointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (grab.current?.id !== event.pointerId) return;
    grab.current = null;
    m.current.held = false;
    // A touch counts as a gesture that may start sound only once it lifts.
    if (event.type === 'pointerup') heard();
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const now = latest.current.value;
    const keys: Record<string, number> = {
      ArrowLeft: now - 5,
      ArrowDown: now - 5,
      ArrowRight: now + 5,
      ArrowUp: now + 5,
      PageDown: now - 20,
      PageUp: now + 20,
      Home: 0,
      End: 100,
    };
    const next = keys[event.key];
    if (next === undefined) return;
    event.preventDefault();
    heard();
    const to = clamp(next, 0, 100);
    aim(to / 100, FOLLOW);
    commit(to);
  };

  /* VOTE: the changing words, the counter's digit and the icons leave; the next question comes. */
  const vote = async (fromDemo = false) => {
    if (busy.current) return;
    busy.current = true;
    const { value: answer, index: at } = latest.current;
    const next = (at + 1) % total;
    if (!fromDemo) {
      heard();
      voice()?.vote();
      onVote?.(answer, at);
      answers.current[at] = answer;
      if (next === 0) onComplete?.(questions.map((_, i) => answers.current[i] ?? defaultValue));
    }
    const words = [...(title.current?.querySelectorAll<HTMLElement>('[data-word]') ?? [])];
    const shared = sharedWords(questions[at].title, questions[next].title);
    const oldCounter = `${at + 1}/${total}`;
    swap.current = { shared, counter: oldCounter, entering: true };
    // The end's ripples, ring and sparkles stop with the question.
    m.current.high = false;
    m.current.low = false;
    slots.current.forEach((slot) =>
      slot
        ?.querySelectorAll<HTMLElement>('[data-ring], [data-spark]')
        .forEach((element) => element.getAnimations().forEach((animation) => animation.cancel())),
    );
    // The thumb glides back to the middle (quietly: the vote made its own sound).
    aim(defaultValue / 100, SETTLE);
    commit(defaultValue, true);
    if (!fromDemo) onChange?.(defaultValue, next);

    if (!m.current.reduced) {
      const newCounter = `${next + 1}/${total}`;
      const leaving: Animation[] = [];
      words.slice(shared).forEach((word, i) => {
        leaving.push(
          word.animate(
            [{ opacity: 0, filter: 'blur(0.22em)', transform: 'translateY(-0.42em) scaleY(0.3)' }],
            {
              duration: 180,
              delay: i * 12,
              easing: 'cubic-bezier(0.5, 0, 0.75, 0)',
              fill: 'forwards',
            },
          ),
        );
      });
      counter.current?.querySelectorAll<HTMLElement>('[data-char]').forEach((char, i) => {
        if (oldCounter[i] === newCounter[i]) return;
        leaving.push(
          char.animate([{ opacity: 0, filter: 'blur(0.15em)', transform: 'translateY(-0.7em)' }], {
            duration: 170,
            easing: 'ease-in',
            fill: 'forwards',
          }),
        );
      });
      const u = unit();
      slots.current.forEach((slot, i) => {
        const pop = slot?.querySelector<HTMLElement>('[data-pop]');
        if (!pop) return;
        leaving.push(
          pop.animate([{ scale: '0', opacity: 0, filter: `blur(${2 * u}px)` }], {
            duration: 170,
            delay: i * 35,
            easing: 'cubic-bezier(0.5, 0, 0.75, 0)',
            fill: 'forwards',
          }),
        );
      });
      await Promise.all(leaving.map((animation) => animation.finished.catch(() => undefined)));
    }
    setIndex(next);
    const heading = `Question ${next + 1} of ${total}: ${questions[next].title}`;
    announce(next === 0 ? `Thanks, your answers are in. ${heading}` : heading);
    if (m.current.reduced) busy.current = false;
  };
  // The next question's changing words, digit and icons come in.
  useLayoutEffect(() => {
    if (!swap.current.entering) return;
    swap.current.entering = false;
    const { shared, counter: oldCounter } = swap.current;
    const done = () => {
      busy.current = false;
    };
    if (m.current.reduced) return done();
    const u = unit();
    const words = [...(title.current?.querySelectorAll<HTMLElement>('[data-word]') ?? [])];
    // Words, digit and icons left with fill: forwards; their old animations go with the old nodes,
    // or are dropped here.
    const all = [
      ...words,
      ...(counter.current?.querySelectorAll<HTMLElement>('[data-char]') ?? []),
      ...slots.current.map((slot) => slot?.querySelector<HTMLElement>('[data-pop]')),
    ];
    all.forEach((element) => element?.getAnimations().forEach((animation) => animation.cancel()));
    const entering: Animation[] = [];
    words.slice(shared).forEach((word, i) => {
      entering.push(
        word.animate(
          [
            {
              opacity: 0,
              filter: 'blur(0.22em)',
              transform: 'translateY(0.3em) scaleY(0.6)',
              clipPath: 'inset(0 0 100% 0)',
            },
            { opacity: 1, filter: 'blur(0)', transform: 'none', clipPath: 'inset(0 0 -20% 0)' },
          ],
          {
            duration: 320,
            delay: i * 18,
            easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)',
            fill: 'backwards',
          },
        ),
      );
    });
    const newCounter = `${latest.current.index + 1}/${total}`;
    counter.current?.querySelectorAll<HTMLElement>('[data-char]').forEach((char, i) => {
      if (oldCounter[i] === newCounter[i]) return;
      entering.push(
        char.animate(
          [
            { opacity: 0, filter: 'blur(0.15em)', transform: 'translateY(0.7em)' },
            { opacity: 1, filter: 'blur(0)', transform: 'none' },
          ],
          { duration: 260, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)', fill: 'backwards' },
        ),
      );
    });
    slots.current.forEach((slot, i) => {
      const pop = slot?.querySelector<HTMLElement>('[data-pop]');
      if (!pop) return;
      entering.push(
        pop.animate(
          [
            { scale: '0', opacity: 0, filter: `blur(${2 * u}px)` },
            { scale: '1.08', opacity: 1, filter: 'blur(0px)', offset: 0.6 },
            { scale: '1', opacity: 1, filter: 'blur(0px)' },
          ],
          {
            duration: 380,
            delay: i * 45,
            easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)',
            fill: 'backwards',
          },
        ),
      );
    });
    // The icons are new drawings: draw them for where the thumb is.
    paint(m.current.x);
    Promise.all(entering.map((animation) => animation.finished.catch(() => undefined))).then(done);
  }, [index]);

  // The title's light-to-grey sweep is one gradient across the whole question, shared by its
  // words: each word shows its own part of it.
  useLayoutEffect(() => {
    const element = title.current;
    if (!element) return;
    const measure = () => {
      element.style.setProperty('--gw', `${element.offsetWidth}px`);
      element.style.setProperty('--gh', `${element.offsetHeight}px`);
      element.querySelectorAll<HTMLElement>('[data-word]').forEach((word) => {
        word.style.setProperty('--gx', `${element.offsetLeft - word.offsetLeft}px`);
        word.style.setProperty('--gy', `${element.offsetTop - word.offsetTop}px`);
      });
    };
    measure();
    // Words move when the face loads, though the title may keep its size.
    let alive = true;
    document.fonts?.ready.then(() => alive && measure());
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => {
      alive = false;
      observer.disconnect();
    };
  }, [question.title]);

  /* The touch mark: the demo's finger. */
  const touchAt = (x: number, y: number, press: boolean) => {
    const mark = touch.current;
    if (!mark) return;
    const u = unit();
    mark.style.translate = `${(x * u).toFixed(1)}px ${(y * u).toFixed(1)}px`;
    if (press) {
      mark.dataset.down = 'true';
      mark.querySelector('[data-halo]')?.animate(
        [
          { scale: '1.4', opacity: 0.9 },
          { scale: '1', opacity: 0 },
        ],
        { duration: 420, easing: 'ease-out' },
      );
    } else delete mark.dataset.down;
  };

  // The demo: like a person checking in. Left first, then all the way right, then VOTE.
  const demoVote = useEffectEvent(() => vote(true));
  const demoValue = useEffectEvent((to: number) => commit(to, true));
  const demo = autoplay && defaultQuestion === undefined && playing;
  useEffect(() => {
    if (!demo) return;
    const state = m.current;
    state.demo = true;
    state.reader = false;
    let alive = true;
    let frame = 0;
    const ids: number[] = [];
    const stop = new Error('stopped');
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        ids.push(window.setTimeout(() => (alive ? resolve() : reject(stop)), ms));
      });
    /** Drags the thumb from where it is towards `to` (past an end, so it lands with a bounce). */
    const drag = (to: number, ms: number) =>
      new Promise<void>((resolve, reject) => {
        const from = state.target;
        const start = performance.now();
        const tick = (now: number) => {
          if (!alive) return reject(stop);
          const t = Math.min(1, (now - start) / ms);
          aim(from + (to - from) * easeInOut(t), FOLLOW);
          if (t < 1) frame = requestAnimationFrame(tick);
          else resolve();
        };
        frame = requestAnimationFrame(tick);
      });
    const press = async () => {
      state.follow = true;
      state.held = true;
      touchAt(TRACK_X + INSET + THUMB / 2 + state.x * TRAVEL, TRACK_Y, true);
      await wait(170);
    };
    const lift = () => {
      state.follow = false;
      state.held = false;
      touchAt(TRACK_X + INSET + THUMB / 2 + state.x * TRAVEL, TRACK_Y, false);
    };
    (async () => {
      await wait(900);
      for (;;) {
        await press();
        await drag(-0.12, 820);
        demoValue(0);
        await wait(260);
        lift();
        // The face at the left end comes alive meanwhile.
        await wait(1800);
        await press();
        await drag(1.1, 820);
        demoValue(100);
        await wait(300);
        lift();
        await wait(2100);
        touchAt(CARD / 2, VOTE_Y, true);
        await wait(240);
        touchAt(CARD / 2, VOTE_Y, false);
        demoVote();
        await wait(1500);
      }
    })().catch(() => undefined);
    return () => {
      alive = false;
      cancelAnimationFrame(frame);
      ids.forEach((timer) => window.clearTimeout(timer));
      state.follow = false;
      state.held = false;
      state.demo = false;
      if (touch.current) delete touch.current.dataset.down;
      // The reader takes over where the demo left the thumb.
      const at = Math.round(clamp(state.target) * 100);
      aim(at / 100, FOLLOW);
      demoValue(at);
    };
  }, [demo]);

  const counterText = `${index + 1}/${total}`;
  const words = question.title.split(' ');

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.stage}>
        <div ref={card} className={styles.card}>
          <p
            ref={counter}
            className={styles.counter}
            data-part="counter"
            aria-hidden="true"
            // One question needs no counter; its room stays, so the card keeps its shape.
            style={total < 2 ? { visibility: 'hidden' } : undefined}
          >
            {[...counterText].map((char, i) => (
              <span key={`${index}-${i}`} data-char className={styles.char}>
                {char}
              </span>
            ))}
          </p>
          <p ref={title} id={`${id}-title`} className={styles.title} data-part="question">
            {words.map((word, i) => (
              <span key={`${index}-${i}`}>
                {i > 0 && ' '}
                <span data-word className={styles.word}>
                  {word}
                </span>
              </span>
            ))}
          </p>
          <div className={styles.icons} data-part="faces" aria-hidden="true">
            {([0, 1, 2] as Slot[]).map((slot) => (
              <div
                key={slot}
                ref={(element) => {
                  slots.current[slot] = element;
                }}
                className={styles.slot}
                data-part="face"
                style={{ '--at': slot / 2, '--small': SMALL } as CSSProperties}
              >
                {slot === 0 && <span data-ring="low" className={styles.ringLow} />}
                {slot === 2 && (
                  <>
                    <span data-ring="high" className={styles.ringHigh} />
                    {['green', 'gold', 'lime', 'green'].map((tone, i) => (
                      <span key={i} data-spark className={styles.spark} data-tone={tone}>
                        <svg viewBox="0 0 20 20" focusable="false">
                          <path d="M10 0c.8 6 4 9.2 10 10-6 .8-9.2 4-10 10-.8-6-4-9.2-10-10 6-.8 9.2-4 10-10z" />
                        </svg>
                      </span>
                    ))}
                  </>
                )}
                <div data-pop className={styles.pop} key={index}>
                  <div data-icon className={styles.grow}>
                    <Icon slot={slot} id={`${id}-${index}-${slot}`} className={styles.art} />
                  </div>
                </div>
              </div>
            ))}
          </div>
          <div
            ref={track}
            className={styles.track}
            data-part="track"
            role="slider"
            tabIndex={0}
            aria-labelledby={`${id}-title`}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={current}
            aria-valuetext={`${describe(current, labels)}, ${current} of 100`}
            aria-orientation="horizontal"
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerEnd}
            onPointerCancel={onPointerEnd}
            onLostPointerCapture={onPointerEnd}
            onKeyDown={onKeyDown}
          >
            <span className={styles.dot} data-part="end" data-end="low" />
            <span className={styles.dot} data-part="end" data-end="high" />
            <span className={styles.fill} data-part="fill">
              <span className={styles.fillLow} />
              <span className={styles.fillHigh} />
            </span>
            <span className={styles.thumb} data-part="thumb">
              <span className={styles.grip} data-part="grip" />
            </span>
          </div>
          <div className={styles.footer}>
            <button type="button" className={styles.vote} data-part="vote" onClick={() => vote()}>
              {voteLabel}
            </button>
          </div>
          <div ref={touch} className={styles.touch} aria-hidden="true">
            <span data-halo className={styles.halo} />
          </div>
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### mood-slider/mood-slider.module.css

```css
/* Mood slider (mood-slider.tsx): a check-in card on a stage. At night the stage is near-black and
   the card a dark grey; by day the card is white on a pale grey.

   Every size is in --u, a unit of the card, which is 382 units wide. The stage sets the unit from
   its own width (a container query), so the whole card scales as one drawing.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). The fills and the faces are the same on both.

   Memory. The card is one layout boundary. The thumb moves by a transform; the fill's box and the
   icons' sizes are calc()s of one custom property, --p, that the spring writes once per frame. */

/* A length that stays the card's unit wherever it is read, even in other type sizes. */
@property --u {
  syntax: '<length>';
  inherits: true;
  initial-value: 1px;
}

.root {
  /* The stage, the card and its lift. */
  --stage: #efefef;
  --card: #fff;
  --edge: rgb(0 0 0 / 0.05);
  --lift: rgb(0 0 0 / 0.06);
  /* The question, light at its start and greyer towards its end; the counter. */
  --ink: #141414;
  --ink-end: #707070;
  --muted: #9a9a9a;
  /* The track, its end dots, the line over VOTE and the words of VOTE. */
  --track: #efefef;
  --track-edge: rgb(0 0 0 / 0.03);
  --dot: #c8c8c8;
  --rule: #ececec;
  --vote: #141414;
  /* The thumb: white, with a soft shade under it, and its grip. */
  --thumb: #fff;
  --thumb-shade: #ececec;
  --thumb-shadow: rgb(0 0 0 / 0.2);
  --grip: #d4d4d4;
  /* The same on both schemes: the focus ring, the red ripple, the green ring and the sparkles,
     and the touch mark of the demo's finger. */
  --focus: #387bff;
  --ripple: #e2441c;
  --ring: #2bb866;
  --spark-green: #35d282;
  --spark-gold: #e6b94c;
  --spark-lime: #a9d861;
  position: relative;
  container-type: inline-size;
  contain: layout;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--edge);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#efefef, #1a1a1a);
    --card: light-dark(#fff, #262626);
    --edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.04));
    --lift: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.25));
    --ink: light-dark(#141414, #fff);
    --ink-end: light-dark(#707070, #c2c2c2);
    --muted: light-dark(#9a9a9a, #777);
    --track: light-dark(#efefef, #303030);
    --track-edge: light-dark(rgb(0 0 0 / 0.03), rgb(0 0 0 / 0.12));
    --dot: light-dark(#c8c8c8, #545454);
    --rule: light-dark(#ececec, #2f2f2f);
    --vote: light-dark(#141414, #f2f2f2);
    --thumb: light-dark(#fff, #fafafa);
    --thumb-shade: light-dark(#ececec, #e4e4e4);
    --thumb-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.42));
    --grip: light-dark(#d4d4d4, #d6d6d6);
  }
}
/* The colorScheme prop. Without it, 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;
}

/* The stage: the card in its middle, with room around it. The card is 84% of the stage's width,
   at most 573 px (1.5 px a unit). */
.stage {
  --u: min(calc(100cqi * 0.84 / 382), 1.5px);
  display: grid;
  place-items: center;
  padding: calc(34 * var(--u)) 0;
}

.card {
  position: relative;
  box-sizing: border-box;
  width: calc(382 * var(--u));
  min-height: calc(327 * var(--u));
  padding: 0 0 calc(1 * var(--u));
  border-radius: calc(20 * var(--u));
  background: var(--card);
  box-shadow:
    0 0 0 max(1px, calc(0.6 * var(--u))) var(--edge),
    0 calc(8 * var(--u)) calc(28 * var(--u)) var(--lift);
  /* The site's geometric face, else the page's. */
  font-family: Satoshi, Inter, system-ui, sans-serif;
  text-align: center;
  -webkit-font-smoothing: antialiased;
  user-select: none;
  -webkit-user-select: none;
}

/* "1/2": its digit rolls on a vote. */
.counter {
  height: calc(14 * var(--u));
  margin: calc(33.6 * var(--u)) 0 0;
  color: var(--muted);
  font-size: calc(10.5 * var(--u));
  font-weight: 500;
  line-height: calc(14 * var(--u));
  letter-spacing: 0.12em;
}
.char {
  display: inline-block;
}

/* The question: two lines, the changing words blur across on a vote. */
.title {
  --gw: 100%;
  --gh: 100%;
  min-height: calc(61 * var(--u));
  margin: calc(5.2 * var(--u)) auto 0;
  max-width: calc(300 * var(--u));
  color: var(--ink);
  font-size: calc(25.5 * var(--u));
  font-weight: 700;
  line-height: calc(30.5 * var(--u));
  letter-spacing: 0.018em;
  text-wrap: balance;
}
.word {
  display: inline-block;
  transform-origin: 50% 0;
  background: linear-gradient(160deg, var(--ink) 52%, var(--ink-end) 100%) var(--gx, 0)
    var(--gy, 0) / var(--gw) var(--gh) no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* The faces: three slots at the thumb's places for 0, the middle and 1. */
.icons {
  position: relative;
  height: calc(64 * var(--u));
  margin: calc(18.9 * var(--u)) calc(53 * var(--u)) 0;
}
.slot {
  --q: clamp(0, var(--p, 0.5), 1);
  position: absolute;
  top: 50%;
  left: calc((27.25 + var(--at) * 221.5) * var(--u));
  width: 0;
  height: 0;
}
.pop {
  position: absolute;
  top: 0;
  left: 0;
}
/* The nearer the thumb, the larger: from --small on to full size where the thumb is on it. */
.grow {
  translate: -50% -50%;
  scale: calc(
    var(--small) + (1 - var(--small)) *
      max(0, 1 - 2 * max(var(--q) - var(--at), var(--at) - var(--q)))
  );
}
.art {
  display: block;
  width: calc(38 * var(--u));
  height: calc(38 * var(--u));
  overflow: visible;
  filter: drop-shadow(0 calc(1.5 * var(--u)) calc(2 * var(--u)) rgb(0 0 0 / 0.22));
  transition: filter 300ms ease;
}

/* The rings at the ends: red ripples on the left face, a green ring on the right one. */
.ringLow,
.ringHigh {
  position: absolute;
  top: 0;
  left: 0;
  box-sizing: border-box;
  width: calc(42 * var(--u));
  height: calc(42 * var(--u));
  translate: -50% -50%;
  border: calc(1.5 * var(--u)) solid var(--ripple);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}
.ringHigh {
  border-color: var(--ring);
}
/* Four-pointed sparkles, flung out with the green ring. */
.spark {
  position: absolute;
  top: 0;
  left: 0;
  width: calc(9 * var(--u));
  height: calc(9 * var(--u));
  margin: calc(-4.5 * var(--u)) 0 0 calc(-4.5 * var(--u));
  opacity: 0;
  pointer-events: none;
  color: var(--spark-green);
}
.spark[data-tone='gold'] {
  color: var(--spark-gold);
  scale: 0.9;
}
.spark[data-tone='lime'] {
  color: var(--spark-lime);
}
.spark svg {
  display: block;
  width: 100%;
  height: 100%;
  fill: currentColor;
}

/* The track: the dots mark where the thumb's middle stops at either end. */
.track {
  position: relative;
  height: calc(41.5 * var(--u));
  margin: calc(4.25 * var(--u)) calc(53 * var(--u)) 0;
  border-radius: 999px;
  background: var(--track);
  box-shadow: inset 0 calc(0.6 * var(--u)) calc(1.2 * var(--u)) var(--track-edge);
  touch-action: pan-y;
  cursor: pointer;
  outline: none;
}
.track:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(3 * var(--u));
}
.dot {
  position: absolute;
  top: 50%;
  left: calc(27.25 * var(--u));
  width: calc(3.8 * var(--u));
  height: calc(3.8 * var(--u));
  margin: calc(-1.9 * var(--u));
  border-radius: 50%;
  background: var(--dot);
}
.dot[data-end='high'] {
  left: auto;
  right: calc(27.25 * var(--u));
}
/* The fill: from the middle of the track to the thumb, wrapping it 3.75 units around. */
.fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(min(var(--p, 0.5) * 221.5, 138) * var(--u));
  width: calc(
    (max(54.5 + var(--p, 0.5) * 221.5, 138) - min(var(--p, 0.5) * 221.5, 138)) * var(--u)
  );
  border-radius: 999px;
  overflow: hidden;
}
.fillLow,
.fillHigh {
  position: absolute;
  inset: 0;
}
.fillLow {
  background: linear-gradient(
    90deg,
    #c9300c 0%,
    #e2501b 16%,
    #f3742e 40%,
    #fe8c3c 52%,
    #fda84c 68%,
    #fdc55f 83%,
    #ffd96a 100%
  );
  opacity: clamp(0, (0.5 - var(--p, 0.5)) * 40, 1);
}
.fillHigh {
  background: linear-gradient(
    90deg,
    #ffd96a 0%,
    #e5dd6e 21%,
    #b0e676 41%,
    #84e878 56%,
    #57d873 76%,
    #2abd66 100%
  );
  opacity: clamp(0, (var(--p, 0.5) - 0.5) * 40, 1);
}
.thumb {
  position: absolute;
  top: calc(3.75 * var(--u));
  left: calc(3.75 * var(--u));
  display: grid;
  place-items: center;
  width: calc(47 * var(--u));
  height: calc(34 * var(--u));
  border-radius: 28% / 50%;
  background: radial-gradient(
    ellipse 85% 75% at 45% 35%,
    var(--thumb) 0%,
    var(--thumb) 55%,
    var(--thumb-shade) 100%
  );
  box-shadow:
    calc(0.6 * var(--u)) calc(2 * var(--u)) calc(5 * var(--u)) var(--thumb-shadow),
    inset 0 calc(-1.2 * var(--u)) calc(1.6 * var(--u)) rgb(0 0 0 / 0.05);
  transform: translateX(calc(var(--p, 0.5) * 221.5 * var(--u)));
  will-change: transform;
}
.grip {
  width: calc(1.7 * var(--u));
  height: calc(9.5 * var(--u));
  border-radius: calc(1 * var(--u));
  background: var(--grip);
}

/* VOTE, under a hairline. */
.footer {
  display: grid;
  place-items: center;
  height: calc(66 * var(--u));
  margin: calc(16.25 * var(--u)) calc(50 * var(--u)) 0;
  border-top: max(1px, calc(0.6 * var(--u))) solid var(--rule);
}
.vote {
  min-width: calc(110 * var(--u));
  height: calc(40 * var(--u));
  margin: calc(-4 * var(--u)) 0 0;
  padding: 0 calc(16 * var(--u));
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--vote);
  font: inherit;
  font-size: calc(11 * var(--u));
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  cursor: pointer;
}
.vote:focus {
  outline: none;
}
.vote:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}

/* The demo's finger: a grey disc with a light rim, and a halo that closes on it when it presses. */
.touch {
  position: absolute;
  top: 0;
  left: 0;
  width: calc(32 * var(--u));
  height: calc(32 * var(--u));
  margin: calc(-16 * var(--u)) 0 0 calc(-16 * var(--u));
  border: calc(1.4 * var(--u)) solid rgb(225 225 225 / 0.8);
  border-radius: 50%;
  background: rgb(120 120 120 / 0.72);
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms ease;
}
.touch[data-down='true'] {
  opacity: 1;
  transition-duration: 80ms;
}
.halo {
  position: absolute;
  inset: calc(-7 * var(--u));
  border: calc(1 * var(--u)) solid rgb(225 225 225 / 0.7);
  border-radius: 50%;
  opacity: 0;
}

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

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

### mood-slider/icons.tsx

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

/*
 * The three faces above the slider: bad, okay and great. Each is drawn once in SVG at its full
 * size; morphIcon() then reshapes its parts from the slider's place, once per frame while the thumb
 * moves: the left face frowns and knits its brows, the right one smiles wider.
 */

export type Slot = 0 | 1 | 2;

/** How large a face is when the thumb is far from it, against its size when the thumb is on it. */
export const SMALL = 0.66;

const clamp = (value: number) => Math.min(1, Math.max(0, value));
const mix = (from: number, to: number, t: number) => from + (to - from) * t;
const ease = (t: number) => t * t * (3 - 2 * t);
const n = (value: number) => Math.round(value * 100) / 100;

/* Faces. A 100-unit drawing: the disc, two eyes, a mouth and, on the left face, two brows. */

const FACE = [
  // Bad: red, a slight frown that turns angry.
  { top: '#ff9152', bottom: '#ea4219', ink: '#b02e0d' },
  // Okay: orange, a small smile.
  { top: '#febd6b', bottom: '#fc6d17', ink: '#bf5317' },
  // Great: green, eyes shut in a smile that widens.
  { top: '#5df29b', bottom: '#1fc96a', ink: '#0f8f47' },
] as const;

/** The angry face's life at the left end: a glance (in drawing units) and a blink (0 to 1). */
export type Life = { glance: number; blink: number };
const STILL: Life = { glance: 0, blink: 0 };

/** An eye: an ellipse at `x`, `y`, squashed shut as `blink` goes to 1. */
const dot = (x: number, y: number, r: number, blink: number) => {
  const ry = r * (1 - 0.78 * blink);
  return `M${n(x - r)} ${n(y)}a${n(r)} ${n(ry)} 0 1 0 ${n(2 * r)} 0a${n(r)} ${n(ry)} 0 1 0 ${n(-2 * r)} 0`;
};

/** The parts of a face, as path data, for `p` the slider's place (0 to 1). */
function faceParts(slot: Slot, p: number, life: Life = STILL) {
  if (slot === 0) {
    // Anger grows from the middle of the track to its left end.
    const a = ease(clamp((0.5 - p) * 2));
    const x = 50 + mix(4, -6, a) + life.glance;
    const y = -Math.abs(life.glance) * 0.15;
    const r = mix(5.4, 5, a);
    const brows = ease(clamp((a - 0.22) / 0.4));
    const drop = mix(-7, 0, brows) + y;
    return {
      eyes:
        dot(x - mix(19, 17, a), mix(45, 43, a) + y, r, life.blink) +
        dot(x + mix(19, 17, a), mix(45, 45, a) + y, r, life.blink),
      mouth: `M${n(x + mix(-14, -11, a))} ${n(mix(62.5, 67, a) + y)}Q${n(x + mix(0, -1, a))} ${n(mix(52.8, 48, a) + y)} ${n(x + mix(14, 11, a))} ${n(mix(59.8, 64, a) + y)}`,
      brows: `M${n(x - 26)} ${n(26 + drop)}L${n(x - 9)} ${n(mix(28, 32, a) + drop + 2 * life.blink)}M${n(x + 9)} ${n(mix(31, 32, a) + drop + 2 * life.blink)}L${n(x + 26)} ${n(mix(28, 25.5, a) + drop)}`,
      browOpacity: brows,
      width: mix(6.4, 6, a),
    };
  }
  if (slot === 1) {
    // Looks a little towards the thumb.
    const x = 50 + (Math.min(1, Math.max(0, p)) - 0.5) * 8;
    const r = 5.5;
    return {
      eyes: `M${n(x - 20.5 - r)} 44a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0M${n(x + 20.5 - r)} 44a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`,
      mouth: `M${n(x - 13)} 59Q${n(x)} 73 ${n(x + 13)} 59`,
      brows: '',
      browOpacity: 0,
      width: 6.4,
    };
  }
  // Joy grows from the middle of the track to its right end.
  const h = ease(clamp((p - 0.5) * 2));
  const x = 50 + mix(-6, 0, h);
  const y = mix(44, 45.8, h);
  const eye = (cx: number) => `M${n(cx - 8)} ${n(y)}Q${n(cx)} ${n(y - 9)} ${n(cx + 8)} ${n(y)}`;
  return {
    eyes: eye(x + mix(-15.5, -17, h)) + eye(x + mix(14.5, 14.75, h)),
    mouth: `M${n(x + mix(-19.3, -22.2, h))} ${n(mix(55, 56.8, h))}Q${n(x + 0.6)} ${n(mix(76.5, 89, h))} ${n(x + mix(21, 22.5, h))} ${n(mix(54.5, 56.8, h))}`,
    brows: '',
    browOpacity: 0,
    width: 6.2,
  };
}

/** Reshapes the face in `svg` for `p`, the slider's place. */
export function morphIcon(svg: SVGSVGElement, slot: Slot, p: number, life: Life = STILL) {
  const key = `${p.toFixed(3)} ${life.glance.toFixed(2)} ${life.blink.toFixed(2)}`;
  if (svg.dataset.at === key) return;
  svg.dataset.at = key;
  const part = (name: string) => svg.querySelector<SVGElement>(`[data-part="${name}"]`);
  const face = faceParts(slot, p, life);
  part('eyes')?.setAttribute('d', face.eyes);
  const mouth = part('mouth');
  mouth?.setAttribute('d', face.mouth);
  mouth?.setAttribute('stroke-width', String(n(face.width)));
  const brows = part('brows');
  if (brows) {
    brows.setAttribute('d', face.brows);
    brows.setAttribute('opacity', String(n(face.browOpacity)));
  }
}

/** One face, drawn for the slider at its middle; morphIcon() takes it from there. */
export function Icon({ slot, id, className }: { slot: Slot; id: string; className?: string }) {
  const colours = FACE[slot];
  const face = faceParts(slot, 0.5);
  return (
    <svg className={className} viewBox="0 0 100 100" focusable="false" aria-hidden="true">
      <defs>
        <radialGradient id={`${id}-f`} cx="0.38" cy="0.22" r="0.85">
          <stop offset="0" stopColor={colours.top} />
          <stop offset="1" stopColor={colours.bottom} />
        </radialGradient>
      </defs>
      <circle cx="50" cy="50" r="50" fill={`url(#${id}-f)`} />
      <g
        fill="none"
        stroke={colours.ink}
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth="6.4"
      >
        {slot === 1 || slot === 0 ? (
          <path data-part="eyes" d={face.eyes} fill={colours.ink} stroke="none" />
        ) : (
          <path data-part="eyes" d={face.eyes} strokeWidth="5.6" />
        )}
        <path data-part="mouth" d={face.mouth} strokeWidth={face.width} />
        {slot === 0 && (
          <path data-part="brows" d={face.brows} opacity={face.browOpacity} strokeWidth="5.6" />
        )}
      </g>
    </svg>
  );
}
```

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

```tsx
/*
 * The slider's sounds, synthesised with the Web Audio API, no audio file. A 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. The ends and the vote are the same kind
 * of blip, a little longer: the low end a soft falling thud, the high end two rising chimes, the
 * vote a short pop that rises.
 *
 * Browsers allow sound only after a gesture of the reader's, so nothing plays before their first
 * press or key on the card. One AudioContext serves every card on the page: it is created on that
 * first gesture, suspended when the sounds stop (an idle context still runs its audio thread), and
 * closed when the last card 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 sound (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 sound.
    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 sounds have stopped for a while. */
function restSoon() {
  window.clearTimeout(restTimer);
  restTimer = window.setTimeout(() => {
    if (context?.state === 'running') void context.suspend();
  }, REST);
}

/**
 * One blip: a sine from `pitch` that glides to `pitch * glide` over `length` seconds, starting
 * `delay` seconds from now, at `peak` volume, with `edge` of noise around `bright` times the pitch.
 */
function blip(
  audible: AudioContext,
  grain: AudioBuffer,
  { pitch, glide, length, peak, delay = 0, edge = 0.7, bright = 3 }: Blip,
) {
  const start = audible.currentTime + 0.002 + delay;
  const out = audible.createGain();
  out.gain.setValueAtTime(0, start);
  out.gain.linearRampToValueAtTime(peak, start + 0.0015);
  out.gain.exponentialRampToValueAtTime(0.0001, start + length);
  out.connect(audible.destination);

  // The body: a sine that glides, as a tap on something hollow.
  const tone = audible.createOscillator();
  tone.type = 'sine';
  tone.frequency.setValueAtTime(pitch, start);
  tone.frequency.exponentialRampToValueAtTime(pitch * glide, start + length * 0.8);
  tone.connect(out);

  // The edge: a few milliseconds of band-passed noise.
  const grit = audible.createBufferSource();
  grit.buffer = grain;
  const band = audible.createBiquadFilter();
  band.type = 'bandpass';
  band.frequency.value = Math.min(12000, pitch * bright);
  band.Q.value = 0.9;
  const edgeGain = audible.createGain();
  edgeGain.gain.setValueAtTime(edge, start);
  edgeGain.gain.exponentialRampToValueAtTime(0.001, start + 0.012);
  grit.connect(band).connect(edgeGain).connect(out);

  tone.start(start);
  tone.stop(start + length);
  grit.start(start);
  grit.stop(start + 0.015);
  tone.onended = () => out.disconnect();
}

type Blip = {
  pitch: number;
  glide: number;
  length: number;
  peak: number;
  delay?: number;
  edge?: number;
  bright?: number;
};

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

  /** The audio, woken, once a gesture has made it; null before. */
  const ready = () => {
    if (disposed || !context || !noise) return null;
    if (context.state === 'suspended') void context.resume();
    restSoon();
    return { audible: context, grain: noise };
  };

  return {
    /** The reader pressed or keyed the card: from now on, sounds may play. */
    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) {
      const now = performance.now();
      if (now - lastTick < MIN_GAP) return;
      const on = ready();
      if (!on) return;
      lastTick = now;
      const share = Math.min(1, Math.max(0, level));
      blip(on.audible, on.grain, {
        pitch: 520 + 1180 * share,
        glide: 0.62,
        length: LENGTH,
        peak: 0.014 + 0.05 * share ** 1.3,
      });
    },

    /** The thumb reached the low end: a soft thud that falls. */
    low() {
      const on = ready();
      if (!on) return;
      blip(on.audible, on.grain, { pitch: 260, glide: 0.55, length: 0.16, peak: 0.07, edge: 0.4 });
    },

    /** The thumb reached the high end: two chimes, a fifth apart, the second a breath later. */
    high() {
      const on = ready();
      if (!on) return;
      blip(on.audible, on.grain, { pitch: 1320, glide: 0.98, length: 0.18, peak: 0.035 });
      blip(on.audible, on.grain, {
        pitch: 1980,
        glide: 0.98,
        length: 0.24,
        peak: 0.03,
        delay: 0.075,
        edge: 0.3,
      });
    },

    /** The vote: a short pop that rises. */
    vote() {
      const on = ready();
      if (!on) return;
      blip(on.audible, on.grain, { pitch: 640, glide: 1.5, length: 0.07, peak: 0.05, edge: 0.5 });
    },

    /** The card 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>;
```

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