# Pager pill

> A pill of dots that stretch from page to page, with a rubber band at either end, driving a small carousel.

Previous and next buttons sit round a row of dots, and the current page’s dot is a bar. It stretches towards the next dot and draws its tail in after it, a soft glint riding its leading end. At either end the button dims and a push meets a little rubber band; drag or swipe the cards above and the dots follow, in yellow or green.

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

Category: Navigation.

## Anatomy

1. **Colour switch**: Two themes, yellow and green. Their colours are registered custom properties, so a change cross-fades everything, 600 ms.
2. **Card**: The page on show. Its neighbours shrink by 8% and fade to about half; cards two pages away are gone.
3. **Drawing**: A 48 × 48 line drawing on a wash of the theme, filled with its accent.
4. **Title**: One short line under an uppercase label in the theme’s ink.
5. **Previous**: A round button in the accent. On the first page it dims, but a press still bumps the bar against the end.
6. **Dots**: One dot a page, at half opacity. Absolutely placed and moved with transforms, never laid out again.
7. **Bar**: The current page: two round ends and a middle stretched with `scaleX`. Between pages its front runs ahead and its tail catches up.
8. **Pill**: Holds both buttons and the dots on a soft tint of the theme. ← → Home and End page while a button has focus.

## When to use

- A short run of cards read one at a time: onboarding tips, a feature tour, the steps of a how-to.
- Three to seven pages, few enough that a dot each still counts them at a glance.
- Where both a tap on a button and a swipe across the cards should work, on touch and on a trackpad.
- A small panel or a sheet where a full pager with numbers would be too heavy.

## When not to use

- Long or open-ended lists. Use a scrolling list, or numbered pagination for pages of results.
- Content the reader must compare side by side: a carousel hides all but one card. Use a grid.
- A required sequence with validation between steps. Use a stepper that names each step.
- A hero banner that turns by itself. Content that moves away while being read is hard to follow.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/pager-pill.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 { PagerPill } from './pager-pill';
import { TIPS } from './tips';

