# Task pills

> A to-do list whose status pills move a little: a breathing dot, a sweeping clock, a flickering flame.

Each row of this to-do list keeps its status in a soft pill: Now for an event in progress, when an overdue task was due, a streak for a habit. Every pill has a small idle motion of its own. Checking a task turns its pill into Done, and checking a habit rolls the streak up with a burst of sparks.

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

Category: Feedback.

## Anatomy

1. **Stage**: A rounded surface whose height follows its width; every measure inside is a unit that scales with it.
2. **Title**: One line, semibold. A habit’s is grey; a task’s turns grey once done, as a strike is drawn across it.
3. **Checkbox**: A real checkbox inside a label, so a click anywhere on the row or Space toggles it. Checked, a filled square springs in and the check is drawn left to right.
4. **Habit checkbox**: Dashed, so a habit reads apart from a task; it turns as it fills orange. An event has a calendar instead and can’t be checked.
5. **Now pill**: An event in progress: a blue dot that breathes and sends out a ripple every 2.6 s.
6. **Overdue pill**: When the task was due, by a clock whose hand sweeps round now and then, speed lines trailing it.
7. **Streak pill**: Days in a row, by a flickering flame with sparks rising out of step. Checking the habit rolls the digit up one.

## When to use

- A short day list (three to seven rows) where each row’s status matters as much as its title.
- Mixing kinds in one list: an event happening now, a task past its due date, a daily habit.
- A home screen or widget where checking a row off should feel like a small reward.
- Statuses short enough for a pill: one or two words, or a number with an icon.

## When not to use

- Long or dense lists, sorted and filtered. Use a table or a plain checklist: the idle motion of dozens of pills would be noise.
- Statuses that need a sentence or several fields. Use a row with a second line, or a card.
- Tasks with subtasks, assignees or priorities. Use a full task row or a board.
- A single yes or no setting. Use a switch or a checkbox on its own.

## Installation

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

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

