# Streak badges

> Milestone badges for a daily streak that tilt towards the pointer, and a sheet that celebrates each new one before sending it to the shelf.

A shelf of seven badges for a daily streak, each a raised hexagon with a colour and an emblem of its own, drawn in SVG. Hover one and it tilts towards the pointer as a sheen slides across it. Check in on the day that reaches a milestone and a sheet rises: the badge pops in with a burst of sparkles, its number rolls up, and OK sends it flying to its place on the shelf.

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

Category: Feedback.

## Anatomy

1. **Streak**: The flame, lit once the streak starts, the count of days, and the days left to the next badge.
2. **Check in**: Adds a day. The day that reaches a milestone earns its badge and raises its sheet.
3. **Reset**: Takes the streak back to zero and locks every badge again.
4. **Shelf**: Every milestone in order of days: seven in one row on a wide stage, four a row on a narrow one.
5. **Earned badge**: An SVG medal in its own colour and emblem. It tilts towards the pointer in 3D while a sheen slides across it.
6. **Next-badge progress**: Under the next badge only: a bar filled from the last milestone to this one, and the days left.
7. **Locked badge**: Drawn in frosted greys, its number still readable, so the reader knows what is coming.
8. **Badge name**: One or two words under each badge, in a fainter grey while it is locked.

## When to use

- A daily habit worth rewarding: practice, a check-in, a lesson a day.
- A few milestones spaced out over weeks, each worth a small celebration when it lands.
- Showing at a glance what is earned, what is next and how many days are left.
- A profile or a home screen where the shelf can sit and the sheet can rise over it.

## When not to use

- Progress with no daily rhythm, or measured in units rather than days. Use a progress bar or a meter.
- Dozens of achievements in many categories. Use a grid or a list of achievements with filters.
- Work tools where a celebration would get in the way. A quiet count in the header is enough.
- A one-off success. Use a toast or a confirmation instead of a badge.

## Installation

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

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