export function PlantTips() {
  return <PagerPill tips={TIPS} defaultTheme="green" autoplay={false} />;
}
```

## States

- **First page**: Previous is dimmed; pushing it stretches the bar to the left and springs back.
- **Middle page**: Both buttons live. The neighbours peek in at the sides, faded.
- **Between pages**: Held halfway, as mid-swipe: the bar is stretched over two dots. The glint on its front shows only while it moves.
- **Last page**: Next is dimmed; one more press bumps into the end.
- **Green theme**: The same pager in the second theme: accent, tints and ink all change together.
- **Dark**: Every colour is a `light-dark()` token; each theme keeps its accent and darkens its tints.

## How it works

- One number drives everything: the position, in pages. A spring carries it to the chosen page (motion.ts), and every frame while it moves the cards and dots are painted from it with transforms, outside React. Nothing runs at rest.
- Between two pages the bar’s leading end runs ahead on an ease-out while its tail lags on an ease-in, so it stretches as it travels. The bar is two round ends and a middle stretched with scaleX, so its length changes with transforms alone.
- Dragging moves the position directly, and past an end the cards give less and less. On release they land on one of the two pages either side, the one the drag’s speed heads for, and the spring keeps that speed.
- A press past either end gives the position a push the spring brings back: the cards nudge about 20px, the bar stretches towards the edge, and both settle.
- The theme colours are registered custom properties (@property, prefixed --pager-), so switching between yellow and green cross-fades every colour that uses them.
- The stage and card are light-dark() tokens on the root, so they follow the page’s color-scheme, or the colorScheme prop. The yellow and green themes keep their accent colours and only their tints change at night; theme colours are registered properties, so a change of theme or of scheme cross-fades.

## API reference

- **tips** (`Tip[]`, default `TIPS`): The cards, in order: an id, a label, a title, a line of text and a 48 × 48 drawing each.
- **defaultTheme** (`'yellow' | 'green'`, default `'yellow'`): The colours it starts in.
- **defaultPage** (`number`): The card it opens on, from 0; a fraction (2.5) holds the cards and dots halfway between two pages until the next input. Turns the demo off.
- **onPageChange** (`(page: number) => void`): Called with the page it heads for, from 0, whenever the page changes.
- **onThemeChange** (`(theme: 'yellow' | 'green') => void`): Called with the new colours whenever they change.
- **autoplay** (`boolean`, default `true`): Pages through the cards, bumps the end and changes colour with a demo cursor while on screen and untouched.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The carousel is a labelled region of slides. Cards off the page are hidden from screen readers, and page changes are announced (“Tip 2 of 5: Water deeply, then wait”).
- Previous and next are real buttons. At an end they stay focusable but are aria-disabled, and pushing one announces “This is the last tip.” The arrow keys (and Home, End) work while either button has focus.
- Only horizontal drags go to the cards (touch-action: pan-y), so a vertical swipe still scrolls the page. A two-finger trackpad swipe turns one page without triggering the browser’s back gesture.
- Under reduced motion the cards and dots go straight to the page and the colours change at once, with no demo. The yellow theme uses dark chevrons, for contrast.

## Source

### pager-pill/pager-pill.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent,
  type SVGProps,
} from 'react';
import { DemoCursor, glideTo, hideCursor, pressWith } from './demo-cursor';
import { cardLook, dotsStyles, dotsWidth, rubberBand, springStep } from './motion';
import { TIPS, type Tip } from './tips';
import { useAutoplay } from './use-autoplay';
import styles from './pager-pill.module.css';

/*
 * Pager pill: a pill with a previous and a next button round a row of dots, driving a small
 * carousel of cards. The current page's dot is a bar: as the page changes, the bar stretches
 * towards the next dot and draws its tail in after it, a glint riding its leading end. At either
 * end the button dims, and pushing past it meets a little rubber band. Drag or swipe the cards
 * and the dots follow along.
 *
 * - One number drives everything: the position, in pages. A spring (motion.ts) carries it to the
 *   chosen page, keeping the speed of a drag you let go of; every frame while it moves, the cards
 *   and the dots are painted from it with transforms, outside React.
 * - The dots are absolutely placed circles. The bar is two round ends and a middle stretched with
 *   scaleX, so it changes length with transforms alone.
 * - Two themes, yellow and green: their colours are registered custom properties (@property), so
 *   a change of theme cross-fades everything that uses them.
 * - Light or dark: every colour is a light-dark() token in the CSS, the themes' included (each
 *   keeps its accent and darkens its tints), so the scheme is the page's color-scheme, or the one
 *   the colorScheme prop sets.
 * - Input: drag the cards with a mouse, a pen or a finger (the cards take horizontal moves only,
 *   so a vertical swipe still scrolls the page), or swipe across them with two fingers on a
 *   trackpad. Keyboard: the buttons, and ← → (Home, End) while one of them has focus. Page
 *   changes are announced to screen readers.
 * - While on screen and untouched, a demo cursor pages through the cards, bumps into the end,
 *   changes colour and pages back (demo-cursor.tsx).
 * - Under reduced motion, the cards and the dots go straight to the page.
 */

export type Theme = 'yellow' | 'green';

const THEMES: { id: Theme; name: string }[] = [
  { id: 'yellow', name: 'Yellow' },
  { id: 'green', name: 'Green' },
];

/** A push past either end: the speed it gives the position, in pages per second. */
const BUMP = 3.2;
/** A drag heads for where its speed would carry the cards in this long, in seconds. */
const PROJECTION = 0.2;
/** How far a press travels before it turns into a drag, in px. */
const DRAG_DISTANCE = 6;
/** A horizontal trackpad swipe turns one page once it has travelled this many px... */
const SWIPE_DISTANCE = 40;
/** ...and ends when its events (momentum included) pause this long, in ms. */
const SWIPE_END = 220;
/** How long the demo's pointer rests on a button before pressing it, then holds the press, in ms. */
const HOVER = 260;
const PRESS = 150;

/** A press on the cards, which may turn into a drag. */
type Drag = {
  pointerId: number;
  /** Where it started: the pointer, in screen px, and the position. */
  x: number;
  start: number;
  /** The page the cards were heading to. */
  from: number;
  /** A page's width, in the cards' px, and screen px per px, should they sit in a scaled box. */
  width: number;
  scale: number;
  moved: boolean;
  /** The last 100 ms of positions, for the speed at release. */
  samples: { time: number; position: number }[];
};

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

/** Adds a sample, keeps the last 100 ms of them, and returns the speed over them in pages/s. */
function track(samples: Drag['samples'], time: number, position: number) {
  samples.push({ time, position });
  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();
  const first = samples[0];
  const seconds = (time - first.time) / 1000;
  return seconds > 0 ? (position - first.position) / seconds : 0;
}

export function PagerPill({
  tips = TIPS,
  defaultTheme = 'yellow',
  autoplay = true,
  colorScheme,
  defaultPage,
  onPageChange,
  onThemeChange,
  className = '',
}: {
  /** The cards, in order: a label, a title, a line of text and a drawing each. */
  tips?: Tip[];
  /** The colours it starts in. */
  defaultTheme?: Theme;
  /** Pages through the cards on its own while on screen and untouched. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * The card it opens on, from 0; a fraction (2.5) holds the cards and the dots halfway between
   * two pages, the bar stretched, until the next input. Turns the demo off.
   */
  defaultPage?: number;
  /** Called with the page it heads for, from 0, whenever the page changes. */
  onPageChange?: (page: number) => void;
  /** Called when the colours change. */
  onThemeChange?: (theme: Theme) => void;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const count = tips.length;
  const last = count - 1;
  /** Where it opens: a position in pages, and the page it counts as. */
  const opening = Math.min(last, Math.max(0, defaultPage ?? 0));
  const openingPage = Math.round(opening);
  const [page, setPage] = useState(openingPage);
  const [theme, setTheme] = useState(defaultTheme);
  /** Where the demo's pointer is: a button ('prev', 'next' or a theme), pressed or not. */
  const [ghost, setGhost] = useState<{ target: string; pressed: boolean } | null>(null);
  const [message, setMessage] = useState('');
  const trackId = useId();

  const cards = useRef<(HTMLElement | null)[]>([]);
  const dots = useRef<(HTMLSpanElement | null)[]>([]);
  const start = useRef<HTMLSpanElement>(null);
  const middle = useRef<HTMLSpanElement>(null);
  const end = useRef<HTMLSpanElement>(null);
  const glint = useRef<HTMLSpanElement>(null);
  const ruler = useRef<HTMLSpanElement>(null);
  const carousel = useRef<HTMLElement>(null);
  const cursor = useRef<HTMLSpanElement>(null);
  /** The position and its speed, where the spring heads (a page), and its frame. */
  const motion = useRef({
    position: opening,
    velocity: 0,
    target: openingPage,
    time: 0,
    frame: 0,
  });
  const drag = useRef<Drag | null>(null);

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

  /** Paints the cards and the dots at `position`, moving at `velocity`. */
  const paint = (position: number, velocity: number) => {
    cards.current.forEach((card, i) => {
      if (!card) return;
      const look = cardLook(i - position);
      card.style.transform = look.transform;
      card.style.opacity = look.opacity;
    });
    const look = dotsStyles(count, position, velocity);
    dots.current.forEach((dot, k) => {
      if (dot) dot.style.transform = look.dots[k].transform;
    });
    if (start.current) start.current.style.transform = look.start.transform;
    if (middle.current) middle.current.style.transform = look.middle.transform;
    if (end.current) end.current.style.transform = look.end.transform;
    if (glint.current) {
      glint.current.style.transform = look.glint.transform;
      glint.current.style.opacity = look.glint.opacity;
    }
  };

  /** Runs the spring until the position rests on its target, painting every frame. */
  const run = () => {
    const m = motion.current;
    if (m.frame) return;
    root.current?.setAttribute('data-moving', '');
    m.time = performance.now();
    const step = (now: number) => {
      const dt = Math.min(0.032, Math.max(0, (now - m.time) / 1000));
      m.time = now;
      const next = springStep(m.position, m.velocity, m.target, dt);
      m.position = next.position;
      m.velocity = next.velocity;
      const done = Math.abs(m.target - m.position) < 0.0005 && Math.abs(m.velocity) < 0.01;
      if (done) {
        m.position = m.target;
        m.velocity = 0;
      }
      paint(m.position, m.velocity);
      if (done) {
        m.frame = 0;
        root.current?.removeAttribute('data-moving');
      } else m.frame = requestAnimationFrame(step);
    };
    m.frame = requestAnimationFrame(step);
  };

  const stop = () => {
    cancelAnimationFrame(motion.current.frame);
    motion.current.frame = 0;
    root.current?.removeAttribute('data-moving');
  };

  /** Heads for page `next`; past either end, bumps into the rubber band instead. */
  const goTo = (next: number, byReader: boolean) => {
    const m = motion.current;
    const reduce = prefersReducedMotion();
    if (next < 0 || next > last) {
      if (byReader) announce(next < 0 ? 'This is the first tip.' : 'This is the last tip.');
      if (reduce) return;
      m.velocity += Math.sign(next - m.target) * BUMP;
      run();
      return;
    }
    if (next !== m.target) onPageChange?.(next);
    m.target = next;
    setPage(next);
    if (byReader) announce(`Tip ${next + 1} of ${count}: ${tips[next].title}`);
    if (!reduce) {
      run();
      return;
    }
    stop();
    m.position = next;
    m.velocity = 0;
    paint(next, 0);
  };

  const chooseTheme = (next: Theme, byReader: boolean) => {
    setTheme(next);
    if (next !== theme) onThemeChange?.(next);
    if (byReader) announce(`${THEMES.find((option) => option.id === next)?.name} theme.`);
  };

  const onPointerDown = (event: PointerEvent<HTMLElement>) => {
    if (!event.isPrimary || event.button !== 0 || !ruler.current) return;
    const m = motion.current;
    // A press catches the cards where they are.
    stop();
    const box = event.currentTarget;
    drag.current = {
      pointerId: event.pointerId,
      x: event.clientX,
      start: m.position,
      from: m.target,
      width: ruler.current.offsetWidth,
      scale: box.getBoundingClientRect().width / box.offsetWidth || 1,
      moved: false,
      samples: [{ time: event.timeStamp, position: m.position }],
    };
    box.setPointerCapture(event.pointerId);
  };

  const onPointerMove = (event: PointerEvent<HTMLElement>) => {
    const current = drag.current;
    if (!current || current.pointerId !== event.pointerId) return;
    const dx = (event.clientX - current.x) / current.scale;
    if (!current.moved) {
      if (Math.abs(dx) < DRAG_DISTANCE) return;
      current.moved = true;
      root.current?.setAttribute('data-dragging', '');
    }
    // Past either end, the cards follow less and less.
    const raw = current.start - dx / current.width;
    const position = raw < 0 ? -rubberBand(-raw) : raw > last ? last + rubberBand(raw - last) : raw;
    motion.current.position = position;
    paint(position, track(current.samples, event.timeStamp, position));
  };

  // The press ends. A drag lands on one of the two pages either side of the cards, the one its
  // speed heads for: a flick turns one page, never more.
  const onPointerEnd = (event: PointerEvent<HTMLElement>) => {
    const current = drag.current;
    if (!current || current.pointerId !== event.pointerId) return;
    drag.current = null;
    root.current?.removeAttribute('data-dragging');
    const m = motion.current;
    if (!current.moved) {
      // A press without a drag: the cards carry on where they were going.
      run();
      return;
    }
    // Held still before letting go, it has no speed left.
    const velocity =
      event.type === 'pointerup' ? track(current.samples, event.timeStamp, m.position) : 0;
    const landing = Math.round(m.position + velocity * PROJECTION);
    const between = Math.max(Math.floor(m.position), Math.min(Math.ceil(m.position), landing));
    const next = Math.min(last, Math.max(0, between));
    m.velocity = velocity;
    if (next !== current.from) goTo(next, true);
    else if (!prefersReducedMotion()) run();
    else {
      // Back to the page it left, at once.
      m.position = next;
      m.velocity = 0;
      paint(next, 0);
    }
  };

  const onPagerKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const target = motion.current.target;
    const moves: Record<string, number> = {
      ArrowLeft: target - 1,
      ArrowRight: target + 1,
      Home: 0,
      End: last,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    goTo(moves[event.key], true);
  };

  // Nothing keeps running once the pager is gone.
  useEffect(() => () => cancelAnimationFrame(motion.current.frame), []);

  // The listeners and the demo outlive renders: they reach the latest state and actions here.
  const latest = useRef({ theme, goTo, chooseTheme });
  useLayoutEffect(() => {
    latest.current = { theme, goTo, chooseTheme };
  });

  // Two fingers on a trackpad: a horizontal swipe over the cards turns one page. Its listener is
  // not passive, to keep the swipe from the browser's back and forward gestures.
  useEffect(() => {
    const box = carousel.current;
    if (!box) return;
    let travelled = 0;
    let turned = false;
    let rest = 0;
    const onWheel = (event: WheelEvent) => {
      if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
      event.preventDefault();
      take();
      window.clearTimeout(rest);
      rest = window.setTimeout(() => {
        travelled = 0;
        turned = false;
      }, SWIPE_END);
      if (turned) return;
      travelled += event.deltaX;
      if (Math.abs(travelled) < SWIPE_DISTANCE) return;
      turned = true;
      latest.current.goTo(motion.current.target + Math.sign(travelled), true);
    };
    box.addEventListener('wheel', onWheel, { passive: false });
    return () => {
      box.removeEventListener('wheel', onWheel);
      window.clearTimeout(rest);
    };
  }, [take]);

  // The demo: page through to the last card and bump into the end, change colour, page back and
  // bump into the start. While on screen and untouched.
  const touring = autoplay && defaultPage === undefined && playing;
  useEffect(() => {
    if (!touring) return;
    let alive = true;
    let timer = 0;
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        timer = window.setTimeout(resolve, ms);
      });
    // Glides the cursor to `target` ('prev', 'next' or a theme), presses it, lets go and acts;
    // false if the demo stopped.
    const press = async (target: string, act: () => void) => {
      // The reader is in the pager (it has their focus): the demo waits for them to leave it.
      if (root.current?.contains(document.activeElement)) {
        take();
        return false;
      }
      const pointer = cursor.current;
      const button = root.current?.querySelector(`[data-target="${target}"]`);
      if (!pointer || !button) return false;
      await wait(glideTo(pointer, button));
      if (!alive) return false;
      setGhost({ target, pressed: false });
      await wait(HOVER);
      if (!alive) return false;
      setGhost({ target, pressed: true });
      pressWith(pointer, true);
      await wait(PRESS);
      if (!alive) return false;
      setGhost(null);
      pressWith(pointer, false);
      act();
      return true;
    };
    const step = (by: number) => () => latest.current.goTo(motion.current.target + by, false);
    (async () => {
      await wait(1200);
      while (alive) {
        while (motion.current.target < last) {
          if (!(await press('next', step(1)))) return;
          await wait(1100);
        }
        if (!(await press('next', step(1)))) return;
        await wait(1300);
        const other = latest.current.theme === 'yellow' ? 'green' : 'yellow';
        if (!(await press(other, () => latest.current.chooseTheme(other, false)))) return;
        await wait(1200);
        while (motion.current.target > 0) {
          if (!(await press('prev', step(-1)))) return;
          await wait(800);
        }
        if (!(await press('prev', step(-1)))) return;
        await wait(1600);
      }
    })();
    return () => {
      alive = false;
      window.clearTimeout(timer);
      setGhost(null);
      if (cursor.current) hideCursor(cursor.current);
    };
  }, [touring, last, root, take]);

  /** The demo pointer's mark for a button. */
  const ghostOn = (target: string) =>
    ghost?.target === target ? (ghost.pressed ? 'pressed' : '') : undefined;

  // The first frame, painted by React (and on the server); every later one by paint().
  const first = dotsStyles(count, opening, 0);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-theme={theme}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocusCapture={take}
    >
      <div className={styles.stage}>
        <div className={styles.themes} role="group" aria-label="Colour" data-part="themes">
          {THEMES.map((option) => (
            <button
              key={option.id}
              type="button"
              className={styles.theme}
              data-part="theme"
              data-option={option.id}
              data-target={option.id}
              aria-pressed={theme === option.id}
              data-ghost={ghostOn(option.id)}
              onClick={() => chooseTheme(option.id, true)}
            >
              <span className={styles.swatch} aria-hidden="true" />
              {option.name}
            </button>
          ))}
        </div>

        <section
          ref={carousel}
          className={styles.carousel}
          aria-roledescription="carousel"
          aria-label="Plant care tips"
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerEnd}
          onPointerCancel={onPointerEnd}
          onLostPointerCapture={onPointerEnd}
        >
          <div id={trackId} className={styles.track}>
            {tips.map((tip, i) => (
              <article
                key={tip.id}
                ref={(element) => {
                  cards.current[i] = element;
                }}
                className={styles.card}
                data-part="card"
                role="group"
                aria-roledescription="slide"
                aria-label={`${i + 1} of ${count}`}
                aria-hidden={i !== page || undefined}
                style={cardLook(i - opening)}
              >
                <div className={styles.art} data-part="art">
                  {tip.art}
                </div>
                <p className={styles.label}>{tip.label}</p>
                <p className={styles.title} data-part="title">
                  {tip.title}
                </p>
                <p className={styles.text}>{tip.text}</p>
              </article>
            ))}
          </div>
          <span ref={ruler} className={styles.ruler} aria-hidden="true" />
        </section>

        <div
          className={styles.pager}
          data-part="pager"
          role="group"
          aria-label="Tips"
          onKeyDown={onPagerKeyDown}
        >
          <button
            type="button"
            className={styles.nav}
            aria-label="Previous tip"
            data-part="prev"
            data-target="prev"
            aria-controls={trackId}
            aria-disabled={page === 0}
            data-ghost={ghostOn('prev')}
            onClick={() => goTo(motion.current.target - 1, true)}
          >
            <ChevronIcon />
          </button>
          <div
            className={styles.dots}
            data-part="dots"
            style={{ width: `${dotsWidth(count)}em` }}
            aria-hidden="true"
          >
            {tips.map((tip, k) => (
              <span
                key={tip.id}
                ref={(element) => {
                  dots.current[k] = element;
                }}
                className={styles.dot}
                style={first.dots[k]}
              />
            ))}
            <span ref={middle} className={styles.middle} data-part="bar" style={first.middle} />
            <span ref={start} className={styles.end} style={first.start} />
            <span ref={end} className={styles.end} style={first.end} />
            <span ref={glint} className={styles.glint} style={first.glint} />
          </div>
          <button
            type="button"
            className={styles.nav}
            aria-label="Next tip"
            data-part="next"
            data-target="next"
            aria-controls={trackId}
            aria-disabled={page === last}
            data-ghost={ghostOn('next')}
            onClick={() => goTo(motion.current.target + 1, true)}
          >
            <ChevronIcon className={styles.flip} />
          </button>
        </div>
        <DemoCursor ref={cursor} />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** A chevron pointing left; flipped, right. */
function ChevronIcon(props: SVGProps<SVGSVGElement>) {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.4"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      <path d="m11.75 5-5 5 5 5" />
    </svg>
  );
}
```