export function Today() {
  return <TaskPills autoplay={false} onToggle={(id, done) => console.log(id, done)} />;
}
```

## States

- **To do**: Nothing checked: the event is on now, the task overdue, the habit at a 5-day streak.
- **Task done**: The box fills green, the title is struck, and the overdue pill shrinks into Done in 460 ms.
- **Habit done**: The box fills orange and the streak rolls to 6 as a burst of sparks flies out of the flame.
- **All done**: Both checked. Unchecking plays each change back the other way.
- **Still**: `ambient={false}`: the dot, the clock and the flame hold their first frame. Reduced motion does the same.
- **Night**: `colorScheme="dark"`: a near-black stage whatever the page, the marks cut into the icons taking each pill’s colour.

## How it works

- All sizes are in one unit, --u, taken from the stage’s width with container units; a pill is 34 units tall, and its type, tracking, paddings and glyphs scale with it.
- The idle motion is CSS keyframes of transform and opacity on small boxes around static SVG drawings: the dot breathes and sends out a ripple, the clock’s minute hand sweeps a full turn with the speed lines trailing, the flame and its core flicker out of step, and sparks rise at staggered times. It pauses while the list is off screen, and a paused animation holds its first frame, the still pose, which is also what shows before JavaScript runs.
- The task’s Due and Done pills sit in the same place, aligned to the right edge. On a toggle, the wider pill is clipped to the narrower one’s width while the colours cross-fade, and the old words go before the new ones come in.
- Checking fills the box with the row’s colour, draws the check from left to right and strikes the title through, with CSS transitions. For the habit, the old digit rolls out of a fixed one-digit box while the new one rolls in, the flame flares up and ten sparks burst out of it.
- Every colour is a light-dark() token on the stage, so it follows the page’s color-scheme, or the colorScheme prop; the marks cut into the pills (the clock’s hands, the Done check) take each pill’s own background, so they still read as cut out at night.

## API reference

- **items** (`TaskItem[]`, default `Design Crit, Finish portfolio, Pushups ×100`): The rows at first: an event (a Now pill), tasks (when they were due) and habits (a streak).
- **onToggle** (`(id: string, done: boolean) => void`): Called when the reader checks or unchecks a task or a habit, with its id.
- **ambient** (`boolean`, default `true`): The pills’ idle motion: the breathing dot, the sweeping clock, the flickering flame.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer checks the task and the habit, then resets.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- Each checkable row is a label around a native checkbox: clicking anywhere on the row or pressing Space toggles it, and a visible focus ring sits on the box.
- The pills are hidden from assistive tech; their meaning is part of the accessible name instead (“Finish portfolio, due Yesterday” or “…, done”, “Pushups ×100, 5-day streak”).
- The reader’s checks are announced politely (“Pushups ×100, done. 6-day streak.”); the demo stays silent.
- Under reduced motion there is no idle motion and no demo, and every state change is instant.

## Source

### task-pills/task-pills.tsx

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

'use client';

import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type Ref,
} from 'react';
import { DemoPointer, runDemo } from './demo-pointer';
import {
  BoxGlyph,
  CalendarGlyph,
  CheckGlyph,
  ClockFace,
  DashedBoxGlyph,
  DoneGlyph,
  FilledBoxGlyph,
  FlameCoreGlyph,
  FlameGlyph,
} from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './task-pills.module.css';

/*
 * Task pills: a to-do list where each row's status sits in a soft pill on the right, and every
 * pill has a small life of its own.
 *
 * - An event in progress reads "Now", its blue dot breathing out a ripple. An overdue task reads
 *   when it was due, its clock's hand sweeping round now and then with speed lines trailing it.
 *   A habit shows its streak by a flame that flickers, sparks rising from it every few seconds.
 * - Checking the task fills its box, strikes its title through, and turns its pill into a green
 *   Done: the two pills are stacked, and the wider one is clipped to the narrower one's width as
 *   they cross-fade, so the pill seems to shrink or grow into the other. Checking the habit adds a
 *   day to the streak, the digit rolling up while a burst of sparks flies out of the flame.
 * - All the idle motion is CSS animations of transforms and opacity on small boxes around static
 *   drawings, paused while the list is off screen; the checks are CSS transitions, and the pill's
 *   morph and the burst are played with the Web Animations API.
 * - Checkable rows are labels around real checkboxes: click anywhere on the row, or Space.
 *
 * Every measure is a number of units, --u, which follows the stage's width. 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 TaskItem =
  | {
      id: string;
      kind: 'event';
      title: string;
      /** The pill's word: Now. */
      status: string;
    }
  | {
      id: string;
      kind: 'task';
      title: string;
      /** When it was due, in the pill: Yesterday. */
      due: string;
      done?: boolean;
    }
  | {
      id: string;
      kind: 'habit';
      title: string;
      /** Days in a row so far, without today. */
      streak: number;
      done?: boolean;
    };

const ITEMS: TaskItem[] = [
  { id: 'crit', kind: 'event', title: 'Design Crit', status: 'Now' },
  { id: 'portfolio', kind: 'task', title: 'Finish portfolio', due: 'Yesterday' },
  { id: 'pushups', kind: 'habit', title: 'Pushups ×100', streak: 5 },
];

/**
 * The sparks around the flame: where they sit (in units from the flame's top left), their
 * radius, and how long each one's cycle lasts and waits, in s, so that they rise out of step.
 */
const SPARKS = [
  { x: 1.28, y: -10.1, r: 1.26, t: 3.4, d: 0.2 },
  { x: 12.8, y: -6.2, r: 2.2, t: 4.1, d: 0.8 },
  { x: 7.27, y: -3.7, r: 0.67, t: 3.7, d: 1.5 },
  { x: 10.8, y: -3.2, r: 0.89, t: 4.6, d: 0.4 },
  { x: 6.3, y: -5.2, r: 0.32, t: 3.9, d: 2.1 },
  { x: 3.9, y: 1, r: 0.89, t: 4.3, d: 1.1 },
  { x: 1.28, y: 3.6, r: 1, t: 3.6, d: 2.6 },
  { x: 15.2, y: 5.1, r: 0.32, t: 4.8, d: 1.7 },
  { x: 14.9, y: 6.3, r: 0.24, t: 4, d: 3 },
];
/** The burst when the habit is done: sparks thrown out of the flame, by angle (degrees, 0 up). */
const BURST = [-78, -50, -24, -4, 18, 42, 68, 96, -104, 130].map((angle, i) => ({
  angle,
  reach: 17 + (i % 3) * 5,
  r: 1.1 + ((i * 7) % 5) * 0.28,
}));

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const unitOf = (element: HTMLElement) =>
  parseFloat(getComputedStyle(element).getPropertyValue('--u')) || 1;

/**
 * Turns pill `from` into pill `to`, stacked at the same right edge: the wider one is clipped to
 * the narrower one's width while the colours cross-fade, and the words swap, the old ones gone
 * before the new ones come.
 */
function morph(from: HTMLElement, to: HTMLElement) {
  const before = from.offsetWidth;
  const after = to.offsetWidth;
  const round = `round ${to.offsetHeight / 2}px`;
  const timing = { duration: 460, easing: 'cubic-bezier(0.3, 0.9, 0.25, 1)' };
  if (after > before) {
    to.animate(
      [
        { clipPath: `inset(0 0 0 ${after - before}px ${round})` },
        { clipPath: `inset(0 ${round})` },
      ],
      timing,
    );
  } else {
    from.animate(
      [
        { clipPath: `inset(0 ${round})` },
        { clipPath: `inset(0 0 0 ${before - after}px ${round})` },
      ],
      timing,
    );
  }
  from.animate([{ opacity: 1 }, { opacity: 1, offset: 0.5 }, { opacity: 0 }], timing);
  to.animate([{ opacity: 0 }, { opacity: 1, offset: 0.5 }, { opacity: 1 }], timing);
  for (const part of from.children) {
    part.animate([{ opacity: 1 }, { opacity: 0, offset: 0.3 }, { opacity: 0 }], timing);
  }
  for (const part of to.children) {
    part.animate(
      [
        { opacity: 0, transform: 'translateX(-12%)' },
        { opacity: 0, transform: 'translateX(-12%)', offset: 0.3 },
        { opacity: 1, transform: 'none' },
      ],
      timing,
    );
  }
  to.querySelector('[data-icon]')?.animate(
    [{ transform: 'scale(0.4) rotate(-30deg)' }, { transform: 'none' }],
    { duration: 560, delay: 120, easing: 'cubic-bezier(0.3, 1.6, 0.5, 1)', fill: 'backwards' },
  );
}

/** Runs `play` when `value` changes, after the first render and never under reduced motion. */
function useOnChange(value: boolean, play: () => void) {
  const first = useRef(true);
  useLayoutEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    if (!reducedMotion()) play();
    // Only a change of `value` plays.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value]);
}

type RowProps<Kind extends TaskItem['kind']> = {
  item: Extract<TaskItem, { kind: Kind }>;
  done: boolean;
  onChange: (done: boolean) => void;
  /** The checkbox's drawing, which the demo's pointer goes to. */
  boxRef: Ref<HTMLSpanElement>;
};

/** The checkbox's drawing: its outline, then the fill and the check that come in when done. */
function Box({ dashed, boxRef }: { dashed: boolean; boxRef: Ref<HTMLSpanElement> }) {
  return (
    <span ref={boxRef} className={styles.box} data-part="checkbox" aria-hidden="true">
      {dashed ? (
        <DashedBoxGlyph className={`${styles.outline} ${styles.dashed}`} />
      ) : (
        <BoxGlyph className={styles.outline} />
      )}
      <span className={styles.fill}>
        <FilledBoxGlyph />
      </span>
      <span className={styles.check}>
        <CheckGlyph />
      </span>
    </span>
  );
}

function Title({ text }: { text: string }) {
  return (
    <span className={styles.title} data-part="title">
      {text}
      <span className={styles.strike} aria-hidden="true" />
    </span>
  );
}

function EventRow({ item }: { item: Extract<TaskItem, { kind: 'event' }> }) {
  return (
    <div className={styles.row} data-part="row" data-kind="event">
      <span className={styles.box} data-part="glyph" aria-hidden="true">
        <CalendarGlyph className={styles.outline} />
      </span>
      <Title text={item.title} />
      <span className={styles.hidden}>, {item.status}</span>
      <span className={styles.slot} aria-hidden="true">
        <span className={styles.pill} data-part="pill" data-tone="now" data-shown="">
          <span className={styles.dot} data-part="dot">
            <span className={styles.ripple} />
            <span className={styles.core} />
          </span>
          {item.status}
        </span>
      </span>
    </div>
  );
}

function TaskRow({ item, done, onChange, boxRef }: RowProps<'task'>) {
  const due = useRef<HTMLSpanElement>(null);
  const finished = useRef<HTMLSpanElement>(null);
  useOnChange(done, () => {
    const [from, to] = done ? [due.current, finished.current] : [finished.current, due.current];
    if (from && to) morph(from, to);
  });
  return (
    <label className={styles.row} data-part="row" data-kind="task" data-done={done || undefined}>
      <input
        type="checkbox"
        className={styles.input}
        checked={done}
        onChange={(event) => onChange(event.target.checked)}
      />
      <Box dashed={false} boxRef={boxRef} />
      <Title text={item.title} />
      <span className={styles.hidden}>{done ? ', done' : `, due ${item.due}`}</span>
      <span className={styles.slot} aria-hidden="true">
        <span
          ref={due}
          className={styles.pill}
          data-part="pill"
          data-tone="due"
          data-shown={!done || undefined}
        >
          <span className={styles.lines}>
            <span className={styles.line} />
            <span className={styles.line} />
            <span className={styles.line} />
          </span>
          <span className={styles.clock} data-part="clock">
            <ClockFace className={styles.face} />
            <span className={`${styles.hand} ${styles.hour}`} />
            <span className={`${styles.hand} ${styles.minute}`} />
          </span>
          <span>{item.due}</span>
        </span>
        <span
          ref={finished}
          className={styles.pill}
          data-part="pill"
          data-tone="done"
          data-shown={done || undefined}
        >
          <span className={styles.mark} data-icon="">
            <DoneGlyph className={styles.face} />
          </span>
          <span>Done</span>
        </span>
      </span>
    </label>
  );
}

function HabitRow({ item, done, onChange, boxRef }: RowProps<'habit'>) {
  const count = item.streak + (done ? 1 : 0);
  // The number on show, and the one rolling out of view as it changes.
  const [shown, setShown] = useState(count);
  const [leaving, setLeaving] = useState<number | null>(null);
  if (shown !== count) {
    setLeaving(shown);
    setShown(count);
  }
  const pill = useRef<HTMLSpanElement>(null);
  const flame = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    if (leaving === null) return;
    const timer = window.setTimeout(() => setLeaving(null), 600);
    return () => window.clearTimeout(timer);
  }, [leaving]);

  useOnChange(done, () => {
    if (!done || !pill.current || !flame.current) return;
    pill.current.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(1.08)', offset: 0.3 },
        { transform: 'scale(1)' },
      ],
      { duration: 480, easing: 'ease-out' },
    );
    // The flame flares up as the sparks fly.
    flame.current.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(1.22)', offset: 0.25 },
        { transform: 'scale(1)' },
      ],
      { duration: 520, easing: 'ease-out' },
    );
    // The burst: each spark flies out along its angle, shrinking and fading.
    const unit = unitOf(flame.current);
    flame.current.querySelectorAll<HTMLElement>('[data-burst]').forEach((spark, i) => {
      const { angle, reach } = BURST[i];
      const radians = (angle * Math.PI) / 180;
      const x = Math.sin(radians) * reach * unit;
      const y = -Math.cos(radians) * reach * unit;
      spark.animate(
        [
          { transform: 'translate(0, 0) scale(1)', opacity: 1 },
          { transform: `translate(${x}px, ${y}px) scale(0.2)`, opacity: 0 },
        ],
        { duration: 560 + i * 30, easing: 'cubic-bezier(0.15, 0.8, 0.3, 1)' },
      );
    });
  });

  return (
    <label className={styles.row} data-part="row" data-kind="habit" data-done={done || undefined}>
      <input
        type="checkbox"
        className={styles.input}
        checked={done}
        onChange={(event) => onChange(event.target.checked)}
      />
      <Box dashed boxRef={boxRef} />
      <Title text={item.title} />
      <span className={styles.hidden}>, {count}-day streak</span>
      <span className={styles.slot} aria-hidden="true">
        <span ref={pill} className={styles.pill} data-part="pill" data-tone="streak" data-shown="">
          <span ref={flame} className={styles.flame} data-part="flame">
            <FlameGlyph className={styles.flameBody} />
            <FlameCoreGlyph className={styles.flameCore} />
            {SPARKS.map(({ x, y, r, t, d }, i) => (
              <span
                key={i}
                className={styles.spark}
                style={
                  { '--x': x, '--y': y, '--r': r, '--t': `${t}s`, '--d': `${d}s` } as CSSProperties
                }
              />
            ))}
            {BURST.map(({ r }, i) => (
              <span
                key={i}
                className={styles.burst}
                data-burst=""
                style={{ '--r': r } as CSSProperties}
              />
            ))}
          </span>
          <span
            className={styles.count}
            data-part="count"
            data-roll={leaving === null ? undefined : leaving < shown ? 'up' : 'down'}
          >
            <span key={shown} className={styles.digit}>
              {shown}
            </span>
            {leaving !== null && (
              <span key={`was-${leaving}`} className={`${styles.digit} ${styles.leaving}`}>
                {leaving}
              </span>
            )}
          </span>
        </span>
      </span>
    </label>
  );
}

export function TaskPills({
  items = ITEMS,
  onToggle,
  ambient = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The rows, at first: an event (a Now pill), tasks (when they were due) and habits (a streak). */
  items?: TaskItem[];
  /** Called when the reader checks or unchecks a task or a habit, with its id. */
  onToggle?: (id: string, done: boolean) => void;
  /** The pills' idle motion: the breathing dot, the sweeping clock, the flickering flame. */
  ambient?: boolean;
  /** While on screen and untouched, a ghost pointer checks the task and the habit, then resets. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Extra class for the root, a stage of its own. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [done, setDone] = useState<Record<string, boolean>>(() =>
    Object.fromEntries(items.map((item) => [item.id, item.kind !== 'event' && !!item.done])),
  );
  const [message, setMessage] = useState('');
  /** Keyboard focus is on the stage: the ghost leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  /** Part of the stage is on screen in a visible tab: the idle motion plays. */
  const [live, setLive] = useState(false);
  const boxes = useRef(new Map<string, HTMLSpanElement>());
  const pointer = useRef<HTMLSpanElement>(null);
  const stopGhost = useRef<(() => void) | null>(null);
  /** What is done, for the demo between renders. */
  const doneNow = useRef(done);
  useLayoutEffect(() => {
    doneNow.current = done;
  }, [done]);

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

  const toggle = (item: TaskItem, next: boolean, byReader: boolean) => {
    if (item.kind === 'event') return;
    setDone((now) => ({ ...now, [item.id]: next }));
    if (!byReader) return;
    onToggle?.(item.id, next);
    if (item.kind === 'task') announce(`${item.title}, ${next ? 'done' : 'not done'}.`);
    else {
      const days = item.streak + (next ? 1 : 0);
      announce(`${item.title}, ${next ? 'done' : 'not done'}. ${days}-day streak.`);
    }
  };

  // The idle motion plays while part of the stage is on screen.
  useEffect(() => {
    const element = root.current;
    if (!element || !ambient) return;
    let onScreen = false;
    const update = () => setLive(onScreen && !document.hidden);
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      setLive(false);
    };
  }, [ambient, root]);

  /* The demo: the ghost checks the task, then the habit, and unchecks both. */
  const demo = autoplay && playing && !keyboard;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const checkable = items.filter((item) => item.kind !== 'event');
    const task = checkable.find((item) => item.kind === 'task');
    const habit = checkable.find((item) => item.kind === 'habit');
    const stop = runDemo(stage, arrow, async ({ wait, moveTo, press }) => {
      const click = async (item: TaskItem | undefined, next: boolean, rest: number) => {
        if (!item || doneNow.current[item.id] === next) return;
        await moveTo(boxes.current.get(item.id));
        await press(() => toggle(item, next, false));
        await wait(rest);
      };
      await wait(1000);
      for (;;) {
        // Whatever the reader left checked goes back first.
        for (const item of checkable) await click(item, false, 500);
        await click(task, true, 1500);
        await click(habit, true, 2600);
        await click(task, false, 800);
        await click(habit, false, 2400);
      }
    });
    stopGhost.current = stop;
    return stop;
    // The script reads refs; it restarts when the demo may play or not.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  /** The reader takes over: the ghost stops at once and waits until they leave it alone. */
  const readerActs = () => {
    stopGhost.current?.();
    take();
  };
  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-part="stage"
      data-color-scheme={colorScheme}
      data-live={live || undefined}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div className={styles.scene}>
        <ul className={styles.list}>
          {items.map((item) => {
            const boxRef = (element: HTMLSpanElement | null) => {
              if (element) boxes.current.set(item.id, element);
              else boxes.current.delete(item.id);
            };
            const onChange = (next: boolean) => toggle(item, next, true);
            return (
              <li key={item.id}>
                {item.kind === 'event' && <EventRow item={item} />}
                {item.kind === 'task' && (
                  <TaskRow item={item} done={!!done[item.id]} onChange={onChange} boxRef={boxRef} />
                )}
                {item.kind === 'habit' && (
                  <HabitRow
                    item={item}
                    done={!!done[item.id]}
                    onChange={onChange}
                    boxRef={boxRef}
                  />
                )}
              </li>
            );
          })}
        </ul>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer ref={pointer} />
      </div>
    </div>
  );
}
```