// The badges, their emblems, colours and words are in milestones.ts.
export function HabitStreak({ days, save }: { days: number; save: (days: number) => void }) {
  return <StreakBadges defaultDays={days} onCheckIn={save} autoplay={false} />;
}
```

## States

- **No streak yet**: The flame is out and every badge is locked; the first is three days away.
- **Streak going**: Earned badges in colour, the next one with its bar and the days left.
- **Badge earned**: The sheet rises over a veil: the badge pops in with a burst, its number rolls up, then its words. Plays once, about 2 s.
- **After the sheet**: OK sends the badge flying to its slot, which bounces; the bar moves on to the next one.
- **Every badge earned**: The bar is gone and the goal line says so.
- **At night**: `colorScheme="dark"`: the badges keep their colours, the sheet’s header takes deep tones of its badge.

## How it works

- Every badge is one SVG built from the same layers: a darker rim and a lighter face (two rounded hexagons lit from the top), a raised emblem with a shadow under it and a light edge along its top, the number embossed the same way, and a soft shadow on the ground. Only the colour, worked out from a hue, and the emblem change from badge to badge; locked badges are drawn in greys under a frost of white specks.
- The badge under the pointer tilts towards it: each move sets a perspective rotation on that badge alone, and a band of light clipped to the hexagon slides across its face. When the pointer leaves, the badge springs back flat and loses its 3D transform, so only one badge is ever a 3D layer.
- The check-in that reaches a milestone raises a bottom sheet with a tinted header. The badge pops in on a spring with a little turn and sparkles fly out and fade; each digit then rolls up behind a window from 0 to its value, and the title and line come in after.
- OK closes the sheet, and the badge flies to its slot along an arc, turning and shrinking from the sheet’s box to the slot’s, and the slot takes it with a bounce. Both boxes are measured on screen and divided by the stage’s scale, so the flight lands in a scaled-down card too.
- Everything moves with transforms and opacity through the Web Animations API, and nothing runs at rest. The sheet’s moving parts sit under a plain rectangular clip and its veil fades its colour, because rounded clips and stage-sized fading layers cost a phone memory.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the badges are medal art and keep their colours. At night the sheet’s header takes deep tones of its badge’s hue, set inline next to the day tones.

## API reference

- **milestones** (`readonly Milestone[]`, default `MILESTONES`): The badges, in order of days: each with its days, name, emblem, hue and saturation, and the title and line of its sheet.
- **defaultDays** (`number`, default `5`): The streak at first, in days; the badges up to it start earned.
- **defaultOpen** (`boolean`, default `false`): Opens with the sheet of the badge defaultDays reaches raised (when it is a milestone’s days), as if just earned. Turns the demo off.
- **onCheckIn** (`(days: number) => void`): Called with the streak after each check-in.
- **onEarn** (`(milestone: Milestone) => void`): Called with the milestone a check-in reaches, as its sheet rises.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer checks in until a badge is earned, closes its sheet and tilts a badge or two.
- **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 shelf is a list of images named by their state: “7-day streak, earned”, “14-day streak, locked, 4 days to go”.
- Check in today and Reset are buttons. A check-in is announced (“Checked in: 6-day streak. 1 day to the next badge.”), and so is a new badge; the demo stays silent.
- The sheet is a modal dialog named by its title and described by its line: focus moves to OK as it opens, Escape or OK closes it, focus returns to Check in today, and the card behind it is inert.
- Under reduced motion nothing tilts, rolls, bursts or flies, the sheet appears at once, and the demo does not play.

## Source

### streak-badges/streak-badges.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent,
} from 'react';
import { Badge, paletteOf, rollUp } from './badge';
import { GhostPointer, runDemo } from './ghost-pointer';
import { MILESTONES, type Milestone } from './milestones';
import { easingOf, spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './streak-badges.module.css';

/*
 * Streak badges: a shelf of milestone badges for a daily streak, and the moment a new one is
 * earned.
 *
 * - The badges are SVG drawings (badge.tsx): earned ones in colour, locked ones in frosted greys
 *   with their number still readable. Under the next one, a bar and the days left.
 * - The badge under the pointer tilts towards it in 3D while a sheen slides across its face. Only
 *   that badge gets a 3D transform, and only while it moves.
 * - The check-in that reaches a milestone raises a sheet: the badge pops in on a spring with a
 *   burst of sparkles, its number rolls up, then its title and line come in. OK closes the sheet
 *   and the badge flies to its slot on the shelf.
 * - While on screen and untouched, a ghost pointer checks in until a badge is earned, closes its
 *   sheet and tilts a badge or two. Under reduced motion nothing tilts, rolls or bursts, and the
 *   sheet appears at once.
 * - Light or dark: the interface's colours are light-dark() tokens in the stylesheet, so it follows
 *   the page's color-scheme, or the one the colorScheme prop sets. The badges keep their colours;
 *   at night the sheet's header takes deep tones of its badge's colour (nightOf).
 */

/** The sheet rising. */
const RISE = spring(0.16, 440);
/** The badge landing in the sheet, and on the shelf: livelier. */
const POP = spring(0.36, 480);
/** The digits rolling up, with a little settle. */
const ROLL = spring(0.1, 760);

/**
 * Where the sparkles of the burst fly to, in em from the badge's middle, and their sizes: they
 * stay inside the sheet's header.
 */
const SPARKS = Array.from({ length: 14 }, (_, i) => {
  const angle = (i / 14) * 2 * Math.PI + (i % 2 ? 0.2 : -0.12);
  const distance = [6.8, 5.2, 7.8][i % 3];
  return {
    x: Math.round(Math.cos(angle) * distance * 100) / 100,
    y: Math.round(Math.sin(angle) * distance * 55) / 100,
    size: [1.25, 0.42, 0.85][i % 3],
    round: i % 3 === 1,
  };
});

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

/** The sheet's header at night: deep tones of its badge's colour, where the day has pale ones. */
function nightOf({ hue, saturation }: Milestone) {
  const tone = (share: number, lightness: number) =>
    `hsl(${hue} ${Math.round(saturation * share)}% ${lightness}%)`;
  return { tint: tone(0.6, 27), glow: tone(0.35, 17) };
}

type Box = { x: number; y: number; width: number; height: number };

/** An element's box in the stage's own px, should the stage sit in a scaled container. */
function boxIn(stage: HTMLElement, element: Element): Box {
  const frame = stage.getBoundingClientRect();
  const scale = frame.width / stage.offsetWidth || 1;
  const box = element.getBoundingClientRect();
  return {
    x: (box.left - frame.left) / scale,
    y: (box.top - frame.top) / scale,
    width: box.width / scale,
    height: box.height / scale,
  };
}

/** A badge tilted towards a point on it, from -1 to 1 across and down. */
const tiltOf = (x: number, y: number) =>
  `perspective(30em) rotateX(${(-y * 16).toFixed(2)}deg) rotateY(${(x * 20).toFixed(2)}deg) scale(1.06)`;

const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;

export function StreakBadges({
  milestones = MILESTONES,
  defaultDays = 5,
  defaultOpen = false,
  onCheckIn,
  onEarn,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The badges, in order of days: each with its emblem, its colour and the words of its sheet. */
  milestones?: readonly Milestone[];
  /** The streak at first, in days; the badges up to it start earned. */
  defaultDays?: number;
  /**
   * Opens with the sheet of the badge that defaultDays reaches raised (when defaultDays is a
   * milestone's), as if it had just been earned. Turns the demo off.
   */
  defaultOpen?: boolean;
  /** Called with the streak after each check-in. */
  onCheckIn?: (days: number) => void;
  /** Called with the milestone a check-in reaches, as its sheet rises. */
  onEarn?: (milestone: Milestone) => void;
  /** While on screen and untouched, a ghost pointer checks in, earns a badge and tilts a few. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [days, setDays] = useState(defaultDays);
  /** The milestones earned, by their days, the one in the sheet included. */
  const [earned, setEarned] = useState(() =>
    milestones.filter((milestone) => milestone.days <= defaultDays).map(({ days }) => days),
  );
  /** The sheet and its milestone, which it keeps while it slides away; each opening is a round. */
  const [sheet, setSheet] = useState<
    { milestone: Milestone; open: boolean; round: number } | undefined
  >(() => {
    const reached = defaultOpen
      ? milestones.find((milestone) => milestone.days === defaultDays)
      : undefined;
    return reached && { milestone: reached, open: true, round: 1 };
  });
  /** The badge flying from the sheet to its slot: the boxes it flies between. */
  const [flight, setFlight] = useState<{ milestone: Milestone; from: Box; to: Box }>();
  /** Focus is in the component: the demo leaves it alone. */
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const titleId = useId();
  const lineId = useId();

  const checkInButton = useRef<HTMLButtonElement>(null);
  const resetButton = useRef<HTMLButtonElement>(null);
  const sheetBox = useRef<HTMLDivElement>(null);
  const hero = useRef<HTMLDivElement>(null);
  const flash = useRef<HTMLSpanElement>(null);
  const burst = useRef<HTMLDivElement>(null);
  const okButton = useRef<HTMLButtonElement>(null);
  const flyer = useRef<HTMLDivElement>(null);
  const slots = useRef(new Map<number, HTMLLIElement>());
  const pointer = useRef<SVGSVGElement>(null);
  /** Where the focus goes once React has put a change on the page. */
  const focusNext = useRef<HTMLElement | null>(null);
  const stopDemo = useRef<(() => void) | null>(null);

  const open = sheet?.open ?? false;
  const next = milestones.find((milestone) => milestone.days > days);
  const previous = milestones.findLast((milestone) => milestone.days <= days)?.days ?? 0;
  const left = next ? next.days - days : 0;
  const goal = next ? `${plural(left, 'day')} to the ${next.days}-day badge` : 'Every badge earned';

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

  /** One more day. The day that reaches a milestone earns its badge and raises its sheet. */
  const checkIn = (byReader: boolean) => {
    if (open) return;
    const now = days + 1;
    setDays(now);
    onCheckIn?.(now);
    const reached = milestones.find(
      (milestone) => milestone.days === now && !earned.includes(milestone.days),
    );
    if (!reached) {
      if (byReader) {
        const after = milestones.find((milestone) => milestone.days > now);
        const togo = after ? ` ${plural(after.days - now, 'day')} to the next badge.` : '';
        announce(`Checked in: ${now}-day streak.${togo}`);
      }
      return;
    }
    setEarned([...earned, reached.days]);
    setSheet({ milestone: reached, open: true, round: (sheet?.round ?? 0) + 1 });
    onEarn?.(reached);
    if (byReader) {
      focusNext.current = okButton.current;
      announce(`Badge earned: ${reached.days}-day streak. ${reached.title}.`);
    }
  };

  /** Closes the sheet; its badge flies to its slot on the shelf. */
  const closeSheet = (byReader: boolean) => {
    if (!sheet?.open) return;
    const stage = root.current;
    const from = hero.current?.querySelector('svg');
    const to = slots.current.get(sheet.milestone.days)?.querySelector('[data-art]');
    if (stage && from && to && !reducedMotion()) {
      setFlight({ milestone: sheet.milestone, from: boxIn(stage, from), to: boxIn(stage, to) });
    }
    // The focus goes back to the button that raised the sheet, unless the reader is elsewhere.
    const focus = document.activeElement;
    if (sheetBox.current?.contains(focus) || (byReader && focus === document.body)) {
      focusNext.current = checkInButton.current;
    }
    setSheet({ ...sheet, open: false });
  };

  const reset = (byReader: boolean) => {
    setDays(0);
    setEarned([]);
    setSheet((now) => now && { ...now, open: false });
    setFlight(undefined);
    if (byReader) announce('Streak reset. Every badge is locked again.');
  };

  // The sheet's entrance: the badge pops in with a flash and a burst of sparkles, its number rolls
  // up, a glint crosses it, then the words come in. Under reduced motion it is all there at once.
  useLayoutEffect(() => {
    const badge = hero.current;
    const glow = flash.current;
    const sparks = burst.current;
    const sheetElement = sheetBox.current;
    const sheen = badge?.querySelector('[data-sheen]');
    if (!sheet?.open || !badge || !glow || !sparks || !sheetElement || reducedMotion()) return;
    const animations = [
      badge.animate(
        [
          { scale: 0.3, rotate: '-18deg' },
          { scale: 1, rotate: '0deg' },
        ],
        {
          delay: 120,
          duration: POP.duration,
          easing: easingOf(POP),
          fill: 'backwards',
        },
      ),
      badge.animate([{ opacity: 0 }, { opacity: 1 }], {
        delay: 120,
        duration: 160,
        fill: 'backwards',
      }),
      glow.animate(
        [
          { opacity: 0, scale: 0.3 },
          { opacity: 1, scale: 0.9, offset: 0.3 },
          { opacity: 0, scale: 1.25 },
        ],
        { delay: 160, duration: 900, easing: 'cubic-bezier(0.2, 0.6, 0.3, 1)' },
      ),
      ...rollUp(badge, { delay: 300, duration: ROLL.duration + 260, easing: easingOf(ROLL) }),
      ...Array.from(sparks.children, (spark, i) => {
        const { x, y } = SPARKS[i];
        return spark.animate(
          [
            { translate: '0 0', scale: 0, opacity: 1 },
            { translate: `${x}em ${y}em`, scale: 1, opacity: 1, offset: 0.55 },
            { translate: `${x * 1.18}em ${y * 1.18 + 0.6}em`, scale: 0.2, opacity: 0 },
          ],
          { delay: 200 + (i % 4) * 30, duration: 820, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)' },
        );
      }),
      ...(sheen
        ? [
            sheen.animate(
              [
                { opacity: 0, transform: 'translateX(-46px)' },
                { opacity: 1, offset: 0.35 },
                { opacity: 0, transform: 'translateX(46px)' },
              ],
              { delay: 1250, duration: 900, easing: 'ease-in-out' },
            ),
          ]
        : []),
      ...Array.from(sheetElement.querySelectorAll('[data-enter]'), (element, i) =>
        element.animate(
          [
            { opacity: 0, translate: '0 0.6em' },
            { opacity: 1, translate: '0 0' },
          ],
          {
            delay: 900 + i * 80,
            duration: 420,
            easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)',
            fill: 'backwards',
          },
        ),
      ),
    ];
    return () => animations.forEach((animation) => animation.cancel());
  }, [sheet?.open, sheet?.round]);

  // The flight: the badge leaves the sheet along an arc, turning a little, and shrinks into its
  // slot, which takes it with a bounce.
  useLayoutEffect(() => {
    const element = flyer.current;
    if (!flight || !element) return;
    const { from, to } = flight;
    const scale = to.width / from.width;
    const dx = to.x + to.width / 2 - (from.x + from.width / 2);
    const dy = to.y + to.height / 2 - (from.y + from.height / 2);
    // The control point of the arc sits off the straight line, to one side.
    const bend = { x: dx / 2 + dy * 0.22, y: dy / 2 - Math.abs(dx) * 0.12 };
    const keyframes = Array.from({ length: 17 }, (_, i) => {
      const t = i / 16;
      const x = 2 * (1 - t) * t * bend.x + t * t * dx;
      const y = 2 * (1 - t) * t * bend.y + t * t * dy;
      const size = 1 + (scale - 1) * t + 0.08 * Math.sin(Math.PI * t);
      return {
        transform: `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${(-12 * Math.sin(Math.PI * t)).toFixed(2)}deg) scale(${size.toFixed(3)})`,
      };
    });
    const animation = element.animate(keyframes, {
      duration: 680,
      easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
      fill: 'forwards',
    });
    const slot = slots.current.get(flight.milestone.days)?.querySelector('[data-tilt]');
    animation.finished.then(
      () => {
        setFlight(undefined);
        slot?.animate([{ scale: 1.16 }, { scale: 1 }], {
          duration: POP.duration,
          easing: easingOf(POP),
        });
      },
      () => {},
    );
    return () => animation.cancel();
  }, [flight]);

  // The focus follows the sheet, once it is on the page.
  useLayoutEffect(() => {
    focusNext.current?.focus();
    focusNext.current = null;
  });

  /** Tilts the badge of `slot` towards the point (x, y) on screen. */
  const tiltTowards = (slot: HTMLElement, x: number, y: number) => {
    const art = slot.querySelector<HTMLElement>('[data-art]');
    const tilt = slot.querySelector<HTMLElement>('[data-tilt]');
    const sheen = slot.querySelector<SVGElement>('[data-sheen]');
    if (!art || !tilt || reducedMotion()) return;
    // The badge's box at rest: the tilt does not change it.
    const box = art.getBoundingClientRect();
    const across = Math.max(-1, Math.min(1, ((x - box.left) / box.width) * 2 - 1));
    const down = Math.max(-1, Math.min(1, ((y - box.top) / box.height) * 2 - 1));
    slot.dataset.tilted = '';
    tilt.style.transform = tiltOf(across, down);
    sheen?.style.setProperty('transform', `translateX(${(across * 34).toFixed(1)}px)`);
  };

  const untilt = (slot: HTMLElement) => {
    delete slot.dataset.tilted;
    slot.querySelector<HTMLElement>('[data-tilt]')?.style.removeProperty('transform');
    slot.querySelector<SVGElement>('[data-sheen]')?.style.removeProperty('transform');
  };

  /** The demo's hover: the badge tilts from its top left to its bottom right, as a pointer sweeps. */
  const sweep = (days: number) => {
    const slot = slots.current.get(days);
    const tilt = slot?.querySelector<HTMLElement>('[data-tilt]');
    const sheen = slot?.querySelector<SVGElement>('[data-sheen]');
    if (!tilt || !sheen || reducedMotion()) return;
    const timing = { duration: 1800, easing: 'ease-in-out' };
    tilt.animate(
      [
        { transform: 'none' },
        { transform: tiltOf(-0.7, -0.45), offset: 0.18 },
        { transform: tiltOf(0.75, 0.4), offset: 0.68 },
        { transform: 'none' },
      ],
      timing,
    );
    sheen.animate(
      [
        { opacity: 0, transform: 'translateX(-24px)' },
        { opacity: 1, transform: 'translateX(-24px)', offset: 0.18 },
        { opacity: 1, transform: 'translateX(26px)', offset: 0.68 },
        { opacity: 0, transform: 'translateX(26px)' },
      ],
      timing,
    );
  };

  // A mouse or a pen tilts a badge as it moves over it; a finger, while it presses it.
  const onSlotPointer = (event: PointerEvent<HTMLLIElement>) => {
    const slot = event.currentTarget;
    const touch = event.pointerType === 'touch';
    if (event.type === 'pointerleave' || event.type === 'pointercancel') untilt(slot);
    else if (event.type === 'pointerup') {
      if (touch) untilt(slot);
    } else if (!touch || event.type === 'pointerdown') {
      tiltTowards(slot, event.clientX, event.clientY);
    }
  };

  // The demo calls the handlers of the latest render.
  const actions = useRef({ checkIn, closeSheet, reset, sweep, days, open, next, earned });
  useLayoutEffect(() => {
    actions.current = { checkIn, closeSheet, reset, sweep, days, open, next, earned };
  });

  // The demo: a ghost pointer checks in, a day per press, until a badge is earned; it waits for
  // the sheet, presses OK, and tilts the new badge and the one before it. Far from the next
  // badge, it starts over.
  const demo = autoplay && !defaultOpen && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !stage || !ghost) return;
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      const act = () => actions.current;
      /** Hovers a badge from its top left to its bottom right. */
      const hover = async (days: number) => {
        const art = slots.current.get(days)?.querySelector('[data-art]');
        if (!art) return;
        await moveTo(art, { x: 0.2, y: 0.28 });
        act().sweep(days);
        await wait(200);
        await moveTo(art, { x: 0.5, y: 0.5 });
        await wait(150);
        await moveTo(art, { x: 0.82, y: 0.66 });
        await wait(700);
      };
      await wait(700);
      for (;;) {
        if (act().open) {
          await moveTo(okButton.current);
          await wait(250);
          await press(() => act().closeSheet(false));
          await wait(1200);
        }
        const target = act().next;
        if (!target || target.days - act().days > 8) {
          await moveTo(resetButton.current);
          await wait(250);
          await press(() => act().reset(false));
          await wait(900);
        }
        await moveTo(checkInButton.current);
        await wait(250);
        while (!act().open) {
          await press(() => act().checkIn(false));
          await wait(480);
        }
        await wait(2500);
        await moveTo(okButton.current);
        await wait(350);
        const won = act().earned;
        await press(() => act().closeSheet(false));
        await wait(1300);
        await hover(won[won.length - 1]);
        if (won.length > 1) await hover(won[won.length - 2]);
        await wait(900);
      }
    });
    stopDemo.current = stop;
    return stop;
  }, [demo, root]);

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

  const shown = sheet?.milestone;
  const tones = shown ? paletteOf(shown.hue, shown.saturation) : null;
  const night = shown ? nightOf(shown) : null;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={
        {
          '--spring': POP.easing,
          '--rise': RISE.easing,
          '--rise-ms': `${RISE.duration}ms`,
        } as CSSProperties
      }
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onPointerMove={(event) => {
        // A mouse over the stage pauses the demo, so nothing moves away from under it.
        if (event.pointerType === 'mouse') readerActs();
      }}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div className={styles.scene}>
        <div className={styles.card} inert={open} data-part="card">
          <div className={styles.head}>
            <div className={styles.streak} data-part="streak">
              <FlameIcon lit={days > 0} />
              <div>
                <p className={styles.count}>{days > 0 ? `${days}-day streak` : 'No streak yet'}</p>
                <p className={styles.goal}>{goal}</p>
              </div>
            </div>
            <div className={styles.actions}>
              <button
                ref={resetButton}
                type="button"
                className={styles.reset}
                data-part="reset"
                aria-label="Reset the streak"
                onClick={() => reset(true)}
              >
                <svg className={styles.icon} viewBox="0 0 20 20" aria-hidden="true">
                  <path d="M4.5 9.5a5.6 5.6 0 1 1 1.7 4.4M4.2 5v4.6h4.6" />
                </svg>
                Reset
              </button>
              <button
                ref={checkInButton}
                type="button"
                className={styles.checkIn}
                data-part="check-in"
                onClick={() => checkIn(true)}
              >
                <svg className={styles.icon} viewBox="0 0 20 20" aria-hidden="true">
                  <path d="m5.5 10.4 3 3 6-6.6" />
                </svg>
                Check in today
              </button>
            </div>
          </div>

          <ol className={styles.shelf} aria-label="Streak badges" data-part="shelf">
            {milestones.map((milestone) => {
              const isEarned =
                earned.includes(milestone.days) && !(open && shown?.days === milestone.days);
              const isNext = milestone === next;
              const share = isNext ? (days - previous) / (milestone.days - previous) : 0;
              const state = isEarned ? 'earned' : 'locked';
              return (
                <li
                  key={milestone.days}
                  ref={(element) => {
                    if (!element) return;
                    slots.current.set(milestone.days, element);
                    return () => {
                      slots.current.delete(milestone.days);
                    };
                  }}
                  className={styles.slot}
                  data-part="slot"
                  data-state={state}
                  data-arriving={flight?.milestone.days === milestone.days || undefined}
                  style={
                    {
                      '--tone': paletteOf(milestone.hue, milestone.saturation).rim[1],
                    } as CSSProperties
                  }
                  onPointerMove={onSlotPointer}
                  onPointerDown={onSlotPointer}
                  onPointerUp={onSlotPointer}
                  onPointerLeave={onSlotPointer}
                  onPointerCancel={onSlotPointer}
                >
                  <div className={styles.art} data-art="">
                    <div className={styles.tilt} data-tilt="">
                      <Badge
                        className={styles.badge}
                        milestone={milestone}
                        locked={!isEarned}
                        label={`${milestone.days}-day streak, ${state}${isNext ? `, ${plural(left, 'day')} to go` : ''}`}
                      />
                    </div>
                  </div>
                  <span className={styles.name} aria-hidden="true" data-part="name">
                    {milestone.name}
                  </span>
                  {isNext && (
                    <span className={styles.progress} aria-hidden="true" data-part="progress">
                      <span className={styles.bar}>
                        <span className={styles.fill} style={{ transform: `scaleX(${share})` }} />
                      </span>
                      {plural(left, 'more day')}
                    </span>
                  )}
                </li>
              );
            })}
          </ol>
        </div>
      </div>

      <div className={styles.overlay}>
        <div
          className={styles.scrim}
          data-open={open || undefined}
          aria-hidden="true"
          onClick={() => closeSheet(true)}
        />
        <div
          ref={sheetBox}
          className={styles.sheet}
          data-part="sheet"
          data-open={open || undefined}
          data-flown={flight ? '' : undefined}
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          aria-describedby={lineId}
          inert={!open}
          style={
            tones && night
              ? ({
                  '--tint': tones.rim[0],
                  '--glow': tones.face[0],
                  '--deep': tones.ink[1],
                  '--spark': tones.rim[1],
                  '--tint-night': night.tint,
                  '--glow-night': night.glow,
                } as CSSProperties)
              : undefined
          }
          onKeyDown={(event) => {
            if (event.key === 'Escape') closeSheet(true);
            // A modal sheet keeps the focus: OK is its one control.
            if (event.key === 'Tab') {
              event.preventDefault();
              okButton.current?.focus();
            }
          }}
        >
          <div className={styles.sheetHead} data-part="sheet-head">
            <span className={styles.grabber} aria-hidden="true" />
            <span ref={flash} className={styles.flash} aria-hidden="true" />
            <div ref={burst} className={styles.burst} aria-hidden="true">
              {SPARKS.map((spark, i) => (
                <span
                  key={i}
                  className={styles.spark}
                  data-round={spark.round || undefined}
                  style={{ '--size': `${spark.size}em` } as CSSProperties}
                >
                  {!spark.round && (
                    <svg viewBox="0 0 20 20">
                      <path d="M10 0c.9 6 3.1 8.2 10 10-6.9 1.8-9.1 4-10 10-.9-6-3.1-8.2-10-10C6.9 8.2 9.1 6 10 0Z" />
                    </svg>
                  )}
                </span>
              ))}
            </div>
            <div ref={hero} className={styles.hero}>
              {shown && (
                <Badge
                  key={sheet.round}
                  className={styles.badge}
                  milestone={shown}
                  rolling
                  label={`${shown.days}-day streak, earned`}
                />
              )}
            </div>
          </div>
          <div className={styles.sheetBody}>
            <h3 id={titleId} className={styles.title} data-enter="">
              {shown?.title}
            </h3>
            <p id={lineId} className={styles.line} data-enter="">
              {shown?.line}
            </p>
            <button
              ref={okButton}
              type="button"
              className={styles.ok}
              data-part="ok"
              data-enter=""
              onClick={() => closeSheet(true)}
            >
              OK
            </button>
          </div>
        </div>
      </div>

      <div className={styles.flights} aria-hidden="true">
        {flight && (
          <div
            ref={flyer}
            className={styles.flyer}
            style={{
              left: flight.from.x,
              top: flight.from.y,
              width: flight.from.width,
              height: flight.from.height,
            }}
          >
            <Badge className={styles.badge} milestone={flight.milestone} />
          </div>
        )}
      </div>

      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** The streak's flame, lit once the streak has started. */
function FlameIcon({ lit }: { lit: boolean }) {
  const id = useId();
  return (
    <svg
      className={styles.flame}
      data-part="flame"
      data-lit={lit || undefined}
      viewBox="0 0 40 40"
      aria-hidden="true"
    >
      <defs>
        <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" style={{ stopColor: 'var(--flame-top)' }} />
          <stop offset="1" style={{ stopColor: 'var(--flame-bottom)' }} />
        </linearGradient>
      </defs>
      <rect width="40" height="40" rx="12" style={{ fill: 'var(--flame-back)' }} />
      <path
        d="M20.5 7.5c1.2 3.6 4.8 5.6 6.4 9 1.6 3.4 1.2 7.6-1.2 10.2-1.6 1.8-3.6 2.8-5.9 2.8s-4.6-1-6-2.9c-1.9-2.5-2.1-6.1-.6-9 .6 1.6 1.6 2.7 3 3.1-.7-4.6 1.5-9.6 4.3-13.2Z"
        fill={`url(#${id})`}
      />
      <path
        d="M20.3 19.6c1 2 3.2 3.2 3.2 5.6 0 2-1.5 3.5-3.4 3.5s-3.4-1.4-3.4-3.3c0-2.4 2.4-3.6 3.6-5.8Z"
        style={{ fill: 'var(--flame-core)' }}
        fillOpacity="0.55"
      />
    </svg>
  );
}
```

### streak-badges/streak-badges.module.css

```css
/* Streak badges (streak-badges.tsx, badge.tsx). Sizes are in em: the stage sets the font-size
   from its own width (a container query), so the card grows with the room it has. --spring and
   --rise, the easings of the springy transitions, come from the component.

   Colours are the tokens below, each 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). The badges are medal art (badge.tsx): their colours
   are the same in both. The sheet's colours that follow its badge (--tint, --glow, --deep,
   --spark and their night tones) come from the component, set on the sheet. */