### pager-pill/pager-pill.module.css

```css
/* Pager pill (pager-pill.tsx): a light stage with the theme switch, the cards, and the pill, or at
   night a near-black stage with dark grey cards. Sizes are in em: the font-size follows the
   component's width (a container), from 14px on a phone to 16.5px. */

/* The theme's colours, registered so that they can transition: a change of theme cross-fades
   everything that uses them. Prefixed, as @property names are global. */
@property --pager-accent {
  syntax: '<color>';
  inherits: true;
  initial-value: #f2c230;
}
@property --pager-on-accent {
  syntax: '<color>';
  inherits: true;
  initial-value: #4d3a00;
}
@property --pager-soft {
  syntax: '<color>';
  inherits: true;
  initial-value: #fbf0c8;
}
@property --pager-dim {
  syntax: '<color>';
  inherits: true;
  initial-value: #f6dd8c;
}
@property --pager-ink {
  syntax: '<color>';
  inherits: true;
  initial-value: #8a6500;
}
@property --pager-wash {
  syntax: '<color>';
  inherits: true;
  initial-value: #fcf5dc;
}

.root {
  /* The theme's colours, yellow here and green below. Its accent and what sits on it are the same
     by day and by night; the rest are light-dark() pairs, by the color-scheme the root inherits
     from the page or the one the colorScheme prop sets (below): the pill, a button at an end,
     the ink of the labels and drawings, and the drawings' ground. */
  --pager-accent: #f2c230;
  --pager-on-accent: #4d3a00;
  --pager-soft: #fbf0c8;
  --pager-dim: #f6dd8c;
  --pager-ink: #8a6500;
  --pager-wash: #fcf5dc;
  /* The stage, then the cards and the theme switch a step up; the chosen theme in the switch,
     and pressed by the demo. */
  --surface: #eaeaed;
  --surface-2: #fff;
  --surface-3: #f0f0f3;
  --surface-3-pressed: #e7e7eb;
  /* Titles and the chosen theme; card text and the other themes. */
  --ink: #1d1d1f;
  --ink-muted: #6e6e73;
  /* The stage's edge and dots, the cards' edge, the switch's edge. */
  --line: rgb(0 0 0 / 0.06);
  --dots: rgb(0 0 0 / 0.05);
  --card-line: rgb(0 0 0 / 0.04);
  --switch-line: rgb(0 0 0 / 0.05);
  /* The cards' soft drop and the shade right under them and under the switch. */
  --shadow: rgb(0 0 0 / 0.25);
  --shadow-near: rgb(0 0 0 / 0.05);
  --switch-shadow: rgb(0 0 0 / 0.04);
  --focus: #0a84ff;
  container-type: inline-size;
  min-width: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  transition:
    --pager-accent 0.6s ease,
    --pager-on-accent 0.6s ease,
    --pager-soft 0.6s ease,
    --pager-dim 0.6s ease,
    --pager-ink 0.6s ease,
    --pager-wash 0.6s ease;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --pager-soft: light-dark(#fbf0c8, #3a3118);
    --pager-dim: light-dark(#f6dd8c, #9f8125);
    --pager-ink: light-dark(#8a6500, #f7dc8a);
    --pager-wash: light-dark(#fcf5dc, #363020);
    --surface: light-dark(#eaeaed, #111113);
    --surface-2: light-dark(#fff, #1c1c1e);
    --surface-3: light-dark(#f0f0f3, #2c2c2e);
    --surface-3-pressed: light-dark(#e7e7eb, #363638);
    --ink: light-dark(#1d1d1f, #f5f5f7);
    --ink-muted: light-dark(#6e6e73, #a1a1a6);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --dots: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --card-line: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.08));
    --switch-line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --shadow: light-dark(rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.6));
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --switch-shadow: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --focus: light-dark(#0a84ff, #3594ff);
  }
}
.root[data-theme='green'] {
  --pager-accent: #2f9e52;
  --pager-on-accent: #fff;
  --pager-soft: #dcf1e2;
  --pager-dim: #a3d4ae;
  --pager-ink: #1f7a3b;
  --pager-wash: #eaf6ed;
}
@supports (color: light-dark(#fff, #000)) {
  .root[data-theme='green'] {
    --pager-soft: light-dark(#dcf1e2, #162a1e);
    --pager-dim: light-dark(#a3d4ae, #205833);
    --pager-ink: light-dark(#1f7a3b, #7fdc9b);
    --pager-wash: light-dark(#eaf6ed, #1e2c24);
  }
}

/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.stage {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.5em;
  min-height: max(460px, 58cqi);
  padding: 22px 0 28px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: clamp(14px, 11.5px + 0.55cqi, 16.5px);
  line-height: 1.3;
}

/* The theme switch. */
.themes {
  display: inline-flex;
  gap: 0.2em;
  padding: 0.2em;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--switch-line),
    0 1px 2px var(--switch-shadow);
}
.theme {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  height: 2.2em;
  margin: 0;
  padding: 0 0.9em 0 0.7em;
  border: 0;
  border-radius: 999px;
  background: transparent;
  font: inherit;
  font-size: 0.8em;
  font-weight: 500;
  color: var(--ink-muted);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.theme[aria-pressed='true'] {
  background: var(--surface-3);
  color: var(--ink);
}
@media (hover: hover) {
  .theme:hover {
    color: var(--ink);
  }
}
.theme[data-ghost] {
  color: var(--ink);
}
.theme[data-ghost='pressed'] {
  background: var(--surface-3-pressed);
}
.swatch {
  width: 0.75em;
  height: 0.75em;
  border-radius: 50%;
}
.theme[data-option='yellow'] .swatch {
  background: #f2c230;
}
.theme[data-option='green'] .swatch {
  background: #2f9e52;
}

/* The cards: stacked in one grid cell, each moved to its place by paint(). Only the cards take
   horizontal drags: a vertical swipe still scrolls the page. */
.carousel {
  --card: 15em;
  --page: calc(var(--card) + 1.25em);
  position: relative;
  width: 100%;
  touch-action: pan-y;
  cursor: grab;
  -webkit-user-select: none;
  user-select: none;
}
.root[data-dragging] .carousel {
  cursor: grabbing;
}
.track {
  display: grid;
  justify-items: center;
}
.card {
  grid-area: 1 / 1;
  width: var(--card);
  padding: 0.75em 0.75em 1.15em;
  border-radius: 1.4em;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--card-line),
    0 1px 2px var(--shadow-near),
    0 14px 30px -18px var(--shadow);
}
/* Layers for the cards only while they move. */
.root[data-moving] .card,
.root[data-dragging] .card {
  will-change: transform, opacity;
}
.art {
  display: grid;
  place-items: center;
  height: 7.5em;
  border-radius: 1em;
  background: var(--pager-wash);
  color: var(--pager-ink);
}
.art svg {
  width: 3.6em;
  height: 3.6em;
}
.label,
.title,
.text {
  margin: 0;
  padding: 0 0.3em;
}
.label {
  margin-top: 1em;
  font-size: 0.72em;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--pager-ink);
}
.title {
  margin-top: 0.3em;
  font-size: 1.06em;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.text {
  margin-top: 0.4em;
  font-size: 0.84em;
  line-height: 1.45;
  color: var(--ink-muted);
}
/* A page's width, measured when a drag starts. */
.ruler {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--page);
  height: 0;
  visibility: hidden;
}

/* The pill. */
.pager {
  display: flex;
  align-items: center;
  gap: 0.9em;
  padding: 0.3em;
  border-radius: 999px;
  background: var(--pager-soft);
}
.nav {
  display: grid;
  place-items: center;
  width: 2.75em;
  height: 2.75em;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--pager-accent);
  color: var(--pager-on-accent);
  cursor: pointer;
  transition: transform 0.25s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.nav svg {
  width: 1.25em;
  height: 1.25em;
}
.flip {
  transform: scaleX(-1);
}
/* At an end: dimmed, but still there to push against. */
.nav[aria-disabled='true'] {
  background: var(--pager-dim);
  cursor: default;
}
.nav[aria-disabled='true'] svg {
  opacity: 0.75;
}
@media (hover: hover) {
  .nav:not([aria-disabled='true']):hover {
    transform: scale(1.05);
  }
}
.nav[data-ghost]:not([aria-disabled='true']) {
  transform: scale(1.05);
}
.nav:active,
.nav[data-ghost='pressed'] {
  transform: scale(0.9);
  transition-duration: 0.1s;
}
.nav:focus-visible,
.theme:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The dots, the bar (two round ends and a middle) and the glint: all placed by paint(). */
.dots {
  position: relative;
  flex: none;
  height: 0.5em;
}
.dot,
.end,
.middle,
.glint {
  position: absolute;
  top: 0;
  left: 0;
}
.dot,
.end {
  width: 0.5em;
  height: 0.5em;
  border-radius: 50%;
  background: var(--pager-accent);
}
.dot {
  opacity: 0.5;
}
.middle {
  width: 1em;
  height: 0.5em;
  background: var(--pager-accent);
  transform-origin: 0 0;
}
.glint {
  top: -0.2em;
  width: 1.1em;
  height: 0.9em;
  margin-left: -0.3em;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(255 255 255 / 0.95), rgb(255 255 255 / 0));
  pointer-events: none;
}

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

@media (prefers-reduced-motion: reduce) {
  .root,
  .theme,
  .nav {
    transition: none;
  }
}
```