### task-pills/task-pills.module.css

```css
/* Task pills (task-pills.tsx): a white stage with the list in its middle, or at night a
   near-black one. Every measure in the list is a number of units, --u (a pill is 34 of them
   tall), which follows the stage's width.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). Each pill has a soft background and an ink; the white marks cut into
   its drawings (the clock's hands, the done check, the flame's heart) take the pill's background
   at night, so they still read as cut out.

   Memory and layout. The stage is one layout boundary, its height set by its width, so nothing
   that changes inside it lays out the page around it. What moves is small boxes around static
   drawings, by transforms and opacity: the compositor plays the idle motion on layers a few px
   wide, and it pauses while the list is off screen (data-live). */

.stage {
  /* The stage and its edge. */
  --surface: #fff;
  --line: rgb(0 0 0 / 0.06);
  /* A title; the habit's and a done one's; the checkboxes' outlines, plain and dashed. */
  --ink: #0d0d0d;
  --ink-muted: #ababab;
  --box: #d1d1d1;
  --box-dashed: #d9d9d9;
  /* A checked box: green for the task, orange for the habit, the check white on both. */
  --accent: #45b36b;
  --accent-habit: #df7843;
  --accent-ink: #fff;
  /* The pills, background then ink: Now, overdue, Done, the streak. */
  --now-bg: #edf7ff;
  --now-ink: #62aef8;
  --due-bg: #f7f7f7;
  --due-ink: #747474;
  --done-bg: #ebf8ef;
  --done-ink: #45b36b;
  --streak-bg: #fcf4ec;
  --streak-ink: #df7843;
  /* The marks cut into the overdue clock and the Done mark: white, or the pill's own background
     at night. */
  --due-hand: #fff;
  --done-mark: #fff;
  --focus: #0a84ff;
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  overflow: hidden;
  overflow: clip;
  container-type: inline-size;
  contain: layout;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#fff, #111113);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --ink: light-dark(#0d0d0d, #f2f2f4);
    --ink-muted: light-dark(#ababab, #858589);
    --box: light-dark(#d1d1d1, #6c6c70);
    --box-dashed: light-dark(#d9d9d9, #646468);
    --accent: light-dark(#45b36b, #3a9d5d);
    --accent-habit: light-dark(#df7843, #d06a37);
    --now-bg: light-dark(#edf7ff, #10233a);
    --now-ink: light-dark(#62aef8, #6cb6ff);
    --due-bg: light-dark(#f7f7f7, #232325);
    --due-ink: light-dark(#747474, #a1a1a6);
    --done-bg: light-dark(#ebf8ef, #11291b);
    --done-ink: light-dark(#45b36b, #5ccb83);
    --streak-bg: light-dark(#fcf4ec, #33200f);
    --streak-ink: light-dark(#df7843, #f08a55);
    --due-hand: light-dark(#fff, #232325);
    --done-mark: light-dark(#fff, #11291b);
    --focus: light-dark(#0a84ff, #3594ff);
  }
}
/* The colorScheme prop. Without it the stage sets nothing and inherits the page's color-scheme. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}

/* The list's place: its height follows the stage's width, so it never depends on the list. */
.scene {
  display: grid;
  place-items: center;
  height: clamp(240px, 75cqw, 620px);
}

.list {
  /* 1.4 px on a wide stage; on a narrow one, the list keeps a margin. */
  --u: min(1.4px, max(0.165cqw, min(0.82px, 0.255cqw)));
  width: calc(348 * var(--u));
  margin: 0;
  padding: 0;
  color: var(--ink);
  font-family:
    -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', Roboto, sans-serif;
  list-style: none;
  -webkit-font-smoothing: antialiased;
}

/* A row: its glyph or checkbox, its title, its pill on the right. */
.row {
  position: relative;
  display: flex;
  align-items: center;
  height: calc(64 * var(--u));
  -webkit-user-select: none;
  user-select: none;
}
label.row {
  cursor: pointer;
}
.input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  opacity: 0;
}

.box {
  position: relative;
  flex: none;
  width: calc(21 * var(--u));
  height: calc(21 * var(--u));
  margin-right: calc(15.4 * var(--u));
  color: var(--box);
}
.box > * {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}
.box svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.dashed {
  color: var(--box-dashed);
}
.input:focus-visible + .box {
  border-radius: calc(6 * var(--u));
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

/* Checked: the outline goes, a filled square springs in, and its check is drawn left to right. */
.outline {
  transition:
    opacity 0.2s ease,
    transform 0.3s ease;
}
.fill {
  color: var(--accent);
  opacity: 0;
  transform: scale(0.4);
  transition:
    opacity 0.18s ease,
    transform 0.2s ease;
}
.row[data-kind='habit'] .fill {
  color: var(--accent-habit);
}
.check {
  color: var(--accent-ink);
  clip-path: inset(0 100% 0 0);
  transition: clip-path 0.15s ease;
}
.row[data-done] .outline {
  opacity: 0;
  transform: scale(0.75);
}
.row[data-done] .dashed {
  transform: rotate(45deg) scale(0.75);
}
.row[data-done] .fill {
  opacity: 1;
  transform: none;
  transition:
    opacity 0.12s ease,
    transform 0.42s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.row[data-done] .check {
  clip-path: inset(0);
  transition: clip-path 0.28s cubic-bezier(0.5, 0, 0.3, 1) 0.14s;
}

.title {
  position: relative;
  overflow: visible;
  font-size: calc(21.3 * var(--u));
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.012em;
  white-space: nowrap;
  transition: color 0.3s ease;
}
.row[data-kind='habit'] .title,
.row[data-done] .title {
  color: var(--ink-muted);
}
/* The strike, drawn across once done. */
.strike {
  position: absolute;
  top: 54%;
  right: calc(-1 * var(--u));
  left: calc(-1 * var(--u));
  height: calc(1.8 * var(--u));
  margin-top: calc(-0.9 * var(--u));
  border-radius: var(--u);
  background: currentColor;
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.25s ease;
}
.row[data-done] .strike {
  transform: none;
  transition: transform 0.38s cubic-bezier(0.6, 0, 0.3, 1) 0.08s;
}

/* The pills, stacked in one place at the row's right end; one is shown. */
.slot {
  display: inline-grid;
  justify-items: end;
  align-items: center;
  margin-left: auto;
}
.pill {
  position: relative;
  grid-area: 1 / 1;
  display: inline-flex;
  align-items: center;
  height: calc(34 * var(--u));
  border-radius: calc(17 * var(--u));
  font-size: calc(16 * var(--u));
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.012em;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  transition: visibility 0s linear 0.46s;
}
.pill[data-shown] {
  z-index: 1;
  opacity: 1;
  visibility: visible;
  transition: none;
}
.pill[data-tone='now'] {
  gap: calc(4.2 * var(--u));
  padding: 0 calc(10.2 * var(--u)) 0 calc(10.7 * var(--u));
  background: var(--now-bg);
  color: var(--now-ink);
}
.pill[data-tone='due'] {
  padding: 0 calc(10 * var(--u)) 0 calc(8.8 * var(--u));
  background: var(--due-bg);
  color: var(--due-ink);
}
.pill[data-tone='done'] {
  gap: calc(5 * var(--u));
  padding: 0 calc(11.6 * var(--u)) 0 calc(9.4 * var(--u));
  background: var(--done-bg);
  color: var(--done-ink);
}
.pill[data-tone='streak'] {
  gap: calc(7.6 * var(--u));
  padding: 0 calc(12 * var(--u)) 0 calc(11.6 * var(--u));
  background: var(--streak-bg);
  color: var(--streak-ink);
}

/* Now: a dot that breathes, and a ripple that leaves it. */
.dot {
  position: relative;
  flex: none;
  width: calc(10.5 * var(--u));
  height: calc(10.5 * var(--u));
}
.ripple,
.core {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: currentColor;
}
.ripple {
  opacity: 0;
  animation: ripple 2.6s cubic-bezier(0.2, 0.6, 0.4, 1) infinite;
}
.core {
  animation: breathe 2.6s ease-in-out infinite;
}
@keyframes breathe {
  0%,
  100% {
    transform: none;
  }
  50% {
    transform: scale(0.78);
  }
}
@keyframes ripple {
  0% {
    opacity: 0.4;
    transform: none;
  }
  70%,
  100% {
    opacity: 0;
    transform: scale(2.4);
  }
}

/* Overdue: speed lines, then a clock whose minute hand sweeps round now and then. */
.lines {
  position: relative;
  flex: none;
  width: calc(4.6 * var(--u));
  height: calc(9.05 * var(--u));
  margin-right: calc(1 * var(--u));
}
.line {
  position: absolute;
  right: 0;
  width: calc(3.5 * var(--u));
  height: calc(1.35 * var(--u));
  border-radius: var(--u);
  background: currentColor;
  transform-origin: 100% 50%;
  animation: trail 3.2s ease-in-out infinite;
}
.line:nth-child(1) {
  top: 0;
}
.line:nth-child(2) {
  top: calc(3.85 * var(--u));
  right: calc(1.1 * var(--u));
  width: calc(3.3 * var(--u));
  animation-delay: 0.06s;
}
.line:nth-child(3) {
  top: calc(7.7 * var(--u));
  animation-delay: 0.12s;
}
.clock {
  position: relative;
  flex: none;
  width: calc(14.2 * var(--u));
  height: calc(14.2 * var(--u));
  margin-right: calc(4.7 * var(--u));
}
/* The drawings stay whole however their boxes round to the screen's pixels. */
.face {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.hand {
  position: absolute;
  bottom: calc(50% - 0.75 * var(--u));
  left: calc(50% - 0.75 * var(--u));
  width: calc(1.5 * var(--u));
  border-radius: var(--u);
  background: var(--due-hand);
  transform-origin: 50% calc(100% - 0.75 * var(--u));
}
.minute {
  height: calc(4.9 * var(--u));
  animation: sweep 3.2s infinite;
}
.hour {
  height: calc(3.9 * var(--u));
  transform: rotate(135deg);
  animation: hour 38.4s linear infinite;
}
@keyframes sweep {
  0% {
    transform: none;
    animation-timing-function: cubic-bezier(0.55, 0, 0.25, 1);
  }
  42%,
  100% {
    transform: rotate(360deg);
  }
}
@keyframes hour {
  from {
    transform: rotate(135deg);
  }
  to {
    transform: rotate(495deg);
  }
}
@keyframes trail {
  0%,
  44%,
  100% {
    opacity: 1;
    transform: none;
  }
  14% {
    opacity: 0.5;
    transform: translateX(calc(-1.4 * var(--u))) scaleX(1.55);
  }
  30% {
    opacity: 0.85;
    transform: translateX(calc(-0.4 * var(--u))) scaleX(1.15);
  }
}

/* Done: a check in a filled circle. */
.mark {
  flex: none;
  width: calc(14.2 * var(--u));
  height: calc(14.2 * var(--u));
}

/* The streak: a flame that flickers, its heart flickering out of step, and sparks rising now
   and then; the burst's sparks wait, hidden, at its middle. */
.flame {
  position: relative;
  flex: none;
  transform-origin: 50% 80%;
  width: calc(15.4 * var(--u));
  height: calc(18.3 * var(--u));
}
.flameBody,
.flameCore {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.flameBody {
  transform-origin: 50% 92%;
  animation: flicker 1.9s ease-in-out infinite;
}
.flameCore {
  color: var(--streak-bg);
  transform-origin: 47% 82%;
  animation: core 1.3s ease-in-out infinite;
}
@keyframes flicker {
  0%,
  100% {
    transform: none;
  }
  25% {
    transform: scale(1.03, 1.07) skewX(-2deg);
  }
  50% {
    transform: scale(0.98, 0.95) skewX(1.5deg);
  }
  75% {
    transform: scale(1.02, 1.04) skewX(1deg);
  }
}
@keyframes core {
  0%,
  100% {
    transform: none;
  }
  40% {
    transform: scale(0.86, 0.9);
  }
  70% {
    transform: scale(1.05, 1.07);
  }
}
.spark,
.burst {
  position: absolute;
  width: calc(var(--r) * 2 * var(--u));
  height: calc(var(--r) * 2 * var(--u));
  margin: calc(var(--r) * -1 * var(--u)) 0 0 calc(var(--r) * -1 * var(--u));
  border-radius: 50%;
  background: currentColor;
}
.spark {
  top: calc(var(--y) * var(--u));
  left: calc(var(--x) * var(--u));
  animation: rise var(--t) ease-in-out var(--d) infinite;
}
.burst {
  top: 38%;
  left: 50%;
  opacity: 0;
}
@keyframes rise {
  0%,
  58%,
  100% {
    opacity: 1;
    transform: none;
  }
  26% {
    opacity: 0;
    transform: translateY(calc(-5 * var(--u))) scale(0.4);
  }
  27% {
    opacity: 0;
    transform: translateY(calc(3 * var(--u))) scale(0.3);
  }
}

/* The count: one digit's box, which a new number rolls into. */
.count {
  position: relative;
  display: inline-block;
  height: 1.2em;
  overflow: hidden;
  overflow: clip;
  font-variant-numeric: tabular-nums;
  line-height: 1.2;
}
.digit {
  display: block;
}
.leaving {
  position: absolute;
  top: 0;
  right: 0;
  opacity: 0;
}
.count[data-roll='up'] .digit {
  animation: in-up 0.46s cubic-bezier(0.3, 1.3, 0.5, 1);
}
.count[data-roll='up'] .leaving {
  animation: out-up 0.36s ease-in;
}
.count[data-roll='down'] .digit {
  animation: in-down 0.46s cubic-bezier(0.3, 1.3, 0.5, 1);
}
.count[data-roll='down'] .leaving {
  animation: out-down 0.36s ease-in;
}
@keyframes in-up {
  from {
    transform: translateY(100%);
  }
}
@keyframes in-down {
  from {
    transform: translateY(-100%);
  }
}
@keyframes out-up {
  from {
    opacity: 1;
    transform: none;
  }
  to {
    opacity: 0;
    transform: translateY(-100%);
  }
}
@keyframes out-down {
  from {
    opacity: 1;
    transform: none;
  }
  to {
    opacity: 0;
    transform: translateY(100%);
  }
}

/* Off screen, or before the page runs, the idle motion holds still at its first frame. */
.stage:not([data-live]) :is(.ripple, .core, .line, .minute, .hour, .flameBody, .flameCore, .spark) {
  animation-play-state: paused;
}

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

/* The demo's pointer moves over the whole stage, in a box of its own, contained, so that nothing
   in it can ever lay out the page around it. */
.pointerLayer {
  position: absolute;
  z-index: 10;
  inset: 0;
  contain: strict;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .stage *,
  .stage *::before,
  .stage *::after {
    animation: none;
    transition: none;
  }
}
```