.root {
  /* The stage and its dots; the card on it; the sheet, a step lighter again at night. */
  --surface: #e8e8eb;
  --dots: rgb(0 0 0 / 0.05);
  --surface-2: #fff;
  --sheet-bg: #fff;
  /* The words: titles and figures; the lines under them; a locked badge's name (the same on
     both). */
  --ink: #1c1c1e;
  --ink-muted: #6e6e73;
  --ink-faint: #8e8e93;
  /* Hairlines: the stage's edge, the card's, the sheet's; the rule over the shelf; the track of
     the next badge's bar. */
  --line: rgb(0 0 0 / 0.06);
  --edge: rgb(0 0 0 / 0.06);
  --sheet-edge: rgb(0 0 0 / 0.08);
  --rule: #ececf0;
  --track: #ececf1;
  /* The card's lift (its contact shadow, then its drop) and the sheet's. */
  --shadow-near: rgb(0 0 0 / 0.04);
  --shadow: rgb(0 0 0 / 0.2);
  --sheet-shadow: rgb(0 0 0 / 0.35);
  /* Check in: an ink pill with white words by day, the other way round at night. Reset: a soft
     pill. */
  --accent: #1c1c1e;
  --accent-ink: #fff;
  --accent-hover: #3a3a3c;
  --button: #f2f2f5;
  --button-hover: #e9e9ee;
  /* The veil over the stage while the sheet is up (from clear), and the sheet's grabber. */
  --scrim-off: rgb(26 26 34 / 0);
  --scrim: rgb(26 26 34 / 0.3);
  --grabber: rgb(0 0 0 / 0.14);
  /* The sheet's header behind its badge: a light in the middle, rays, and a flash as it lands,
     softer at night. */
  --burst: rgb(255 255 255 / 0.95);
  --rays: rgb(255 255 255 / 0.32);
  --flash: rgb(255 255 255 / 0.95);
  --flash-mid: rgb(255 255 255 / 0.5);
  /* The focus rings: the system's blue, legible on both. */
  --focus: #0a84ff;
  /* The demo's pointer (ghost-pointer.tsx): the system's, dark on a white edge, on both. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  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);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#e8e8eb, #0f0f11);
    --dots: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --surface-2: light-dark(#fff, #1c1c1e);
    --sheet-bg: light-dark(#fff, #242427);
    --ink: light-dark(#1c1c1e, #f5f5f7);
    --ink-muted: light-dark(#6e6e73, #a1a1a6);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --sheet-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --rule: light-dark(#ececf0, #2c2c2f);
    --track: light-dark(#ececf1, #333337);
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.55));
    --sheet-shadow: light-dark(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.6));
    --accent: light-dark(#1c1c1e, #f5f5f7);
    --accent-ink: light-dark(#fff, #1c1c1e);
    --accent-hover: light-dark(#3a3a3c, #d1d1d6);
    --button: light-dark(#f2f2f5, #2c2c2e);
    --button-hover: light-dark(#e9e9ee, #3a3a3c);
    --scrim-off: light-dark(rgb(26 26 34 / 0), rgb(0 0 0 / 0));
    --scrim: light-dark(rgb(26 26 34 / 0.3), rgb(0 0 0 / 0.5));
    --grabber: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.2));
    --burst: light-dark(rgb(255 255 255 / 0.95), rgb(255 255 255 / 0.16));
    --rays: light-dark(rgb(255 255 255 / 0.32), rgb(255 255 255 / 0.05));
    --flash: light-dark(rgb(255 255 255 / 0.95), rgb(255 255 255 / 0.5));
    --flash-mid: light-dark(rgb(255 255 255 / 0.5), rgb(255 255 255 / 0.2));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
/* The buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}
:where(.root) p,
:where(.root) h3 {
  margin: 0;
}

.scene {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: max(400px, 54cqi);
  padding: 2.25em 12px;
  font-size: clamp(13px, 9px + 0.9cqi, 16px);
}

/* The card: the streak and its buttons over the shelf of badges. */
.card {
  box-sizing: border-box;
  width: min(100%, 46em);
  padding: 1.4em 1.5em 1.5em;
  border-radius: 1.75em;
  background: var(--surface-2);
  box-shadow:
    0 0 0 0.5px var(--edge),
    0 1px 2px var(--shadow-near),
    0 14px 34px -16px var(--shadow);
}
.head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.9em 1em;
}
.streak {
  display: flex;
  align-items: center;
  gap: 0.75em;
}
/* The flame (FlameIcon): grey on a pale tile until the streak starts, then orange on a warm one.
   At night the tiles are deep, the lit flame the same. */