### pager-pill/motion.ts

```tsx
/*
 * The pager's motion, as pure functions of the position: a float counted in pages (1.5 is halfway
 * from the second card to the third). The component moves the position with a spring and paints
 * the cards and the dots from it, every frame while it moves.
 */

/** The spring that carries the position to its page: it overshoots a touch, then settles. */
const STIFFNESS = 230;
const DAMPING = 24;

/** One step of the spring, `dt` seconds long, from `position` and `velocity` (pages per second). */
export function springStep(position: number, velocity: number, target: number, dt: number) {
  // Four small steps of semi-implicit Euler: stable, and plenty precise for a few frames' worth.
  const h = dt / 4;
  for (let i = 0; i < 4; i += 1) {
    velocity += (STIFFNESS * (target - position) - DAMPING * velocity) * h;
    position += velocity * h;
  }
  return { position, velocity };
}

/** Dragged past an end, the cards give less and less: `overshoot` pages pulled, pages moved. */
export function rubberBand(overshoot: number) {
  return 1 - 1 / (overshoot * 0.55 + 1);
}

/**
 * A card's look `distance` pages from the centre: its neighbours shrink a little and fade to about
 * half, and the cards beyond fade out by two pages away.
 */
export function cardLook(distance: number) {
  const away = Math.abs(distance);
  const scale = 1 - 0.08 * Math.min(away, 1);
  const opacity = Math.max(0, 1 - 0.45 * Math.min(away, 1) - 0.55 * Math.max(0, away - 1));
  return {
    transform: `translateX(calc(${distance.toFixed(4)} * var(--page))) scale(${scale.toFixed(4)})`,
    opacity: opacity.toFixed(3),
  };
}

/* The dots, in em: a dot, the gap between dots, and the bar the current page's dot stretches to. */
const DOT = 0.5;
const GAP = 0.375;
const BAR = 1.75;
const PITCH = DOT + GAP;
/** Past either end the bar stretches towards the edge, up to this much, in em. */
const STRETCH = 0.9;

/** How far the bar stretches when the position is `overshoot` pages past an end. */
const stretch = (overshoot: number) => STRETCH * (1 - 1 / (1 + overshoot * 6));

/** The dots' width, in em. */
export function dotsWidth(count: number) {
  return (count - 1) * PITCH + BAR;
}

const easeIn = (t: number) => t * t * t;
const easeOut = (t: number) => 1 - (1 - t) ** 3;

/**
 * Where everything in the dots sits at `position`, in em from their left: each dot, the bar's two
 * ends, and the glint on the bar's leading end, which shows with speed (`velocity`, in pages per
 * second).
 *
 * Between two pages the bar's leading end runs ahead (ease-out) while its trailing end lags
 * (ease-in), so the bar stretches as it travels and settles to its length at the next page; the
 * dot it reaches slides under it and the dot it leaves comes out from behind it. Past either end,
 * the bar stretches towards the edge.
 */
function dotsLayout(count: number, position: number, velocity: number) {
  const last = count - 1;
  let left: number;
  let right: number;
  if (position < 0) {
    left = -stretch(-position);
    right = BAR;
  } else if (position > last) {
    left = last * PITCH;
    right = last * PITCH + BAR + stretch(position - last);
  } else {
    const page = Math.min(Math.floor(position), last - 1);
    const t = Math.max(0, position - page);
    left = (page + easeIn(t)) * PITCH;
    right = (page + easeOut(t)) * PITCH + BAR;
  }
  const dots = Array.from(
    { length: count },
    (_, k) => k * PITCH + (BAR - DOT) * Math.min(1, Math.max(0, k - position)),
  );
  return {
    dots,
    left,
    right,
    // The bar's middle, between the centres of its rounded ends.
    middle: Math.max(0, right - left - DOT),
    glint: velocity >= 0 ? right - DOT : left,
    shine: Math.min(1, Math.abs(velocity) / 3),
  };
}

/** The dots' inline styles at `position`: for the first render, and for every frame after. */
export function dotsStyles(count: number, position: number, velocity: number) {
  const layout = dotsLayout(count, position, velocity);
  const at = (x: number) => `translateX(${x.toFixed(4)}em)`;
  return {
    dots: layout.dots.map((x) => ({ transform: at(x) })),
    start: { transform: at(layout.left) },
    end: { transform: at(layout.right - DOT) },
    middle: { transform: `${at(layout.left + DOT / 2)} scaleX(${layout.middle.toFixed(4)})` },
    glint: { transform: at(layout.glint), opacity: layout.shine.toFixed(3) },
  };
}
```