### task-pills/glyphs.tsx

```tsx
/*
 * The list's drawings, as static SVGs. Nothing inside them ever changes: whatever moves (a clock
 * hand, the flame, a check being drawn) is an HTML box around a drawing, moved by transforms or
 * revealed by a clip. Their colours are the stage's tokens (task-pills.module.css): most take
 * the colour of the box they sit in.
 */

/** The event's calendar: a rounded square with a solid header and four dots. */
export function CalendarGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 21 21" aria-hidden="true">
      <rect
        x="0.85"
        y="0.85"
        width="19.3"
        height="19.3"
        rx="5.4"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.7"
      />
      <path d="M.94 5.25A5.4 5.4 0 0 1 6.25.85h8.5a5.4 5.4 0 0 1 5.31 4.4z" fill="currentColor" />
      <circle cx="9" cy="9.1" r="1.55" fill="currentColor" />
      <circle cx="13.3" cy="9.1" r="1.55" fill="currentColor" />
      <circle cx="4.7" cy="13.4" r="1.55" fill="currentColor" />
      <circle cx="9" cy="13.4" r="1.55" fill="currentColor" />
    </svg>
  );
}

/** A task's empty checkbox: a rounded square. */
export function BoxGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 21 21" aria-hidden="true">
      <rect
        x="1"
        y="1"
        width="19"
        height="19"
        rx="5.6"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.9"
      />
    </svg>
  );
}

/** A habit's checkbox: the same square, drawn as two short dashes a side with open corners. */
export function DashedBoxGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 21 21" aria-hidden="true">
      <path
        d="M4.8 1h4m3.4 0h4M4.8 20h4m3.4 0h4M1 4.8v4m0 3.4v4m19-11.4v4m0 3.4v4"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.8"
      />
    </svg>
  );
}

/** A filled square with a check, for a done task or habit: the check is drawn on its own. */
export function FilledBoxGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 21 21" aria-hidden="true">
      <rect x="0.05" y="0.05" width="20.9" height="20.9" rx="6.5" fill="currentColor" />
    </svg>
  );
}

export function CheckGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 21 21" aria-hidden="true">
      <path
        d="m5.6 10.9 3.3 3.3 6.5-7"
        fill="none"
        stroke="currentColor"
        strokeWidth="2.3"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

/** The overdue clock's face, without its hands (they move). */
export function ClockFace({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 14 14" aria-hidden="true">
      <circle cx="7" cy="7" r="7" fill="currentColor" />
    </svg>
  );
}

/** A done task's mark: a filled circle with a check cut into it. */
export function DoneGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 14 14" aria-hidden="true">
      <circle cx="7" cy="7" r="7" fill="currentColor" />
      <path
        d="m4.1 7.2 2 2 3.9-4.3"
        fill="none"
        style={{ stroke: 'var(--done-mark)' }}
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

/** The streak's flame: a full, rounded drop with a short blunt point. */
export function FlameGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 30.8 36.6" aria-hidden="true">
      <path
        d="M15.4 0c6 2 15.4 12.5 15.4 22 0 13-14.2 14.6-15.4 14.6S0 35 0 22C0 12.5 9.4 2 15.4 0z"
        fill="currentColor"
      />
    </svg>
  );
}

/** The flame's hollow heart, filled with the pill's colour. */
export function FlameCoreGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 30.8 36.6" aria-hidden="true">
      <path
        d="M15.4 18.3c2.2 1.6 6.14 4.3 6.14 9.8 0 2.5-2.14 5.85-6.14 5.85s-6.14-3.35-6.14-5.85c0-5.5 3.94-8.2 6.14-9.8z"
        fill="currentColor"
      />
    </svg>
  );
}
```

### task-pills/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer onto a control and press it, calling the component's own handler as a hand
 * would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress
 * at once: its promise rejects, which ends the script right there, and the pointer fades out.
 *
 * Nothing the pointer does while the demo plays needs a layout, because one here in the same
 * frame as one in the list would make the browser lay out the whole page (see the note at the
 * top of the stylesheet). So it moves by animations the compositor plays, and the press scales a
 * box that keeps a transform at rest, since a box that gains or loses one is laid out again.
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
};

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

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

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

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

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

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

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

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

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    return glide(toStage({ x: box.left + box.width * share.x, y: box.top + box.height * share.y }));
  };

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

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

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

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

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

/** The 20 × 20 drawing at 22 px. Its colours are the stage's tokens (task-pills.module.css). */
const DRAWING: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  overflow: 'visible',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
};

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

### task-pills/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 };
}
```