.flame {
  --flame-top: #d4d4da;
  --flame-bottom: #b4b4bc;
  --flame-back: #f2f2f5;
  --flame-core: #fff;
  flex: none;
  width: 2.6em;
  height: 2.6em;
}
@supports (color: light-dark(#fff, #000)) {
  /* The flame (FlameIcon): grey on a pale tile until the streak starts, then orange on a warm one.
   At night the tiles are deep, the lit flame the same. */
  .flame {
    --flame-top: light-dark(#d4d4da, #5c5c63);
    --flame-bottom: light-dark(#b4b4bc, #46464c);
    --flame-back: light-dark(#f2f2f5, #2a2a2d);
  }
}
.flame[data-lit] {
  --flame-top: #ffbe4d;
  --flame-bottom: #ff6a3d;
  --flame-back: #fff0e3;
}
@supports (color: light-dark(#fff, #000)) {
  .flame[data-lit] {
    --flame-back: light-dark(#fff0e3, #3d2416);
  }
}
.count {
  font-size: 1.25em;
  font-weight: 700;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}
.goal {
  margin-top: 0.15em;
  font-size: 0.85em;
  color: var(--ink-muted);
}

.actions {
  display: flex;
  gap: 0.5em;
}
.reset,
.checkIn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4em;
  height: 2.6em;
  border: 0;
  border-radius: 999px;
  font-size: 0.9em;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    scale 0.3s var(--spring);
}
.reset {
  padding: 0 1em 0 0.85em;
  background: var(--button);
  color: var(--ink-muted);
}
.checkIn {
  padding: 0 1.15em 0 0.95em;
  background: var(--accent);
  color: var(--accent-ink);
}
@media (hover: hover) {
  .reset:hover {
    background: var(--button-hover);
  }
  .checkIn:hover {
    background: var(--accent-hover);
  }
}
.reset:active,
.checkIn:active,
.reset[data-pressed],
.checkIn[data-pressed] {
  scale: 0.95;
}
.reset:focus-visible,
.checkIn:focus-visible,
.ok:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.icon {
  width: 1.2em;
  height: 1.2em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The shelf: four badges a row on a narrow stage, all seven in one on a wide one. */
.shelf {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 1.1em 0.6em;
  margin: 1.5em 0 0;
  padding: 1.3em 0 0;
  border-top: 1px solid var(--rule);
  list-style: none;
}
.slot {
  display: flex;
  flex: 0 0 calc((100% - 1.8em) / 4);
  flex-direction: column;
  align-items: center;
  min-width: 0;
}
@container (min-width: 600px) {
  .shelf {
    flex-wrap: nowrap;
    gap: 0.75em;
  }
  .slot {
    flex: 1 1 0;
  }
}
.art {
  width: 100%;
  max-width: 6.4em;
}
.badge {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 120 / 132;
  overflow: visible;
}
/* The sheen shows only while a badge tilts, or glints. */
.badge [data-sheen] {
  opacity: 0;
}
/* The badge under the pointer follows it closely; let go, it springs back flat. */
.tilt {
  transition: transform 0.7s var(--spring);
}
.slot[data-tilted] .tilt {
  transition: transform 0.14s ease-out;
}
.slot[data-arriving] .tilt {
  visibility: hidden;
}
.slot [data-sheen] {
  opacity: 0;
  transition:
    opacity 0.45s ease,
    transform 0.14s ease-out;
}
.slot[data-tilted] [data-sheen] {
  opacity: 1;
}
.name {
  margin-top: 0.35em;
  max-width: 100%;
  overflow: hidden;
  font-size: 0.78em;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.slot[data-state='locked'] .name {
  color: var(--ink-faint);
}
.progress {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3em;
  margin-top: 0.35em;
  font-size: 0.72em;
  font-weight: 600;
  white-space: nowrap;
  color: var(--ink-muted);
}
.bar {
  width: 4.6em;
  height: 0.42em;
  overflow: hidden;
  border-radius: 999px;
  background: var(--track);
}
.fill {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: var(--tone);
  transform-origin: left;
  transition: transform 0.6s var(--spring);
}

/* The sheet and the veil behind it, over the stage. The sheet rises from below the stage's edge,
   which this box cuts off. Its clip is a plain rectangle, and nothing else clips the moving parts:
   a phone builds a mask for each layer under a rounded clip, and the entrance has many. */
.overlay {
  position: absolute;
  z-index: 2;
  inset: 0;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
}
/* The veil fades its colour, not its opacity: a stage-sized layer would cost memory too. */
.scrim {
  position: absolute;
  inset: 0;
  border-radius: 18px;
  background: var(--scrim-off);
  pointer-events: auto;
  visibility: hidden;
  transition:
    background-color 0.3s ease,
    visibility 0s linear 0.3s;
}
.scrim[data-open] {
  background: var(--scrim);
  visibility: visible;
  transition-delay: 0s;
}
/* At night the header takes deep tones of the badge's colour, and OK its pale tint with deep words
   (--tint, --glow, --deep and their night tones are set on the sheet by the component). */
.sheet {
  --head-top: var(--tint);
  --head-glow: var(--glow);
  --ok: var(--deep, var(--accent));
  --ok-ink: #fff;
  --dot: var(--deep);
  position: absolute;
  right: 0;
  bottom: 10px;
  left: 0;
  box-sizing: border-box;
  width: min(calc(100% - 20px), 24em);
  margin: 0 auto;
  border-radius: 1.75em;
  background: var(--sheet-bg);
  box-shadow:
    0 0 0 0.5px var(--sheet-edge),
    0 12px 28px -12px var(--sheet-shadow);
  font-size: clamp(13px, 9px + 0.9cqi, 16px);
  pointer-events: auto;
  visibility: hidden;
  transform: translateY(calc(100% + 16px));
  transition:
    transform 0.34s cubic-bezier(0.5, 0, 0.75, 0.3),
    visibility 0s linear 0.34s;
}
@supports (color: light-dark(#fff, #000)) {
  /* At night the header takes deep tones of the badge's colour, and OK its pale tint with deep words
   (--tint, --glow, --deep and their night tones are set on the sheet by the component). */
  .sheet {
    --head-top: light-dark(var(--tint), var(--tint-night));
    --head-glow: light-dark(var(--glow), var(--glow-night));
    --ok: light-dark(var(--deep, var(--accent)), var(--tint, var(--accent)));
    --ok-ink: light-dark(#fff, var(--deep, var(--accent-ink)));
    --dot: light-dark(var(--deep), var(--tint));
  }
}
.sheet[data-open] {
  visibility: visible;
  transform: none;
  transition:
    transform var(--rise-ms) var(--rise),
    visibility 0s;
}
.sheetHead {
  position: relative;
  display: grid;
  place-items: center;
  height: 11.8em;
  border-top-left-radius: inherit;
  border-top-right-radius: inherit;
  background:
    radial-gradient(circle at 50% 55%, var(--burst) 0, rgb(255 255 255 / 0) 34%),
    repeating-conic-gradient(
      from 0deg at 50% 55%,
      var(--rays) 0deg 7deg,
      rgb(255 255 255 / 0) 7deg 20deg
    ),
    linear-gradient(var(--head-top), var(--head-glow) 80%, var(--sheet-bg));
}
.grabber {
  position: absolute;
  top: 0.55em;
  left: 50%;
  width: 2.4em;
  height: 0.3em;
  margin-left: -1.2em;
  border-radius: 999px;
  background: var(--grabber);
}
.hero {
  position: relative;
  width: 8.2em;
  margin-top: 1em;
}
/* A soft flash of light behind the badge as it lands: a gradient, not a blur, small enough to
   stay inside the sheet. */
.flash {
  position: absolute;
  top: 55%;
  left: 50%;
  width: 12em;
  height: 12em;
  margin: -6em 0 0 -6em;
  border-radius: 50%;
  background: radial-gradient(
    closest-side,
    var(--flash),
    var(--flash-mid) 45%,
    rgb(255 255 255 / 0)
  );
  opacity: 0;
}
.sheet[data-flown] .hero {
  visibility: hidden;
}
.burst {
  position: absolute;
  top: 55%;
  left: 50%;
  width: 0;
  height: 0;
}
.spark {
  position: absolute;
  top: calc(var(--size) / -2);
  left: calc(var(--size) / -2);
  width: var(--size);
  height: var(--size);
  opacity: 0;
}
.spark svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  fill: var(--spark);
}
.spark:nth-child(4n + 1) svg {
  fill: #fff;
  stroke: var(--spark);
  stroke-width: 1.4;
  stroke-linejoin: round;
}
.spark[data-round] {
  border-radius: 50%;
  background: var(--dot);
  opacity: 0;
}
.sheetBody {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.45em;
  padding: 0.2em 1.6em 1.5em;
  text-align: center;
}
.title {
  font-size: 1.3em;
  font-weight: 700;
  letter-spacing: -0.02em;
}
.line {
  max-width: 19em;
  font-size: 0.92em;
  line-height: 1.4;
  color: var(--ink-muted);
}
.ok {
  width: 100%;
  height: 2.9em;
  margin-top: 0.9em;
  border: 0;
  border-radius: 999px;
  background: var(--ok);
  color: var(--ok-ink);
  font-weight: 600;
  cursor: pointer;
  transition:
    filter 0.2s ease,
    scale 0.3s var(--spring);
}
@media (hover: hover) {
  .ok:hover {
    filter: brightness(1.12);
  }
}
.ok:active,
.ok[data-pressed] {
  scale: 0.97;
}

/* The badge on its way from the sheet to the shelf: a fixed-size layer, moved by a transform. */
.flights {
  position: absolute;
  z-index: 4;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
}
.flyer {
  position: absolute;
}

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

@container (max-width: 420px) {
  .head {
    justify-content: center;
  }
  .streak {
    width: 100%;
  }
  .actions {
    width: 100%;
  }
  .checkIn {
    flex: 1;
  }
}

/* Final states at once. */
@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    transition: none !important;
  }
}
```

### streak-badges/badge.tsx

```tsx
import { useId, type ReactNode } from 'react';
import type { Emblem, Milestone } from './milestones';

/*
 * A streak badge, drawn in SVG so it stays crisp at any size. Every badge is made of the same
 * layers, from the back:
 *
 * - a soft shadow on the ground, and the edge: a darker copy of the hexagon just below it;
 * - the rim, a rounded hexagon, and the face, a smaller and lighter one, both lit from the top;
 * - the emblem, raised: a darker copy below it, its fill, a light line along its top;
 * - the number of days, embossed the same way, and "days" under it;
 * - the sheen, a band of light, hidden until the badge tilts (the stylesheet shows it).
 *
 * Only the colours and the emblem change from one badge to the next. A locked badge is drawn in
 * greys under a frost of white specks, its number still readable.
 *
 * The number is set digit by digit, each in a slot of the same width, so that a digit can roll:
 * a `rolling` badge draws each digit as a strip of digits behind a window, and `rollUp` slides
 * the strips up.
 */

export type Palette = {
  rim: readonly [string, string];
  edge: string;
  face: readonly [string, string];
  emblem: readonly [string, string];
  emblemEdge: string;
  ink: readonly [string, string];
};

/** A badge's tones from its hue and saturation: a pastel rim, face and emblem, and a deep ink. */
export function paletteOf(hue: number, saturation: number): Palette {
  const tone = (share: number, lightness: number) =>
    `hsl(${hue} ${Math.round(saturation * share)}% ${lightness}%)`;
  return {
    rim: [tone(0.9, 80), tone(0.82, 67)],
    edge: tone(0.6, 56),
    face: [tone(1, 97), tone(0.95, 90)],
    emblem: [tone(1, 89), tone(0.92, 75)],
    emblemEdge: tone(0.7, 63),
    ink: [tone(0.7, 42), tone(0.75, 27)],
  };
}

/** The greys of a badge not earned yet. */
const LOCKED: Palette = {
  rim: ['#dfe0e5', '#cbccd3'],
  edge: '#b7b9c1',
  face: ['#f6f6f8', '#e9eaee'],
  emblem: ['#f1f2f4', '#e0e1e6'],
  emblemEdge: '#cdced5',
  ink: ['#a6a8b1', '#8d8f99'],
};

const FONT = "ui-rounded, 'SF Pro Rounded', system-ui, -apple-system, 'Segoe UI', sans-serif";

type Point = readonly [number, number];

/** Two decimals are plenty at any size. */
const fixed = (n: number) => Math.round(n * 100) / 100;

/** The corners of a polygon around (cx, cy), the first at the top; a star alternates two radii. */
function polygon(cx: number, cy: number, radii: readonly number[], count: number): Point[] {
  return Array.from({ length: count }, (_, i) => {
    const angle = ((i / count) * 2 - 0.5) * Math.PI;
    const r = radii[i % radii.length];
    return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
  });
}

/** A closed path through `points`, each corner cut `radius` along both edges and curved. */
function rounded(points: readonly Point[], radius: number | readonly number[]) {
  const n = points.length;
  const corners = points.map(([x, y], i) => {
    const r = typeof radius === 'number' ? radius : radius[i % radius.length];
    const [px, py] = points[(i + n - 1) % n];
    const [nx, ny] = points[(i + 1) % n];
    const back = Math.hypot(px - x, py - y);
    const ahead = Math.hypot(nx - x, ny - y);
    const cut = Math.min(r, back / 2, ahead / 2);
    const from = `${fixed(x + ((px - x) * cut) / back)} ${fixed(y + ((py - y) * cut) / back)}`;
    const to = `${fixed(x + ((nx - x) * cut) / ahead)} ${fixed(y + ((ny - y) * cut) / ahead)}`;
    return `${from}Q${fixed(x)} ${fixed(y)} ${to}`;
  });
  return `M${corners.join('L')}Z`;
}

/** A circle as a path, to join other shapes in one. */
const circle = (cx: number, cy: number, r: number) =>
  `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0Z`;

/** A disc with a scalloped edge: `count` bumps standing `depth` out of radius `r`. */
function rosette(cx: number, cy: number, r: number, count: number, depth: number) {
  const step = (2 * Math.PI) / count;
  const at = (angle: number, radius: number) =>
    `${fixed(cx + radius * Math.cos(angle))} ${fixed(cy + radius * Math.sin(angle))}`;
  // Each bump is a curve between two dips, its control point placed so the curve peaks at r + depth.
  const control = 2 * (r + depth) - (r - depth) * Math.cos(step / 2);
  let d = `M${at(-Math.PI / 2, r - depth)}`;
  for (let i = 0; i < count; i += 1) {
    const angle = -Math.PI / 2 + i * step;
    d += `Q${at(angle + step / 2, control)} ${at(angle + step, r - depth)}`;
  }
  return `${d}Z`;
}

/** A ribbon's tail hanging from under the rosette, its end cut in a V; `side` -1 left, 1 right. */
function tail(side: -1 | 1) {
  const top: Point = [60 + side * 8, 72];
  const angle = ((90 - side * 16) * Math.PI) / 180;
  const along: Point = [Math.cos(angle), Math.sin(angle)];
  const across: Point = [-along[1], along[0]];
  const end: Point = [top[0] + along[0] * 22, top[1] + along[1] * 22];
  const half = 6;
  const points: Point[] = [
    [top[0] - across[0] * half, top[1] - across[1] * half],
    [top[0] + across[0] * half, top[1] + across[1] * half],
    [end[0] + across[0] * half, end[1] + across[1] * half],
    [end[0] - along[0] * 4.5, end[1] - along[1] * 4.5],
    [end[0] - across[0] * half, end[1] - across[1] * half],
  ];
  return rounded(points, [1, 1, 1.2, 0.8, 1.2]);
}

/** A petal: an ellipse `length` long, pointing out from (cx, cy) at `angle` degrees. */
function petal(cx: number, cy: number, angle: number, distance: number) {
  const length = 15;
  const width = 11;
  const radians = (angle * Math.PI) / 180;
  const x = cx + distance * Math.cos(radians);
  const y = cy + distance * Math.sin(radians);
  const dx = length * Math.cos(radians);
  const dy = length * Math.sin(radians);
  const arc = `A${length} ${width} ${angle} 1 1`;
  return `M${fixed(x - dx)} ${fixed(y - dy)}${arc} ${fixed(x + dx)} ${fixed(y + dy)}${arc} ${fixed(x - dx)} ${fixed(y - dy)}Z`;
}

/** The badge's frame: a rounded hexagon, point up, and the face inside it. */
const RIM = rounded(polygon(60, 62, [56], 6), 12);
const FACE = rounded(polygon(60, 62, [46], 6), 9);

/** Where the number sits: its baseline, and that of "days" under it. */
const BASELINE = 70;
const DAYS_BASELINE = 82.5;

type EmblemArt = {
  /** The raised shapes, back to front; each gets its own drop and light. */
  shapes: readonly string[];
  /** Applied to the shapes and the details, in the badge's coordinates. */
  transform?: string;
  /** Lines and fills over the shapes. */
  details: (palette: Palette) => ReactNode;
};

const EMBLEMS: Record<Emblem, EmblemArt> = {
  leaf: {
    shapes: [
      'M0 -36C17.5 -26 27 -7 22.5 11.5C18 26 8 33 0 33C-8 33 -18 26 -22.5 11.5C-27 -7 -17.5 -26 0 -36Z',
    ],
    transform: 'translate(60 62) rotate(38)',
    details: (palette) => (
      <>
        <path d="M0 33C-0.6 37 -2.4 40 -5.5 42" stroke={palette.emblemEdge} strokeWidth="2.4" />
        <g stroke="#fff" strokeOpacity="0.7" strokeWidth="1.5">
          <path d="M0 28C1.6 12 1.6 -10 0 -29" />
          <path d="M0.9 8C6 4.5 10.5 0 13.5 -6M0.6 -8C-4 -11.5 -8 -16 -10.5 -21M0.8 18C-4.5 15.5 -9 12 -12.5 7" />
        </g>
      </>
    ),
  },
  ribbon: {
    shapes: [`${tail(-1)}${tail(1)}`, rosette(60, 60, 25, 14, 2)],
    details: () => (
      <circle
        cx="60"
        cy="60"
        r="19"
        fill="none"
        stroke="#fff"
        strokeOpacity="0.75"
        strokeWidth="1.3"
      />
    ),
  },
  flower: {
    shapes: [[-90, -30, 30, 90, 150, 210].map((angle) => petal(60, 62, angle, 21)).join('')],
    details: (palette) => (
      <circle
        cx="60"
        cy="62"
        r="13.5"
        fill={palette.face[0]}
        stroke="#fff"
        strokeOpacity="0.8"
        strokeWidth="1.2"
      />
    ),
  },
  flame: {
    shapes: [
      'M60 20C63 31 68 38 70 45C74 41 79 38 82 33C88 44 90 56 89 68C88 88 76 102 60 102C44 102 31 89 31 70C31 58 34 47 38 38C41 44 45 48 50 50C50 38 54 28 60 20Z',
    ],
    details: () => (
      <path
        d="M60 58C64 66 73 72 73 82C73 91 67 96 60 96C53 96 47 91 47 82C47 72 56 66 60 58Z"
        fill="#fff"
        fillOpacity="0.4"
      />
    ),
  },
  star: {
    shapes: [rounded(polygon(60, 62.5, [37.5, 18.5], 10), [4.5, 2.5])],
    details: (palette) => {
      const corners = polygon(60, 62.5, [33, 16.5], 10);
      return (
        <g strokeWidth="1.1" strokeLinecap="round">
          {corners.map(([x, y], i) => (
            <path
              key={i}
              d={`M60 62.5L${fixed(x)} ${fixed(y)}`}
              stroke={i % 2 ? palette.emblemEdge : '#fff'}
              strokeOpacity={i % 2 ? 0.5 : 0.7}
            />
          ))}
        </g>
      );
    },
  },
  crown: {
    shapes: [
      `${rounded(
        [
          [32, 85],
          [28, 47],
          [44.5, 61],
          [60, 40],
          [75.5, 61],
          [92, 47],
          [88, 85],
        ],
        [5, 3, 3, 3, 3, 3, 5],
      )}${circle(28, 44, 4.2)}${circle(60, 36.5, 4.8)}${circle(92, 44, 4.2)}`,
    ],
    details: (palette) => (
      <>
        <path d="M31.2 75.5H88.8L88.1 82H31.9Z" fill={palette.emblemEdge} fillOpacity="0.4" />
        <path d="M31.4 75.5H88.6" stroke="#fff" strokeOpacity="0.7" strokeWidth="1.1" />
        <circle cx="38" cy="78.8" r="1.9" fill="#fff" fillOpacity="0.8" />
        <circle cx="82" cy="78.8" r="1.9" fill="#fff" fillOpacity="0.8" />
      </>
    ),
  },
  gem: {
    shapes: [
      rounded(
        [
          [37, 41],
          [83, 41],
          [97, 57],
          [60, 101],
          [23, 57],
        ],
        [4, 4, 4, 5, 4],
      ),
    ],
    details: (palette) => (
      <>
        <path
          d="M24 57H96M37.5 41.5L48 57L60 41.5L72 57L82.5 41.5"
          fill="none"
          stroke="#fff"
          strokeOpacity="0.75"
          strokeWidth="1.1"
          strokeLinejoin="round"
        />
        <path
          d="M48 57L60 100M72 57L60 100"
          stroke={palette.emblemEdge}
          strokeOpacity="0.6"
          strokeWidth="1.1"
        />
      </>
    ),
  },
};

/** The digits a rolling column passes on its way up: a full turn first for the last digit. */
function stripOf(digit: number, last: boolean) {
  const run = Array.from({ length: digit + 1 }, (_, i) => i);
  return last ? [...Array.from({ length: 10 }, (_, i) => i), ...run] : run;
}

/** One digit, embossed: a light edge above it and a soft shadow below. */
function Glyph({
  x,
  y,
  digit,
  ink,
  shadow,
}: {
  x: number;
  y: number;
  digit: number;
  /** The digit's fill, a gradient. */
  ink: string;
  /** The colour of its shadow. */
  shadow: string;
}) {
  return (
    <>
      <text x={x + 0.3} y={y + 1.3} fill={shadow} fillOpacity="0.2">
        {digit}
      </text>
      <text x={x - 0.25} y={y - 0.6} fill="#fff" fillOpacity="0.9">
        {digit}
      </text>
      <text x={x} y={y} fill={ink}>
        {digit}
      </text>
    </>
  );
}

export function Badge({
  milestone,
  locked = false,
  rolling = false,
  label,
  className,
}: {
  milestone: Milestone;
  /** Not earned yet: drawn in frosted greys. */
  locked?: boolean;
  /** Each digit is a strip behind a window, ready for `rollUp`. */
  rolling?: boolean;
  /** Its accessible name; without one, it is decorative. */
  label?: string;
  className?: string;
}) {
  const id = useId();
  const palette = locked ? LOCKED : paletteOf(milestone.hue, milestone.saturation);
  const art = EMBLEMS[milestone.emblem];
  const url = (name: string) => `url(#${id}${name})`;
  const digits = String(milestone.days).split('').map(Number);
  const size = digits.length > 2 ? 30 : digits.length > 1 ? 34 : 36;
  /** Each digit's slot, the same for every digit. */
  const slot = size * 0.6;

  const number = digits.map((digit, i) => {
    const x = fixed(60 + (i - (digits.length - 1) / 2) * slot);
    const glyph = { x, ink: url('ink'), shadow: palette.ink[1] };
    if (!rolling) return <Glyph key={i} {...glyph} y={BASELINE} digit={digit} />;
    const strip = stripOf(digit, i === digits.length - 1);
    const rest = (strip.length - 1) * size;
    return (
      <g key={i} data-strip="" style={{ transform: `translateY(${-rest}px)` }}>
        {strip.map((passing, k) => (
          <Glyph key={k} {...glyph} y={BASELINE + k * size} digit={passing} />
        ))}
      </g>
    );
  });

  const layer = (fill: string, offset?: number) => (
    <g transform={offset ? `translate(0 ${offset})` : undefined}>
      <g transform={art.transform}>
        {art.shapes.map((shape, i) => (
          <path key={i} d={shape} fill={fill} />
        ))}
      </g>
    </g>
  );

  return (
    <svg
      className={className}
      viewBox="0 0 120 132"
      role={label ? 'img' : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
    >
      <defs>
        <linearGradient id={`${id}rim`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={palette.rim[0]} />
          <stop offset="1" stopColor={palette.rim[1]} />
        </linearGradient>
        <linearGradient id={`${id}face`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={palette.face[0]} />
          <stop offset="1" stopColor={palette.face[1]} />
        </linearGradient>
        <linearGradient id={`${id}emblem`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={palette.emblem[0]} />
          <stop offset="1" stopColor={palette.emblem[1]} />
        </linearGradient>
        <linearGradient id={`${id}ink`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={palette.ink[0]} />
          <stop offset="1" stopColor={palette.ink[1]} />
        </linearGradient>
        {/* The light along the top of a raised shape, fading out halfway down. */}
        <linearGradient id={`${id}light`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#fff" stopOpacity="0.95" />
          <stop offset="0.55" stopColor="#fff" stopOpacity="0" />
        </linearGradient>
        <radialGradient id={`${id}shadow`}>
          <stop offset="0" stopColor={palette.ink[1]} stopOpacity="0.3" />
          <stop offset="1" stopColor={palette.ink[1]} stopOpacity="0" />
        </radialGradient>
        <linearGradient id={`${id}sheen`} x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" stopColor="#fff" stopOpacity="0" />
          <stop offset="0.5" stopColor="#fff" stopOpacity="0.6" />
          <stop offset="1" stopColor="#fff" stopOpacity="0" />
        </linearGradient>
        <clipPath id={`${id}clip`}>
          <path d={RIM} />
        </clipPath>
        {rolling && (
          <clipPath id={`${id}window`}>
            {/* From the digits' tops (and their light edge) to just under their shadow. */}
            <rect x="0" y={BASELINE - size * 0.78} width="120" height={size * 0.78 + 2} />
          </clipPath>
        )}
        {locked && (
          <pattern
            id={`${id}specks`}
            width="9"
            height="9"
            patternUnits="userSpaceOnUse"
            patternTransform="rotate(18)"
          >
            <circle cx="2" cy="2" r="0.8" fill="#fff" />
            <circle cx="6.5" cy="5.5" r="0.55" fill="#fff" />
            <circle cx="3.5" cy="7.6" r="0.4" fill="#fff" />
          </pattern>
        )}
      </defs>

      <ellipse cx="60" cy="124" rx="44" ry="6" fill={url('shadow')} />
      <path d={RIM} fill={palette.edge} transform="translate(0 4)" />
      <path d={RIM} fill={url('rim')} />
      <path d={RIM} fill="none" stroke={url('light')} strokeWidth="1.6" />
      <path d={FACE} fill={palette.edge} fillOpacity="0.35" transform="translate(0 2)" />
      <path d={FACE} fill={url('face')} />
      <path d={FACE} fill="none" stroke={url('light')} strokeWidth="1.2" />

      {layer(palette.emblemEdge, 2.4)}
      {layer(url('emblem'))}
      <g
        transform={art.transform}
        fill="none"
        stroke={url('light')}
        strokeWidth="1.4"
        strokeLinecap="round"
      >
        {art.shapes.map((shape, i) => (
          <path key={i} d={shape} />
        ))}
      </g>
      <g transform={art.transform} fill="none" strokeLinecap="round">
        {art.details(palette)}
      </g>
      {locked && <path d={FACE} fill={url('specks')} fillOpacity="0.85" />}

      <g
        fontFamily={FONT}
        fontWeight="800"
        textAnchor="middle"
        style={{ fontVariantNumeric: 'tabular-nums' }}
      >
        <g fontSize={size} clipPath={rolling ? url('window') : undefined}>
          {number}
        </g>
        <text
          x="60"
          y={DAYS_BASELINE + 0.7}
          fontSize="9"
          fontWeight="700"
          letterSpacing="0.6"
          fill="#fff"
          fillOpacity="0.8"
        >
          days
        </text>
        <text
          x="60"
          y={DAYS_BASELINE}
          fontSize="9"
          fontWeight="700"
          letterSpacing="0.6"
          fill={palette.ink[0]}
        >
          days
        </text>
      </g>
      {locked && <path d={RIM} fill="#fff" fillOpacity="0.16" />}

      <g clipPath={url('clip')}>
        <g transform="rotate(22 60 62)">
          <rect data-sheen="" x="32" y="-30" width="56" height="190" fill={url('sheen')} />
        </g>
      </g>
    </svg>
  );
}

/**
 * Rolls a rolling badge's digits up to its number, each column from 0 to its digit; the last one
 * turns a full round first. Returns the animations, to cancel them.
 */
export function rollUp(badge: Element, timing: KeyframeAnimationOptions) {
  return Array.from(badge.querySelectorAll<SVGGElement>('[data-strip]'), (strip) =>
    strip.animate([{ transform: 'translateY(0px)' }, { transform: strip.style.transform }], {
      ...timing,
      fill: 'backwards',
    }),
  );
}
```

### streak-badges/milestones.ts

```tsx
/*
 * The streak's milestones: the badges on the shelf, in order of days. Each one has an emblem and
 * a colour of its own, and the words its sheet shows when it is earned.
 */

/** The raised shape behind a badge's number. */
export type Emblem = 'leaf' | 'ribbon' | 'flower' | 'flame' | 'star' | 'crown' | 'gem';

export type Milestone = {
  /** The streak, in days, that earns it. */
  days: number;
  /** Its name, under it on the shelf. */
  name: string;
  emblem: Emblem;
  /** Its colour, drawn in pastel tones: a hue (0 to 360) and a saturation (0 to 100). */
  hue: number;
  saturation: number;
  /** The heading of its sheet, and the line under it. */
  title: string;
  line: string;
};

export const MILESTONES: readonly Milestone[] = [
  {
    days: 3,
    name: 'Sprout',
    emblem: 'leaf',
    hue: 140,
    saturation: 52,
    title: 'Three days in a row',
    line: 'Something is taking root. Come back tomorrow and watch it grow.',
  },
  {
    days: 7,
    name: 'One week',
    emblem: 'ribbon',
    hue: 208,
    saturation: 82,
    title: 'Seven days in a row',
    line: 'A whole week of showing up. This is how a habit starts.',
  },
  {
    days: 14,
    name: 'In bloom',
    emblem: 'flower',
    hue: 336,
    saturation: 78,
    title: 'Two weeks strong',
    line: 'Fourteen days without a gap, and it is starting to bloom.',
  },
  {
    days: 21,
    name: 'On fire',
    emblem: 'flame',
    hue: 20,
    saturation: 90,
    title: 'Three weeks on fire',
    line: 'Twenty-one days in a row. It is part of your day now.',
  },
  {
    days: 30,
    name: 'Star month',
    emblem: 'star',
    hue: 44,
    saturation: 90,
    title: 'A month in a row',
    line: 'Thirty days of showing up. Take a moment to enjoy this one.',
  },
  {
    days: 60,
    name: 'Crowned',
    emblem: 'crown',
    hue: 262,
    saturation: 68,
    title: 'Sixty days in a row',
    line: 'Two months without missing a day. This habit is yours now.',
  },
  {
    days: 100,
    name: 'Rare gem',
    emblem: 'gem',
    hue: 174,
    saturation: 58,
    title: 'One hundred days',
    line: 'A hundred days in a row. Few get this far, and you did.',
  },
];
```

### streak-badges/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once, and the same curve
 * serves CSS transitions and the Web Animations API.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}

/** The spring's easing for `element.animate()`, or an ease-out where `linear()` is unknown. */
export function easingOf({ easing }: Spring) {
  return CSS.supports('transition-timing-function', easing)
    ? easing
    : 'cubic-bezier(0.2, 0.8, 0.2, 1)';
}
```

### streak-badges/ghost-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a control, press it and call the component's own handler, as a
 * click 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.
 */

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?: { x: number; y: number }) => 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 {}

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

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

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

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  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.8, y: stage.offsetHeight * 0.9 };

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

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

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    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 press: Demo['press'] = async (action) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { 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';
  };
}

/** Its colours are the stage's tokens (streak-badges.module.css). */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
};

/** The arrow the demo moves around. It is decorative: the demo's effects are announced. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### streak-badges/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 };
}
```