### pager-pill/tips.tsx

```tsx
import type { ReactNode, SVGProps } from 'react';

/*
 * The sample cards: five tips for keeping a house plant happy, each with a small drawing. The
 * drawings fill with the theme's colour (--pager-accent) and draw in its ink (currentColor).
 */

export type Tip = {
  id: string;
  /** A word above the title. */
  label: string;
  title: string;
  text: string;
  /** A 48 × 48 drawing. */
  art: ReactNode;
};

function Art({ children, ...props }: SVGProps<SVGSVGElement>) {
  return (
    <svg
      viewBox="0 0 48 48"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      {children}
    </svg>
  );
}

const fill = { fill: 'var(--pager-accent)', stroke: 'none' };

export const TIPS: Tip[] = [
  {
    id: 'light',
    label: 'Light',
    title: 'Find the bright spot',
    text: 'Most leafy plants love bright light, just out of the direct midday sun.',
    art: (
      <Art>
        <circle cx="24" cy="24" r="8.5" {...fill} />
        <path d="M24 6.5v5M24 36.5v5M6.5 24h5M36.5 24h5M11.6 11.6l3.5 3.5M32.9 32.9l3.5 3.5M36.4 11.6l-3.5 3.5M15.1 32.9l-3.5 3.5" />
      </Art>
    ),
  },
  {
    id: 'water',
    label: 'Water',
    title: 'Water deeply, then wait',
    text: 'Soak the soil through, and water again once the top few centimetres are dry.',
    art: (
      <Art>
        <path d="M24 6.5c6.4 7.8 11 14 11 20a11 11 0 0 1-22 0c0-6 4.6-12.2 11-20z" {...fill} />
        <path d="M18.5 27.5a5.5 5.5 0 0 0 5 5" stroke="#fff" />
      </Art>
    ),
  },
  {
    id: 'food',
    label: 'Food',
    title: 'Feed while it grows',
    text: 'A mild feed every couple of weeks in spring and summer keeps new leaves coming.',
    art: (
      <Art>
        <path d="M24 25.5c0-6.4-4.6-10.5-11.5-10.5 0 6.8 4.6 10.5 11.5 10.5z" {...fill} />
        <path d="M24 21.5c0-7 4.8-11.5 12-11.5 0 7.4-4.8 11.5-12 11.5z" {...fill} />
        <path d="M24 40V21.5M16 40h16" />
      </Art>
    ),
  },
  {
    id: 'roots',
    label: 'Roots',
    title: 'Repot when roots peek out',
    text: 'Move up one pot size, with fresh soil and a hole in the bottom for drainage.',
    art: (
      <Art>
        <path d="M13.5 26h21l-2.8 14.5H16.3z" {...fill} />
        <path d="M11.5 26h25M24 26V15.5" />
        <path d="M24 18.5c-.8-4.5-3.8-7-8.5-7 0 4.6 3.2 7 8.5 7zM24 16c.8-4.5 3.8-7 8.5-7 0 4.6-3.2 7-8.5 7z" />
      </Art>
    ),
  },
  {
    id: 'rest',
    label: 'Rest',
    title: 'Let it rest in winter',
    text: 'Water less, skip the feed, and keep it away from radiators until spring.',
    art: (
      <Art>
        <path d="M29 9.5a15 15 0 1 0 9.5 26A13 13 0 0 1 29 9.5z" {...fill} />
        <path d="M13 10v5M10.5 12.5h5M38 18v4M36 20h4" />
      </Art>
    ),
  },
];
```

### pager-pill/demo-cursor.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './demo-cursor.module.css';

/*
 * The demo's pointer: while the demo plays on its own, an arrow glides to what it is about to
 * press and dips as it presses, so the demo reads like someone using the component. It sits in
 * the stage (its offset parent). Each glide is a CSS transition, the two axes on different
 * easings so that the path bends a little: nothing runs between moves.
 */

const ARROW = 'M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z';

export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true">
      <span className={styles.vertical}>
        <span className={styles.ring} />
        <svg className={styles.arrow} viewBox="0 0 20 20">
          {/* Its shadow: the arrow again, offset and faint (a CSS filter would cost a large
              GPU surface while the cursor moves). */}
          <path className={styles.shade} d={ARROW} transform="translate(0.3 1.1)" />
          <path className={styles.body} d={ARROW} />
        </svg>
      </span>
    </span>
  );
}

/** Puts the arrow's tip at (x, y) in the stage's px, gliding there in `ms`. */
function place(cursor: HTMLElement, x: number, y: number, ms: number) {
  cursor.style.setProperty('--move', `${ms}ms`);
  cursor.style.setProperty('--x', `${x}px`);
  cursor.style.setProperty('--y', `${y}px`);
}

/**
 * Glides the cursor to the middle of `target` (it fades in nearby first, if hidden) and returns
 * how long the trip takes, in ms.
 */
export function glideTo(cursor: HTMLElement, target: Element) {
  const stage = cursor.offsetParent;
  if (!(stage instanceof HTMLElement)) return 0;
  // Screen px to the stage's own, should it sit in a scaled container.
  const box = stage.getBoundingClientRect();
  const scale = box.width / stage.offsetWidth || 1;
  const rect = target.getBoundingClientRect();
  const x = (rect.left + rect.width / 2 - box.left) / scale;
  const y = (rect.top + rect.height / 2 - box.top) / scale;
  if (cursor.dataset.state === 'hidden') {
    // It comes in from below and to the right, inside the stage.
    place(
      cursor,
      Math.min(x + 64, stage.offsetWidth - 24),
      Math.min(y + 72, stage.offsetHeight - 24),
      0,
    );
    cursor.getBoundingClientRect();
    cursor.dataset.state = 'shown';
  }
  const distance = Math.hypot(
    x - parseFloat(cursor.style.getPropertyValue('--x')),
    y - parseFloat(cursor.style.getPropertyValue('--y')),
  );
  // Already there: no trip. Otherwise, longer trips take a little longer.
  const ms = distance < 2 ? 0 : Math.round(Math.min(900, Math.max(380, distance * 2.2)));
  place(cursor, x, y, ms);
  return ms;
}

/** The arrow dips, and a ring spreads where it presses. */
export function pressWith(cursor: HTMLElement, down: boolean) {
  cursor.dataset.state = down ? 'pressed' : 'shown';
  if (!down) return;
  cursor.querySelector(`.${styles.ring}`)?.animate(
    [
      { transform: 'scale(0.2)', opacity: 0.55 },
      { transform: 'scale(1)', opacity: 0 },
    ],
    { duration: 520, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
  );
}

/** Fades the cursor away where it is. */
export function hideCursor(cursor: HTMLElement) {
  cursor.dataset.state = 'hidden';
}
```

### pager-pill/demo-cursor.module.css

```css
/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in the stage. The
   outer box carries the horizontal move and the inner one the vertical move, eased differently,
   which bends the path into a gentle arc. The arrow keeps the system pointer's look, dark edged
   in white, in both schemes; the ring where it presses turns light in the dark. */

.cursor {
  --ring: rgb(0 0 0 / 0.14);
  --x: 0px;
  --y: 0px;
  --move: 0ms;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  pointer-events: none;
  opacity: 0;
  transform: translateX(var(--x));
  transition:
    opacity 0.25s ease,
    transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);
}
@supports (color: light-dark(#fff, #000)) {
  .cursor {
    --ring: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.18));
  }
}
.cursor[data-state='shown'],
.cursor[data-state='pressed'] {
  opacity: 1;
}
.vertical {
  display: block;
  transform: translateY(var(--y));
  transition: transform var(--move) cubic-bezier(0.3, 0.55, 0.25, 1);
}
.arrow {
  display: block;
  width: 22px;
  height: 22px;
  /* The tip, at (4, 2.5) of the drawing, on the point. */
  margin: -2.75px 0 0 -4.4px;
  overflow: visible;
  transform-origin: 4.4px 2.75px;
  transition: transform 0.12s ease;
}
.body {
  fill: #1d1d1f;
  stroke: #fff;
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.shade {
  fill: rgb(0 0 0 / 0.2);
  stroke: rgb(0 0 0 / 0.1);
  stroke-width: 2.4;
  stroke-linejoin: round;
}
.cursor[data-state='pressed'] .arrow {
  transform: scale(0.86);
}
/* A soft ring where it presses. */
.ring {
  position: absolute;
  top: -15px;
  left: -15px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--ring);
  opacity: 0;
}
```

### pager-pill/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 };
}
```