# Notification pills

> A bell that keeps your unread notifications as glassy pills: they spring out of it, swipe away and fold back in.

A notification tray drawn as soft, glassy pills stacked above a bell. Open it and the pills fly out of the bell one after another, small and blurred at first, full size on a spring; swipe one to the right, or press it, and it leaves while the others close the gap. New notifications drop in at the top as the bell swings and its badge jumps.

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

Category: Feedback.

## Anatomy

1. **Pill**: One notification, a button of glass. A press marks it read; a swipe right of a third of its width, or a quick flick, dismisses it.
2. **Icon disc**: The kind of notification (comment, reminder, system, mention), each in its own pastel tint. Turns green with a tick when read.
3. **Title and line**: What happened, in a few words, and one line under it, cut with an ellipsis when long.
4. **Time**: How long ago it came in: now, 4m, 2h. A screen reader hears it in full in the pill’s name.
5. **Mark all as read**: Sends every pill off as read, one after another, 80 ms apart. Gone when there is nothing left.
6. **Bell**: Opens and closes the list; the pills fly out of it and fold back into it. Escape closes it too.
7. **Badge**: Counts the pills, up to 9+. Jumps when one comes in, dips when one goes, and shrinks away at zero.
8. **Send a notification**: Brings in the next sample, at the top of the list. In a product this is the server, not a button.

## When to use

- A handful of unread notifications kept behind a bell, in an app header or a corner of a dashboard.
- Short, glanceable items: a title and one line each, where reading one is the same as clearing it.
- Touch and pointer alike: a press marks a pill read, a swipe to the right dismisses it.
- A product with a soft, tactile look, where the arrival of a notification may be felt (the bell swings, the badge jumps).

## When not to use

- A long history to scroll, search or filter. Use an inbox or an activity page; the stack holds up to `max` pills and drops the oldest.
- A notification that needs an action of its own (approve, reply, undo). Use a toast with its button, or a card in the inbox.
- Urgent or blocking news, such as a failed payment or lost connection. Use a banner or an alert that stays in view.
- Feedback on what the reader just did (saved, copied). Use a toast near the action, not the bell.
- Dense or long text: each pill shows one line and cuts the rest.

## Installation

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

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

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

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

## Usage

```tsx
import { NotificationPills } from './notification-pills';
import type { Notice } from './notices';

const inbox: Notice[] = [
  { id: 'invoice', kind: 'reminder', title: 'Invoice due', text: 'Tomorrow at 5 PM' },
  { id: 'deploy', kind: 'system', title: 'Deploy finished', text: 'The preview is ready' },
];

export function Inbox() {
  // Your notifications, four at most, and no demo.
  return <NotificationPills notifications={inbox} max={4} autoplay={false} />;
}
```

## States

- **Open**: The unread pills stacked above the bell, newest first.
- **Full**: At `max` pills; the next one in pushes the oldest out at the bottom.
- **Closed**: Folded into the bell, with the badge counting what waits.
- **Read**: A pressed pill turns green and shows a tick. Live, it holds 600 ms, then flies off; here it is held.
- **All caught up**: Nothing left: a quieter pane says so, and the badge is gone.
- **Dark**: Dark glass over a dark stage, by `colorScheme` or the page’s; the badge stays red.

## How it works

- Each pill starts inside the bell, shrunk around the middle of its round end where its icon is, faint and a little blurred. It grows to its place on a spring, the nearest first, 55 ms apart. Closing plays it the other way, with a small lift before the dive.
- The springs are real springs: the motion of a damped spring is sampled into a CSS linear() easing and played with the Web Animations API. Browsers without linear() get a cubic-bezier that overshoots a little.
- When a pill leaves, a copy takes its place over the list and flies off to the right at the swipe’s speed, while the pills that stay move to their new places with FLIP. A read pill turns green and shows a check for 600 ms before leaving. The reader’s confirmation plays a soft ascending chime; “Mark all as read” plays it once for the batch.
- A swipe follows the pointer. The pills take touch-action: pan-y, so sideways moves dismiss and up-and-down still scrolls the page. Past a third of the pill’s width, or flung fast, it goes; otherwise it springs back.
- The glass is drawn with gradients, a rim of light and soft shadows, without backdrop-filter. Only transform, opacity and a little blur move, only while something happens, and nothing runs at rest.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. At night each pill is translucent dark glass over a near-black stage, so the bell’s glow shows through, with a faint rim and deeper shadows; the badge stays red, its rim cut from the stage.

## API reference

- **notifications** (`Notice[]`, default `NOTICES`): The list at first, newest first. A notice has an id, a kind (comment, reminder, system or mention, which sets its icon and colour), a title, one line of text and, if you like, how many minutes ago it arrived.
- **incoming** (`Notice[]`, default `INCOMING`): What comes in, in turn: from the demo or from the “Send a notification” button.
- **max** (`number`, default `5`): The most pills the list holds; past it, the oldest leaves. The stage is as tall as that many pills.
- **label** (`string`, default `'Notifications'`): The list’s heading, and the bell’s name.
- **defaultOpen** (`boolean`, default `true`): Whether the list starts open; false starts with the bell alone and turns the demo off.
- **defaultRead** (`string[]`): The ids of pills drawn read, green with their tick, and held there. Turns the demo off.
- **onRead** (`(notice: Notice) => void`): Called when the reader marks a notification as read by pressing its pill.
- **onDismiss** (`(notice: Notice) => void`): Called when the reader dismisses a notification, by a swipe or Delete.
- **onReadAll** (`(notices: Notice[]) => void`): Called with every notification left when the reader presses “Mark all as read”.
- **onOpenChange** (`(open: boolean) => void`): Called when the reader opens (true) or closes (false) the list. The demo’s own changes are not reported.
- **autoplay** (`boolean`, default `true`): Plays on its own while on screen and untouched.
- **sound** (`boolean`, default `true`): A short success chime on a reader’s confirmation, once for “Mark all as read”. Set false to silence it. The demo and dismissals are silent; unsupported audio never prevents confirmation.
- **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 bell is a button with aria-expanded, and its name carries the count (“Notifications, 4 unread”). Escape closes the list and puts focus back on the bell.
- The list is a list of buttons. Enter or Space marks one as read, Delete or Backspace dismisses it, and the arrow keys, Home and End move between them. When a pill leaves, focus moves to its neighbour.
- New notifications, dismissals and “Mark all as read” are announced politely. The demo’s own changes are not, and it stops as soon as the reader points, types or focuses inside it.
- Under reduced motion nothing flies and the demo never plays. A read pill still shows a static green confirmation briefly before disappearing. Confirmation sounds are triggered by the reader and can be disabled with sound={false}.

## Source

### notification-pills/notification-pills.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type MouseEvent,
} from 'react';
import { BellIcon, CheckIcon, KindIcon, PlusIcon } from './icons';
import {
  TICK,
  bump,
  dropIn,
  flip,
  fold,
  leave,
  measure,
  reducedMotion,
  ring,
  rise,
  scaleOf,
} from './motion';
import { INCOMING, NOTICES, ago, type Notice } from './notices';
import { useSwipe } from './swipe';
import { useAutoplay } from './use-autoplay';
import { successSound, type SuccessSound } from './success-sound';
import styles from './notification-pills.module.css';

/*
 * Notification pills: a bell that keeps the unread notifications, shown as a stack of soft, glassy
 * pills above it.
 *
 * - The bell opens and closes the list. The pills fly out of it one after another, from small and
 *   blurred to full size on a spring, and fold back into it (motion.ts).
 * - A pill leaves when it is read (pressed) or dismissed (swiped to the right, or Delete): a copy
 *   of it flies off over the list while the others close the gap with FLIP. "Mark all as read"
 *   sends them all, one after another.
 * - A new notification drops in at the top; the bell swings and the badge, which counts the
 *   pills, jumps.
 * - The glass is drawn, not filtered: gradients, a rim of light and soft shadows, no
 *   backdrop-filter. Only transform, opacity and a little blur move, and only while something
 *   happens.
 * - Light or dark: every colour is a light-dark() token in the stylesheet, so it follows the page's
 *   color-scheme, or the one the colorScheme prop sets. At night the glass is a dark, translucent
 *   pane over the stage, and the badge stays red.
 * - While on screen and untouched, it plays on its own: it opens, a notification comes in, the
 *   oldest is swiped away, it closes, and again with new ones.
 * - defaultOpen={false} starts it closed (the bell alone) and defaultRead draws some pills read,
 *   green with their tick, held still; either turns the demo off. onRead, onDismiss, onReadAll and
 *   onOpenChange report what the reader does (the demo's own changes are not reported).
 */

/** In "Mark all as read", each pill leaves this long after the one above it, in ms. */
const CASCADE = 80;

/** What to play once React has put a change on the page. */
type Pending = {
  /** Where the pills and the heading were before the change, for FLIP. */
  before?: Map<HTMLElement, DOMRect>;
  /** How long they wait before they move, in ms: while a read pill shows its tick. */
  delay?: number;
  /** The pill that just arrived. */
  arrived?: string;
  /** The list just opened. */
  rise?: boolean;
};

const unread = (count: number) => (count ? `${count} unread` : 'none unread');

export function NotificationPills({
  notifications = NOTICES,
  incoming = INCOMING,
  max = 5,
  label = 'Notifications',
  autoplay = true,
  sound = true,
  colorScheme,
  defaultOpen = true,
  defaultRead,
  onRead,
  onDismiss,
  onReadAll,
  onOpenChange,
  className = '',
}: {
  /** The notifications in the list at first, newest first. */
  notifications?: Notice[];
  /** The notifications that come in, in turn: from the demo, or the "Send a notification" button. */
  incoming?: Notice[];
  /** The most pills the list holds: past it, the oldest leaves. The stage is as tall as that. */
  max?: number;
  /** The list's heading, and the bell's name. */
  label?: string;
  /** Plays on its own while on screen and untouched. */
  autoplay?: boolean;
  /** A quiet success chime when the reader marks a notification (or all of them) as read. */
  sound?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Starts with the list open (the default) or closed into the bell; closed turns the demo off. */
  defaultOpen?: boolean;
  /** The ids of pills drawn read (green, with a tick) and held there; turns the demo off. */
  defaultRead?: string[];
  /** Called when the reader marks a notification as read (presses its pill). */
  onRead?: (notice: Notice) => void;
  /** Called when the reader dismisses a notification (swipe, or Delete). */
  onDismiss?: (notice: Notice) => void;
  /** Called with every notification left when the reader presses "Mark all as read". */
  onReadAll?: (notices: Notice[]) => void;
  /** Called when the reader opens (true) or closes (false) the list. */
  onOpenChange?: (open: boolean) => void;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [items, setItems] = useState(() => notifications.slice(0, max));
  const [open, setOpen] = useState(defaultOpen);
  /** The list is on screen: open, or folding back into the bell. */
  const [shown, setShown] = useState(defaultOpen);
  const [message, setMessage] = useState('');
  const panelId = useId();
  const labelId = useId();
  const hintId = useId();

  // The latest state, for the handlers and the demo, which run between renders.
  const live = useRef({ items, open: defaultOpen });
  /** The reader's callbacks, latest, for the handlers. */
  const report = useRef({ onRead, onDismiss, onReadAll, onOpenChange });
  useEffect(() => {
    report.current = { onRead, onDismiss, onReadAll, onOpenChange };
  });
  const pending = useRef<Pending>({});
  const panel = useRef<HTMLDivElement>(null);
  const head = useRef<HTMLDivElement>(null);
  const ghosts = useRef<HTMLUListElement>(null);
  const bell = useRef<HTMLButtonElement>(null);
  const bellIcon = useRef<SVGSVGElement>(null);
  const badge = useRef<HTMLSpanElement>(null);
  const figure = useRef<HTMLSpanElement>(null);
  /** Counts the toggles, so that a fold overtaken by an opening does not hide the list. */
  const toggles = useRef(0);
  const delivered = useRef(0);
  const timers = useRef(new Set<number>());
  const chime = useRef<SuccessSound | null>(null);

  const confirmSound = () => {
    if (!sound) return;
    chime.current ??= successSound();
    chime.current.play();
  };

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

  const later = (action: () => void, ms: number) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      action();
    }, ms);
    timers.current.add(timer);
  };

  const itemOf = (id: string) =>
    panel.current?.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`) ?? null;
  const all = (selector: string) =>
    Array.from(panel.current?.querySelectorAll<HTMLElement>(selector) ?? []);

  /**
   * Changes the list. The pills that stay will move to their new places with FLIP, `delay` ms
   * later; a new one (`arrived`) drops in.
   */
  const commit = (
    next: Notice[],
    { arrived, delay = 0 }: { arrived?: string; delay?: number } = {},
  ) => {
    if (live.current.open && !reducedMotion()) {
      const job = pending.current;
      const before = measure(all('[data-flip]'));
      // A change not yet on the page keeps its measures: they are the older ones.
      job.before = new Map([...before, ...(job.before ?? [])]);
      job.delay = Math.max(job.delay ?? 0, delay);
      if (arrived) job.arrived = arrived;
    }
    live.current.items = next;
    setItems(next);
  };

  // Plays the change React has just put on the page, before the browser paints it.
  useLayoutEffect(() => {
    const job = pending.current;
    const stage = root.current;
    if (!stage || !(job.rise || job.before || job.arrived)) return;
    pending.current = {};
    const scale = scaleOf(stage);
    if (job.rise && bell.current) rise(all('[data-float]'), head.current, bell.current, scale);
    else if (job.before) flip(job.before, scale, job.delay);
    const arrived = job.arrived && itemOf(job.arrived)?.querySelector<HTMLElement>('[data-float]');
    if (arrived) dropIn(arrived);
  });

  /** Opens or closes the list. */
  const toggle = (next: boolean) => {
    if (live.current.open === next) return;
    live.current.open = next;
    const round = ++toggles.current;
    setOpen(next);
    if (next) {
      setShown(true);
      if (!reducedMotion()) pending.current.rise = true;
      return;
    }
    // Focus in the list goes back to the bell before the list turns inert.
    if (panel.current?.contains(document.activeElement)) bell.current?.focus();
    const stage = root.current;
    if (reducedMotion() || !stage || !bell.current) {
      setShown(false);
      return;
    }
    fold(all('[data-float]'), bell.current, scaleOf(stage)).then(
      () => {
        if (round === toggles.current) setShown(false);
      },
      // Overtaken: the list opened again before the pills were in.
      () => {},
    );
  };

  /**
   * Takes a notification out of the list, read or dismissed: a swipe passes the `speed` it was let
   * go at (px per ms); `quiet` skips the announcement.
   */
  const remove = (
    id: string,
    how: 'read' | 'dismissed',
    { speed = 0, quiet = false }: { speed?: number; quiet?: boolean } = {},
  ) => {
    const notice = live.current.items.find((item) => item.id === id);
    if (!notice) return;
    const item = itemOf(id);
    // Focus on the pill moves to the next one down, or up, or to the bell.
    if (item?.contains(document.activeElement)) {
      const next = item.nextElementSibling ?? item.previousElementSibling;
      (next?.querySelector('button') ?? bell.current)?.focus();
    }
    const stage = root.current;
    const layer = ghosts.current;
    const read = how === 'read';
    let flying = false;
    if (read && !quiet) confirmSound();
    if (item && stage && layer && live.current.open && (read || !reducedMotion())) {
      leave(item, layer, { read, speed, scale: scaleOf(stage) });
      flying = true;
    }
    const next = live.current.items.filter((other) => other !== notice);
    // The gap closes as the pill flies: after its tick, when it was read.
    commit(next, { delay: flying && read ? TICK : 0 });
    if (!quiet) {
      (read ? report.current.onRead : report.current.onDismiss)?.(notice);
      const what = read ? `${notice.title} marked as read` : `Dismissed ${notice.title}`;
      announce(`${what}. ${unread(next.length)}.`);
    }
  };

  /** The next notification of `incoming` comes in, at the top; past `max`, the oldest leaves. */
  const deliver = () => {
    if (!incoming.length) return null;
    const sample = incoming[delivered.current % incoming.length];
    delivered.current += 1;
    const notice = { ...sample, id: `${sample.id}-${delivered.current}` };
    const { items: now } = live.current;
    if (now.length >= max) remove(now[now.length - 1].id, 'dismissed', { quiet: true });
    commit([notice, ...live.current.items], { arrived: notice.id });
    return notice;
  };

  const sendOne = () => {
    const notice = deliver();
    if (notice) announce(`New notification: ${notice.title}. ${notice.text}.`);
  };

  const markAllRead = () => {
    const now = live.current.items;
    if (!now.length) return;
    confirmSound();
    report.current.onReadAll?.(now);
    // The link goes with the last pill: focus waits on the bell.
    bell.current?.focus();
    announce(`All caught up: ${now.length} marked as read.`);
    if (reducedMotion()) {
      now.forEach((notice) => remove(notice.id, 'read', { quiet: true }));
      return;
    }
    now.forEach((notice, i) =>
      later(() => remove(notice.id, 'read', { quiet: true }), i * CASCADE),
    );
  };

  const { handlers: swipe, swiped } = useSwipe((id, speed) => remove(id, 'dismissed', { speed }));

  const onPillClick = (event: MouseEvent<HTMLButtonElement>, id: string) => {
    // The click that ends a swipe is not a press (a key's click has no detail).
    if (event.detail > 0 && swiped.current) return;
    remove(id, 'read');
  };

  const onListKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
    const pill = event.target instanceof HTMLElement ? event.target.closest('button') : null;
    const id = pill?.dataset.id;
    if (!pill || !id) return;
    if (event.key === 'Delete' || event.key === 'Backspace') {
      event.preventDefault();
      remove(id, 'dismissed');
      return;
    }
    const pills = Array.from(event.currentTarget.querySelectorAll('button'));
    const index = pills.indexOf(pill);
    const moves: Record<string, number> = {
      ArrowDown: index + 1,
      ArrowUp: index - 1,
      Home: 0,
      End: pills.length - 1,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    pills[Math.min(pills.length - 1, Math.max(0, moves[event.key]))]?.focus();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'Escape' && live.current.open) {
      toggle(false);
      report.current.onOpenChange?.(false);
    }
  };

  // The badge counts the pills. It jumps (and the bell swings) as one comes in, and dips as one
  // goes; at zero it shrinks away, still showing the last figure.
  const count = items.length;
  const [figureShown, setFigureShown] = useState(count || 1);
  if (count > 0 && count !== figureShown) setFigureShown(count);
  const previous = useRef(count);
  useEffect(() => {
    const before = previous.current;
    previous.current = count;
    if (count === before || reducedMotion()) return;
    if (count > before && bellIcon.current) ring(bellIcon.current);
    if (count && before && badge.current && figure.current) {
      bump(badge.current, figure.current, count > before);
    }
  }, [count]);

  // The demo: it opens, a notification comes in, the oldest is swiped away (or, with few left,
  // another comes in), it closes; and again, while on screen and untouched. Its changes are not
  // announced: they would talk over the page every few seconds.
  const demo = autoplay && defaultOpen && !defaultRead?.length && playing;
  useEffect(() => {
    if (!demo) return;
    let alive = true;
    let timer = 0;
    let nudged: HTMLElement | null = null;
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        timer = window.setTimeout(resolve, ms);
      });
    (async () => {
      while (alive) {
        const opening = !live.current.open;
        if (opening) toggle(true);
        await wait(opening ? 1600 : 1100);
        if (!alive) return;
        deliver();
        await wait(1800);
        if (!alive) return;
        const now = live.current.items;
        if (now.length >= max) {
          // A swipe on the oldest: it slides a little way, then flies off.
          const oldest = now[now.length - 1];
          nudged = itemOf(oldest.id)?.querySelector('button') ?? null;
          nudged?.style.setProperty('transform', 'translate3d(2.6em, 0, 0) rotate(0.6deg)');
          await wait(300);
          if (!alive) return;
          remove(oldest.id, 'dismissed', { speed: 0.9, quiet: true });
          nudged = null;
        } else {
          deliver();
        }
        await wait(1900);
        if (!alive) return;
        toggle(false);
        await wait(1500);
      }
    })();
    return () => {
      alive = false;
      window.clearTimeout(timer);
      nudged?.style.removeProperty('transform');
    };
    // The helpers it calls read refs only, so `demo` is all it depends on.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo]);

  // Nothing waits once the component is gone.
  useEffect(() => {
    const pendingTimers = timers.current;
    return () => {
      pendingTimers.forEach((timer) => window.clearTimeout(timer));
      chime.current?.dispose();
      chime.current = null;
    };
  }, []);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={{ '--max': max } as CSSProperties}
      onKeyDown={onKeyDown}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocusCapture={take}
      onPointerMove={(event) => {
        // A mouse over the stage pauses the demo, so nothing moves away from under it.
        if (event.pointerType === 'mouse') take();
      }}
    >
      <div className={styles.stage} data-part="stage">
        <div className={styles.column}>
          <div
            ref={panel}
            id={panelId}
            className={styles.panel}
            data-open={open || undefined}
            hidden={!shown}
            inert={!open}
          >
            <div ref={head} className={styles.head} data-part="heading" data-flip>
              <p id={labelId} className={styles.label}>
                {label}
              </p>
              {count > 0 && (
                <button
                  type="button"
                  className={styles.markAll}
                  data-part="mark-all"
                  onClick={markAllRead}
                >
                  Mark all as read
                </button>
              )}
            </div>
            {count === 0 && (
              <div className={styles.float} data-float>
                <p className={styles.empty} data-part="empty">
                  <span className={styles.disc} aria-hidden="true">
                    <CheckIcon className={styles.glyph} />
                  </span>
                  <span className={styles.copy}>
                    <span className={styles.title}>You’re all caught up</span>
                    <span className={styles.text}>New notifications land here</span>
                  </span>
                </p>
              </div>
            )}
            <ul className={styles.list} aria-labelledby={labelId} onKeyDown={onListKeyDown}>
              {items.map((notice) => {
                const time = ago(notice.minutes);
                return (
                  <li
                    key={notice.id}
                    className={styles.item}
                    data-id={notice.id}
                    data-read={defaultRead?.includes(notice.id) || undefined}
                    data-flip
                  >
                    <div className={styles.float} data-float>
                      <button
                        type="button"
                        className={styles.pill}
                        data-part="pill"
                        data-id={notice.id}
                        data-kind={notice.kind}
                        aria-label={`${notice.title}, ${notice.text}, ${time.long}`}
                        aria-describedby={hintId}
                        onClick={(event) => onPillClick(event, notice.id)}
                        {...swipe}
                      >
                        <span className={styles.disc} data-part="icon" aria-hidden="true">
                          <KindIcon kind={notice.kind} className={styles.glyph} />
                          <CheckIcon className={styles.tick} />
                        </span>
                        <span className={styles.copy}>
                          <span className={styles.title} data-part="title">
                            {notice.title}
                          </span>
                          <span className={styles.text} data-part="text">
                            {notice.text}
                          </span>
                        </span>
                        <span className={styles.time} data-part="time" aria-hidden="true">
                          {time.short}
                        </span>
                      </button>
                    </div>
                  </li>
                );
              })}
            </ul>
          </div>

          <div className={styles.bellRow}>
            <button
              ref={bell}
              type="button"
              className={styles.bell}
              data-part="bell"
              aria-label={`${label}, ${unread(count)}`}
              aria-expanded={open}
              aria-controls={panelId}
              onClick={() => {
                const next = !live.current.open;
                toggle(next);
                report.current.onOpenChange?.(next);
              }}
            >
              <BellIcon ref={bellIcon} className={styles.bellIcon} />
              <span
                ref={badge}
                className={styles.badge}
                data-part="badge"
                data-empty={count === 0 || undefined}
                aria-hidden="true"
              >
                <span ref={figure} className={styles.figure}>
                  {figureShown > 9 ? '9+' : figureShown}
                </span>
              </span>
            </button>
            <button type="button" className={styles.send} data-part="send" onClick={sendOne}>
              <PlusIcon />
              Send a notification
            </button>
          </div>
        </div>
        {/* The copies of the pills on their way out (motion.ts). */}
        <ul ref={ghosts} className={styles.ghosts} aria-hidden="true" inert />
      </div>

      <p id={hintId} className={styles.hidden}>
        Press to mark as read, or Delete to dismiss.
      </p>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### notification-pills/notification-pills.module.css

```css
/* Notification pills (notification-pills.tsx): a stage with the bell at the bottom and the list
   of pills stacked above it. Sizes are in em: the stage sets the font size, smaller in a narrow
   container (a container query), and everything follows. The glass is drawn with gradients, a
   rim of light and soft shadows.

   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). By day the glass is white; at night it is a dark,
   translucent pane (white at a low alpha over the stage) with a faint rim. --pill, --gap, --disc
   and --bell are sizes, and --max comes from the component, so no colour takes those names. */

.root {
  /* The stage, top to bottom, lit from below. */
  --surface: #dcdcdf;
  --surface-low: #d2d2d5;
  --stage-glow: rgb(255 255 255 / 0.32);
  /* A pill's glass, top to bottom, its rim (the hairline round it) and the shade inside its
     bottom edge. */
  --surface-2: #fdfdfd;
  --glass-mid: #f4f4f5;
  --glass-low: #ededef;
  --line: rgb(255 255 255 / 0.9);
  --rim-shade: rgb(0 0 0 / 0.025);
  /* A pill's shadows, near to far, and the deeper one of a lifted pill. */
  --shadow-near: rgb(20 20 30 / 0.07);
  --shadow: rgb(20 20 30 / 0.16);
  --shadow-far: rgb(20 20 30 / 0.2);
  --shadow-lift: rgb(20 20 30 / 0.26);
  /* The words: the stage's (and a hovered link's), a pill's title, its line, its time; the
     heading, Mark all as read, Send a notification. */
  --ink: #2d2d33;
  --ink-title: #3a3a40;
  --ink-muted: #77777f;
  --ink-faint: #9d9da5;
  --ink-label: #57575e;
  --ink-link: #6c6c74;
  --ink-send: #5d5d64;
  /* The bell: its glass, top to bottom, rim, shadows and glyph; and the glow it sits on. */
  --bell-top: #fff;
  --bell-mid: #f6f6f7;
  --bell-low: #eeeef0;
  --bell-rim: rgb(255 255 255 / 0.95);
  --bell-shadow-near: rgb(20 20 30 / 0.08);
  --bell-shadow: rgb(20 20 30 / 0.24);
  --bell-shadow-far: rgb(20 20 30 / 0.2);
  --ink-bell: #46464d;
  --bell-glow: rgb(255 255 255 / 0.6);
  /* The count: red in both, a white figure, a rim cut out of what is behind it, a red glow. */
  --accent: #d8323e;
  --accent-top: #f2585f;
  --accent-ink: #fff;
  --badge-rim: #fff;
  --badge-glow: rgb(216 50 62 / 0.55);
  /* The rim of an icon's disc (each kind's colours are on its pill, below). */
  --disc-rim: rgb(255 255 255 / 0.85);
  /* Read: the pill turns to green glass, with green words, a green disc and a tick. */
  --read-top: #ecfbf1;
  --read-mid: #d3f3df;
  --read-low: #bce8cd;
  --read-shade: rgb(20 101 55 / 0.06);
  --read-shadow: rgb(20 101 55 / 0.2);
  --read-shadow-far: rgb(20 101 55 / 0.22);
  --read-title: #146537;
  --read-text: #3f7a54;
  --read-disc-top: #ebf8ef;
  --read-disc-low: #c9ebd4;
  --tick: #1f944a;
  /* None left: a quieter pane of glass. */
  --empty-top: rgb(255 255 255 / 0.55);
  --empty-low: rgb(255 255 255 / 0.3);
  --empty-rim: rgb(255 255 255 / 0.7);
  --empty-shadow: rgb(20 20 30 / 0.12);
  /* Send a notification: a small pane of glass. */
  --send-top: rgb(255 255 255 / 0.75);
  --send-low: rgb(255 255 255 / 0.45);
  --send-rim: rgb(255 255 255 / 0.8);
  --send-shadow: rgb(20 20 30 / 0.2);
  /* The focus rings. */
  --focus: #3a67f0;
  /* A pill's height and the space between two, its icon's disc, the bell. */
  --pill: 4.5em;
  --gap: 0.7em;
  --disc: 2.9em;
  --bell: 4em;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --bounce: cubic-bezier(0.34, 1.5, 0.64, 1);
  position: relative;
  container-type: inline-size;
  min-width: 0;
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#dcdcdf, #18181b);
    --surface-low: light-dark(#d2d2d5, #101012);
    --stage-glow: light-dark(rgb(255 255 255 / 0.32), rgb(255 255 255 / 0.045));
    --surface-2: light-dark(#fdfdfd, rgb(255 255 255 / 0.12));
    --glass-mid: light-dark(#f4f4f5, rgb(255 255 255 / 0.085));
    --glass-low: light-dark(#ededef, rgb(255 255 255 / 0.065));
    --line: light-dark(rgb(255 255 255 / 0.9), rgb(255 255 255 / 0.1));
    --rim-shade: light-dark(rgb(0 0 0 / 0.025), rgb(0 0 0 / 0.22));
    --shadow-near: light-dark(rgb(20 20 30 / 0.07), rgb(0 0 0 / 0.35));
    --shadow: light-dark(rgb(20 20 30 / 0.16), rgb(0 0 0 / 0.45));
    --shadow-far: light-dark(rgb(20 20 30 / 0.2), rgb(0 0 0 / 0.5));
    --shadow-lift: light-dark(rgb(20 20 30 / 0.26), rgb(0 0 0 / 0.6));
    --ink: light-dark(#2d2d33, #ececf0);
    --ink-title: light-dark(#3a3a40, #f0f0f3);
    --ink-muted: light-dark(#77777f, #a8a8b0);
    --ink-faint: light-dark(#9d9da5, #9e9ea6);
    --ink-label: light-dark(#57575e, #b9b9c1);
    --ink-link: light-dark(#6c6c74, #a9a9b1);
    --ink-send: light-dark(#5d5d64, #b4b4bb);
    --bell-top: light-dark(#fff, rgb(255 255 255 / 0.16));
    --bell-mid: light-dark(#f6f6f7, rgb(255 255 255 / 0.12));
    --bell-low: light-dark(#eeeef0, rgb(255 255 255 / 0.09));
    --bell-rim: light-dark(rgb(255 255 255 / 0.95), rgb(255 255 255 / 0.12));
    --bell-shadow-near: light-dark(rgb(20 20 30 / 0.08), rgb(0 0 0 / 0.35));
    --bell-shadow: light-dark(rgb(20 20 30 / 0.24), rgb(0 0 0 / 0.5));
    --bell-shadow-far: light-dark(rgb(20 20 30 / 0.2), rgb(0 0 0 / 0.45));
    --ink-bell: light-dark(#46464d, #e6e6ea);
    --bell-glow: light-dark(rgb(255 255 255 / 0.6), rgb(255 255 255 / 0.07));
    --badge-rim: light-dark(#fff, #161618);
    --badge-glow: light-dark(rgb(216 50 62 / 0.55), rgb(216 50 62 / 0.4));
    --disc-rim: light-dark(rgb(255 255 255 / 0.85), rgb(255 255 255 / 0.1));
    --read-top: light-dark(#ecfbf1, rgb(74 222 128 / 0.2));
    --read-mid: light-dark(#d3f3df, rgb(74 222 128 / 0.15));
    --read-low: light-dark(#bce8cd, rgb(74 222 128 / 0.12));
    --read-shade: light-dark(rgb(20 101 55 / 0.06), rgb(0 0 0 / 0.2));
    --read-shadow: light-dark(rgb(20 101 55 / 0.2), rgb(0 0 0 / 0.45));
    --read-shadow-far: light-dark(rgb(20 101 55 / 0.22), rgb(0 0 0 / 0.5));
    --read-title: light-dark(#146537, #8ef0b4);
    --read-text: light-dark(#3f7a54, #8fd0a8);
    --read-disc-top: light-dark(#ebf8ef, #24563a);
    --read-disc-low: light-dark(#c9ebd4, #1c4630);
    --tick: light-dark(#1f944a, #6ee39b);
    --empty-top: light-dark(rgb(255 255 255 / 0.55), rgb(255 255 255 / 0.07));
    --empty-low: light-dark(rgb(255 255 255 / 0.3), rgb(255 255 255 / 0.04));
    --empty-rim: light-dark(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.08));
    --empty-shadow: light-dark(rgb(20 20 30 / 0.12), rgb(0 0 0 / 0.35));
    --send-top: light-dark(rgb(255 255 255 / 0.75), rgb(255 255 255 / 0.1));
    --send-low: light-dark(rgb(255 255 255 / 0.45), rgb(255 255 255 / 0.06));
    --send-rim: light-dark(rgb(255 255 255 / 0.8), rgb(255 255 255 / 0.1));
    --send-shadow: light-dark(rgb(20 20 30 / 0.2), rgb(0 0 0 / 0.4));
    --focus: light-dark(#3a67f0, #86a2ff);
  }
}
/* 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;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}

.stage {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  /* Room for `max` pills (set by the component), the heading and the bell. */
  height: calc(var(--max, 5) * (var(--pill) + var(--gap)) + 13.4em);
  padding: 2em 1.1em 2.6em;
  overflow: hidden;
  overflow: clip;
  /* Its own stacking context: the bell's glow (z-index: -1) paints right above its background. */
  isolation: isolate;
  border-radius: 18px;
  background:
    radial-gradient(70% 55% at 50% 100%, var(--stage-glow), transparent 70%),
    linear-gradient(180deg, var(--surface), var(--surface-low));
  font-size: 15px;
  line-height: 1.3;
  color: var(--ink);
}

.column {
  display: flex;
  flex-direction: column;
  width: min(100%, 26em);
}

/* The list and its heading, stacked from the bell up. */
.panel {
  display: flex;
  flex-direction: column;
}
.panel[hidden] {
  display: none;
}

.head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1em;
  padding: 0 0.5em 0.7em 1.2em;
  transition: opacity 0.18s ease;
}
.label {
  margin: 0;
  font-size: 0.86em;
  font-weight: 600;
  letter-spacing: -0.005em;
  color: var(--ink-label);
}
.markAll {
  padding: 0.3em 0.4em;
  border: 0;
  border-radius: 0.4em;
  background: none;
  font: inherit;
  font-size: 0.82em;
  font-weight: 500;
  color: var(--ink-link);
  text-decoration: underline transparent;
  text-underline-offset: 0.2em;
  cursor: pointer;
  transition:
    color 0.2s ease,
    text-decoration-color 0.2s ease;
}
.markAll:hover {
  color: var(--ink);
  text-decoration-color: currentColor;
}

.list {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Closing: the heading fades, and what is not flying into the bell is hidden at once. */
.panel:not([data-open]) .head,
.panel:not([data-open]) .float {
  opacity: 0;
}

/* Out of and back into the bell, a pill pivots on the middle of its round end, where its icon is. */
.float {
  transform-origin: calc(var(--pill) / 2) 50%;
}

/* A pill: glass, a faint vertical gradient, a thin rim of light inside, a wide soft shadow. */
.pill {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.85em;
  width: 100%;
  height: var(--pill);
  margin: 0;
  padding: 0 1.4em 0 calc((var(--pill) - var(--disc)) / 2);
  border: 0;
  border-radius: 999px;
  background: linear-gradient(
    180deg,
    var(--surface-2) 0%,
    var(--glass-mid) 52%,
    var(--glass-low) 100%
  );
  box-shadow:
    inset 0 0 0 1.5px var(--line),
    inset 0 -1px 1px var(--rim-shade),
    0 1px 1.5px var(--shadow-near),
    0 0.55em 1.2em -0.5em var(--shadow),
    0 1.5em 2.8em -1.3em var(--shadow-far);
  font: inherit;
  text-align: left;
  color: inherit;
  cursor: pointer;
  /* Sideways moves are the swipe's; up and down still scroll the page. */
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  transition:
    translate 0.35s var(--ease-out),
    scale 0.2s var(--ease-out),
    transform 0.55s var(--bounce),
    opacity 0.3s ease;
}
/* The deeper shadow of a lifted pill, faded in. */
.pill::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 1.3em 2.4em -1em var(--shadow-lift);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.35s var(--ease-out);
}
@media (hover: hover) {
  .pill:hover {
    translate: 0 -0.16em;
  }
  .pill:hover::after {
    opacity: 1;
  }
}
.pill:active {
  scale: 0.985;
}
/* Under the pointer: it follows the hand, with no transition. */
.pill[data-swiping] {
  transition:
    translate 0.35s var(--ease-out),
    scale 0.2s var(--ease-out);
}
.pill:focus-visible,
.bell:focus-visible,
.markAll:focus-visible,
.send:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

/* The icon's disc: a tint of the kind's colour, lighter at the top, with a rim of light. By day a
   pastel under a deep glyph; at night a deep tint under a light one. */
.pill[data-kind='comment'] {
  --tint: #eef1fd;
  --tint-deep: #d5dffb;
  --hue: #3d63e6;
}
@supports (color: light-dark(#fff, #000)) {
  /* The icon's disc: a tint of the kind's colour, lighter at the top, with a rim of light. By day a
   pastel under a deep glyph; at night a deep tint under a light one. */
  .pill[data-kind='comment'] {
    --tint: light-dark(#eef1fd, #2f3a63);
    --tint-deep: light-dark(#d5dffb, #252d4d);
    --hue: light-dark(#3d63e6, #93abff);
  }
}
.pill[data-kind='reminder'] {
  --tint: #fcf1e8;
  --tint-deep: #f8dcc6;
  --hue: #e0702d;
}
@supports (color: light-dark(#fff, #000)) {
  .pill[data-kind='reminder'] {
    --tint: light-dark(#fcf1e8, #4a3426);
    --tint-deep: light-dark(#f8dcc6, #3a281c);
    --hue: light-dark(#e0702d, #ffa066);
  }
}
.pill[data-kind='system'] {
  --tint: #f6eefc;
  --tint-deep: #e3d3f7;
  --hue: #8845de;
}
@supports (color: light-dark(#fff, #000)) {
  .pill[data-kind='system'] {
    --tint: light-dark(#f6eefc, #3d2f57);
    --tint-deep: light-dark(#e3d3f7, #2f2445);
    --hue: light-dark(#8845de, #c39bff);
  }
}
.pill[data-kind='mention'] {
  --tint: #fcf5d6;
  --tint-deep: #f3e2a0;
  --hue: #b07a12;
}
@supports (color: light-dark(#fff, #000)) {
  .pill[data-kind='mention'] {
    --tint: light-dark(#fcf5d6, #46391a);
    --tint-deep: light-dark(#f3e2a0, #372c12);
    --hue: light-dark(#b07a12, #f0c050);
  }
}
.disc {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: var(--disc);
  height: var(--disc);
  border-radius: 50%;
  background: linear-gradient(180deg, var(--tint), var(--tint-deep));
  box-shadow:
    inset 0 0 0 1.5px var(--disc-rim),
    inset 0 -0.35em 0.6em -0.3em color-mix(in srgb, var(--hue) 22%, transparent),
    0 0.15em 0.45em -0.1em color-mix(in srgb, var(--hue) 30%, transparent);
  color: var(--hue);
}
.glyph,
.tick {
  position: relative;
  width: 1.6em;
  height: 1.6em;
}
.tick {
  position: absolute;
  color: var(--tick);
  opacity: 0;
}
/* Read: the whole glass pill turns green and shows a tick before it leaves (motion.ts). */
.item[data-read] .pill {
  background: linear-gradient(
    180deg,
    var(--read-top) 0%,
    var(--read-mid) 52%,
    var(--read-low) 100%
  );
  box-shadow:
    inset 0 0 0 1.5px var(--line),
    inset 0 -1px 1px var(--read-shade),
    0 0.55em 1.2em -0.5em var(--read-shadow),
    0 1.5em 2.8em -1.3em var(--read-shadow-far);
}
.item[data-read] .title {
  color: var(--read-title);
}
.item[data-read] .text,
.item[data-read] .time {
  color: var(--read-text);
}
.disc::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(180deg, var(--read-disc-top), var(--read-disc-low));
  box-shadow: inset 0 0 0 1.5px var(--disc-rim);
  opacity: 0;
}
.item[data-read] .disc::before {
  animation: appear 0.2s ease-out both;
}
.item[data-read] .glyph {
  animation: vanish 0.16s ease-in both;
}
.item[data-read] .tick {
  animation: tick 0.42s var(--bounce) 0.06s both;
}

.copy {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 0.18em;
  min-width: 0;
}
.title,
.text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.title {
  font-size: 1.02em;
  font-weight: 600;
  letter-spacing: -0.012em;
  color: var(--ink-title);
}
.text {
  font-size: 0.88em;
  color: var(--ink-muted);
}
.time {
  flex: none;
  align-self: flex-start;
  margin-top: 1.45em;
  font-size: 0.76em;
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}

/* None left: a quiet pill of its own, not a button. */
.empty {
  --tint: #f1faf3;
  --tint-deep: #d3efdc;
  --hue: #2a9853;
  display: flex;
  align-items: center;
  gap: 0.85em;
  height: var(--pill);
  margin: 0;
  padding: 0 1.4em 0 calc((var(--pill) - var(--disc)) / 2);
  border-radius: 999px;
  background: linear-gradient(180deg, var(--empty-top), var(--empty-low));
  box-shadow:
    inset 0 0 0 1.5px var(--empty-rim),
    0 0.5em 1.4em -0.8em var(--empty-shadow);
  /* After the last pill has flown. */
  animation: appear 0.4s ease 0.5s both;
}
@supports (color: light-dark(#fff, #000)) {
  /* None left: a quiet pill of its own, not a button. */
  .empty {
    --tint: light-dark(#f1faf3, #213d2c);
    --tint-deep: light-dark(#d3efdc, #1a3123);
    --hue: light-dark(#2a9853, #5fd38a);
  }
}

/* The bell, on a soft glow, lined up under the pills' icons; on its right, a notification on
   demand. */
.bellRow {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1em;
  margin-top: 1.5em;
  padding: 0 0.5em 0 calc((var(--pill) - var(--bell)) / 2);
}
.bellRow::before {
  content: '';
  position: absolute;
  /* Under everything: the pills that fly into the bell never pass under it, so it never has to be
     composited with them. */
  z-index: -1;
  top: 50%;
  left: calc(var(--pill) / 2);
  width: 12em;
  height: 12em;
  margin: -6em 0 0 -6em;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--bell-glow), rgb(255 255 255 / 0));
  pointer-events: none;
}
.bell {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--bell);
  height: var(--bell);
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: linear-gradient(
    180deg,
    var(--bell-top) 0%,
    var(--bell-mid) 60%,
    var(--bell-low) 100%
  );
  box-shadow:
    inset 0 0 0 1.5px var(--bell-rim),
    0 1px 2px var(--bell-shadow-near),
    0 0.7em 1.5em -0.5em var(--bell-shadow),
    0 1.6em 3em -1.4em var(--bell-shadow-far);
  color: var(--ink-bell);
  cursor: pointer;
  transition:
    translate 0.3s var(--ease-out),
    scale 0.2s var(--ease-out);
}
@media (hover: hover) {
  .bell:hover {
    translate: 0 -0.08em;
  }
}
.bell:active {
  scale: 0.95;
}
.bellIcon {
  width: 1.9em;
  height: 1.9em;
  transform-origin: 50% 15%;
}

/* The count: red, with a rim cut out of what is behind it. */
.badge {
  position: absolute;
  top: -0.2em;
  right: -0.3em;
  display: grid;
  place-items: center;
  min-width: 1.8em;
  height: 1.8em;
  padding: 0 0.45em;
  overflow: hidden;
  border-radius: 999px;
  background: linear-gradient(180deg, var(--accent-top), var(--accent));
  box-shadow:
    0 0 0 0.15em var(--badge-rim),
    0 0.3em 0.7em -0.15em var(--badge-glow);
  font-size: 0.8em;
  font-weight: 700;
  line-height: 1;
  color: var(--accent-ink);
  font-variant-numeric: tabular-nums;
  transition:
    transform 0.45s var(--bounce),
    opacity 0.2s ease;
}
.badge[data-empty] {
  transform: scale(0);
  opacity: 0;
  transition:
    transform 0.25s ease-in,
    opacity 0.2s ease 0.05s;
}
.figure {
  display: block;
}

/* The copies of pills on their way out, over the list: placed from the stage's top left. */
.ghosts {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  pointer-events: none;
}

/* A notification on demand: a small glass pill, quieter than the bell. */
.send {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  height: 2.5em;
  padding: 0 1em 0 0.7em;
  border: 0;
  border-radius: 999px;
  background: linear-gradient(180deg, var(--send-top), var(--send-low));
  box-shadow:
    inset 0 0 0 1px var(--send-rim),
    0 0.3em 0.8em -0.4em var(--send-shadow);
  font: inherit;
  font-size: 0.82em;
  font-weight: 500;
  color: var(--ink-send);
  cursor: pointer;
  transition:
    color 0.2s ease,
    translate 0.3s var(--ease-out),
    scale 0.2s var(--ease-out);
}
.send svg {
  width: 1.3em;
  height: 1.3em;
}
@media (hover: hover) {
  .send:hover {
    translate: 0 -0.08em;
    color: var(--ink);
  }
}
.send:active {
  scale: 0.96;
}

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

@keyframes appear {
  from {
    opacity: 0;
  }
}
@keyframes vanish {
  to {
    opacity: 0;
    transform: scale(0.5);
  }
}
@keyframes tick {
  from {
    opacity: 0;
    transform: scale(0.4);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@container (max-width: 460px) {
  .stage {
    font-size: 14px;
    padding: 1.6em 0.9em 2.2em;
  }
}
@container (max-width: 300px) {
  .stage {
    font-size: 13px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .head,
  .pill,
  .pill::after,
  .bell,
  .badge,
  .markAll,
  .send {
    transition: none;
  }
  .empty {
    animation: none;
  }
  .item[data-read] .disc::before,
  .item[data-read] .glyph,
  .item[data-read] .tick {
    animation: none;
  }
  .item[data-read] .disc::before,
  .item[data-read] .tick {
    opacity: 1;
  }
  .item[data-read] .glyph {
    opacity: 0;
  }
}
```

### notification-pills/motion.ts

```tsx
/*
 * The notifications' motion, on transform, opacity and a touch of blur, with the Web Animations
 * API. Every animation ends and leaves nothing behind, so nothing runs at rest.
 *
 * - `spring`: the response of a damped spring, sampled into a CSS linear() easing.
 * - `flip`: the pills that change place play from where they were to where they are (FLIP).
 * - `rise` and `fold`: the pills fly out of the bell to their places, and back into it.
 * - `dropIn`: a new pill drops into its place at the top.
 * - `leave`: a copy of a pill flies off to the right, over the list, while the list closes the gap.
 * - `ring` and `bump`: the bell swings, the badge jumps.
 */

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

/** Screen pixels to an element's own: 1, unless it sits in a scaled container. */
export const scaleOf = (element: HTMLElement) =>
  element.getBoundingClientRect().width / element.offsetWidth || 1;

const springs = new Map<number, string>();

/**
 * A damped spring from 0 to 1, as an easing. `damping` is between 0 and 1: 0.6 overshoots by
 * about 9%, 0.8 by 1.5%. The spring settles (to 1%) at the end of the animation, whatever its
 * duration. Browsers without linear() get a cubic-bezier that overshoots a little.
 */
export function spring(damping: number) {
  let easing = springs.get(damping);
  if (easing) return easing;
  if (CSS.supports('animation-timing-function', 'linear(0, 1)')) {
    // x(t) = 1 − e^(−ζωt) (cos ω′t + ζω/ω′ sin ω′t), with e^(−ζω) = 1%.
    const w = 4.6 / damping;
    const wd = w * Math.sqrt(1 - damping ** 2);
    const points: number[] = [];
    for (let i = 0; i < 48; i++) {
      const t = i / 48;
      const decay = Math.exp(-damping * w * t);
      points.push(
        +(1 - decay * (Math.cos(wd * t) + ((damping * w) / wd) * Math.sin(wd * t))).toFixed(4),
      );
    }
    easing = `linear(${points.join(', ')}, 1)`;
  } else {
    easing = 'cubic-bezier(0.34, 1.4, 0.64, 1)';
  }
  springs.set(damping, easing);
  return easing;
}

/** Where each element is on screen now, mid-flight included. */
export function measure(elements: Iterable<HTMLElement>) {
  const rects = new Map<HTMLElement, DOMRect>();
  for (const element of elements) rects.set(element, element.getBoundingClientRect());
  return rects;
}

/**
 * FLIP: each element measured `before` a change plays from where it was to where the change put
 * it, after `delay` ms. A move still running is replaced, from where it had got to.
 */
export function flip(before: Map<HTMLElement, DOMRect>, scale: number, delay = 0) {
  const moved = [...before].filter(([element]) => element.isConnected);
  for (const [element] of moved) {
    for (const running of element.getAnimations()) if (running.id === 'flip') running.cancel();
  }
  for (const [element, from] of moved) {
    const to = element.getBoundingClientRect();
    // Not on screen before or after: nothing to play.
    if (!from.width || !to.width) continue;
    const dx = (from.left - to.left) / scale;
    const dy = (from.top - to.top) / scale;
    if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
    element.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
      id: 'flip',
      duration: 620,
      delay,
      easing: spring(0.74),
      fill: 'backwards',
    });
  }
}

/** How small a pill is inside the bell. */
const IN_BELL = 0.18;
/** Each pill leaves the bell this long after the one below it, in ms... */
const RISE_STAGGER = 55;
/** ...and goes back in this long after the one above it. */
const FOLD_STAGGER = 34;

type Look = { transform: string; opacity: string; filter: string };

/**
 * Stops the flights in progress: returns each element's look where its flight had got to (or
 * null, when it was not flying), so the next flight starts from there.
 */
function interrupt(elements: HTMLElement[]) {
  const looks = elements.map((element): Look | null => {
    const flying = element.getAnimations().some((running) => running.playState === 'running');
    if (!flying) return null;
    const { transform, opacity, filter } = getComputedStyle(element);
    return { transform, opacity, filter };
  });
  for (const element of elements) for (const running of element.getAnimations()) running.cancel();
  return looks;
}

/**
 * Where each element sits in the bell: moved so that its pivot (its transform-origin, the centre
 * of the pill's round left end, where the icon is) is on the bell's centre, and shrunk.
 */
function inBell(elements: HTMLElement[], bell: HTMLElement, scale: number) {
  const box = bell.getBoundingClientRect();
  const x = box.left + box.width / 2;
  const y = box.top + box.height / 2;
  return elements.map((element) => {
    const { left, top, height } = element.getBoundingClientRect();
    const dx = (x - (left + height / 2)) / scale;
    const dy = (y - (top + height / 2)) / scale;
    return `translate(${dx}px, ${dy}px) scale(${IN_BELL})`;
  });
}

/**
 * The pills fly out of the bell to their places, the nearest first: small and blurred as they
 * leave it, full size on a spring. Pills on their way into the bell turn round where they are.
 * The heading fades in once they are out.
 */
export function rise(
  pills: HTMLElement[],
  heading: HTMLElement | null,
  bell: HTMLElement,
  scale: number,
) {
  const looks = interrupt(pills);
  const starts = inBell(pills, bell, scale);
  pills.forEach((pill, i) => {
    const look = looks[i];
    const delay = look ? 0 : (pills.length - 1 - i) * RISE_STAGGER;
    pill.animate([{ transform: look?.transform ?? starts[i] }, { transform: 'none' }], {
      duration: 780,
      delay,
      easing: spring(0.62),
      fill: 'backwards',
    });
    pill.animate(
      [
        { opacity: look?.opacity ?? 0, filter: look?.filter ?? 'blur(6px)' },
        { opacity: 1, filter: 'blur(0px)' },
      ],
      { duration: 380, delay, easing: 'cubic-bezier(0.2, 0.6, 0.35, 1)', fill: 'backwards' },
    );
  });
  heading?.animate([{ opacity: 0 }, { opacity: 1 }], {
    duration: 320,
    delay: pills.length * RISE_STAGGER + 140,
    easing: 'ease',
    fill: 'backwards',
  });
}

/**
 * The pills fold back into the bell, the farthest first, with a little lift before they dive.
 * They stay there (`fill`) until the list is hidden; the promise resolves when all are in.
 */
export function fold(pills: HTMLElement[], bell: HTMLElement, scale: number) {
  const looks = interrupt(pills);
  const ends = inBell(pills, bell, scale);
  const flights = pills.map((pill, i) => {
    const look = looks[i];
    const delay = look ? 0 : i * FOLD_STAGGER;
    pill.animate(
      [
        { opacity: look?.opacity ?? 1, filter: look?.filter ?? 'blur(0px)' },
        { opacity: 0, filter: 'blur(4px)' },
      ],
      { duration: 240, delay: delay + 200, easing: 'ease-in', fill: 'both' },
    );
    return pill.animate([{ transform: look?.transform ?? 'none' }, { transform: ends[i] }], {
      duration: 460,
      delay,
      easing: 'cubic-bezier(0.5, -0.25, 0.75, 0.3)',
      fill: 'both',
    });
  });
  return Promise.all(flights.map((flight) => flight.finished)).then(() => undefined);
}

/** A new pill drops into its place, from a little above. */
export function dropIn(pill: HTMLElement) {
  pill.animate([{ transform: 'translateY(-1.4em) scale(0.94)' }, { transform: 'none' }], {
    duration: 720,
    easing: spring(0.6),
  });
  pill.animate(
    [
      { opacity: 0, filter: 'blur(5px)' },
      { opacity: 1, filter: 'blur(0px)' },
    ],
    { duration: 340, easing: 'ease-out' },
  );
}

/** How long a read pill shows its tick before it leaves, in ms. */
export const TICK = 600;

/**
 * A pill leaves: a copy of it takes its place in `layer`, over the list, and flies off to the
 * right while the list, without it, closes the gap. A pill that was read shows a tick first (the
 * CSS plays it on `data-read`), for `TICK` ms. A swipe goes on at the speed it was let go at; a
 * key or a press pushes the pill off from rest. `layer` is an empty box at the stage's top left:
 * at 0 × 0 it overlaps nothing, so the compositor never has to promote it.
 */
export function leave(
  item: HTMLElement,
  layer: HTMLElement,
  { read = false, speed = 0, scale = 1 }: { read?: boolean; speed?: number; scale?: number },
) {
  const box = item.getBoundingClientRect();
  const origin = layer.getBoundingClientRect();
  const width = box.width / scale;
  const copy = item.cloneNode(true) as HTMLElement;
  Object.assign(copy.style, {
    position: 'absolute',
    left: `${(box.left - origin.left) / scale}px`,
    top: `${(box.top - origin.top) / scale}px`,
    width: `${width}px`,
  });
  // The copy starts where the pill is seen: mid-swipe, springing back, or lifted by the pointer.
  const pill = item.querySelector('button');
  const pillCopy = copy.querySelector('button');
  if (pill && pillCopy) {
    const seen = getComputedStyle(pill);
    for (const property of ['transform', 'translate', 'scale', 'opacity'] as const) {
      pillCopy.style[property] = seen[property];
    }
  }
  if (read) copy.dataset.read = '';
  layer.append(copy);
  if (reducedMotion()) {
    window.setTimeout(() => copy.remove(), read ? TICK : 0);
    return;
  }
  // Its own width and a bit more: out of the stage, or faded out by then.
  const distance = width * 1.15;
  const fling = speed > 0.3;
  const flight = copy.animate(
    [
      { transform: 'none', opacity: 1 },
      { transform: `translateX(${distance}px) rotate(4deg)`, opacity: 0 },
    ],
    {
      duration: fling ? Math.min(420, Math.max(200, distance / speed)) : 440,
      delay: read ? TICK : 0,
      easing: fling ? 'cubic-bezier(0.25, 0.6, 0.5, 1)' : 'cubic-bezier(0.55, 0, 0.75, 0.4)',
      fill: 'both',
    },
  );
  flight.finished.then(
    () => copy.remove(),
    () => copy.remove(),
  );
}

/** The bell swings on its hook. */
export function ring(icon: Element) {
  icon.animate(
    [0, 16, -12, 8, -4, 0].map((angle) => ({ transform: `rotate(${angle}deg)` })),
    { duration: 720, easing: 'ease-out' },
  );
}

/** The badge jumps when the count goes up and dips when it goes down; the new figure slides in. */
export function bump(badge: HTMLElement, figure: HTMLElement, up: boolean) {
  badge.animate(
    [
      { transform: 'scale(1)', easing: 'cubic-bezier(0.3, 0, 0.6, 1)' },
      { transform: `scale(${up ? 1.3 : 0.82})`, offset: 0.2, easing: spring(0.5) },
      { transform: 'scale(1)' },
    ],
    { duration: 640 },
  );
  figure.animate(
    [
      { transform: `translateY(${up ? 65 : -65}%)`, opacity: 0 },
      { transform: 'none', opacity: 1 },
    ],
    { duration: 320, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
  );
}
```

### notification-pills/swipe.ts

```tsx
'use client';

import { useRef, type PointerEvent } from 'react';

/*
 * Swipe to dismiss. A pill follows the pointer to the right (to the left it resists); let go far
 * enough or fast enough and it is dismissed, otherwise it springs back (a CSS transition). Only
 * sideways moves are taken: the pills have `touch-action: pan-y`, so a phone still scrolls the
 * page from them. The pill element carries the notification's id in `data-id`.
 */

/** How far a press travels, in px, before it turns into a swipe (or a scroll). */
const SLOP = 6;
/** A swipe dismisses past this share of the pill's width... */
const DISTANCE = 0.32;
/** ...or when let go this fast, in px per ms. */
const SPEED = 0.45;

type Swipe = {
  pointerId: number;
  startX: number;
  startY: number;
  /** How far the pill has moved, in its own px. */
  dx: number;
  /** In px per ms, smoothed over the last moves. */
  speed: number;
  time: number;
  /** Screen pixels to the pill's own, should it sit in a scaled container. */
  scale: number;
  active: boolean;
};

export function useSwipe(onSwipe: (id: string, speed: number) => void) {
  const swipe = useRef<Swipe | null>(null);
  /** The click that ends a swipe is not a press. */
  const swiped = useRef(false);

  const springBack = (pill: HTMLElement) => {
    delete pill.dataset.swiping;
    pill.style.transform = '';
    pill.style.opacity = '';
  };

  const end = (event: PointerEvent<HTMLElement>, cancelled: boolean) => {
    const current = swipe.current;
    if (!current || current.pointerId !== event.pointerId) return;
    swipe.current = null;
    if (!current.active) return;
    swiped.current = true;
    const pill = event.currentTarget;
    // A pointer that stopped before letting go has no speed left.
    const speed = event.timeStamp - current.time > 90 ? 0 : current.speed;
    const far = current.dx > pill.offsetWidth * DISTANCE;
    const fast = speed > SPEED && current.dx > 24;
    if (!cancelled && (far || fast) && pill.dataset.id) onSwipe(pill.dataset.id, speed);
    else springBack(pill);
  };

  const handlers = {
    onPointerDown(event: PointerEvent<HTMLElement>) {
      swiped.current = false;
      if (event.button !== 0 || swipe.current) return;
      const pill = event.currentTarget;
      swipe.current = {
        pointerId: event.pointerId,
        startX: event.clientX,
        startY: event.clientY,
        dx: 0,
        speed: 0,
        time: event.timeStamp,
        scale: pill.getBoundingClientRect().width / pill.offsetWidth || 1,
        active: false,
      };
    },
    onPointerMove(event: PointerEvent<HTMLElement>) {
      const current = swipe.current;
      if (!current || current.pointerId !== event.pointerId) return;
      const pill = event.currentTarget;
      const dx = (event.clientX - current.startX) / current.scale;
      const dy = (event.clientY - current.startY) / current.scale;
      if (!current.active) {
        // Mostly up or down: a scroll, or nothing; the pill stays put.
        if (Math.abs(dy) > SLOP && Math.abs(dy) > Math.abs(dx)) swipe.current = null;
        if (Math.abs(dx) < SLOP || !swipe.current) return;
        current.active = true;
        pill.setPointerCapture(event.pointerId);
        pill.dataset.swiping = '';
      }
      const elapsed = Math.max(1, event.timeStamp - current.time);
      current.speed = current.speed * 0.3 + ((dx - current.dx) / elapsed) * 0.7;
      current.time = event.timeStamp;
      current.dx = dx;
      // To the left the pill resists: it moves a quarter of the way.
      const x = dx > 0 ? dx : dx / 4;
      pill.style.transform = `translate3d(${x}px, 0, 0) rotate(${x * 0.012}deg)`;
      pill.style.opacity = String(1 - Math.min(0.45, Math.max(0, x) / pill.offsetWidth));
    },
    onPointerUp(event: PointerEvent<HTMLElement>) {
      end(event, false);
    },
    onPointerCancel(event: PointerEvent<HTMLElement>) {
      end(event, true);
    },
  };

  return { handlers, swiped };
}
```

### notification-pills/notices.ts

```tsx
/*
 * What a notification holds, and the samples the demo starts with and delivers. Each kind has its
 * icon (icons.tsx) and its pastel colour (the CSS, by `data-kind`).
 */

export type Kind = 'comment' | 'reminder' | 'system' | 'mention';

export type Notice = {
  id: string;
  kind: Kind;
  title: string;
  /** One line under the title. */
  text: string;
  /** How long ago it arrived, in minutes; just now when left out. */
  minutes?: number;
};

/** The time on a pill ("now", "4m", "2h") and the way a screen reader says it. */
export function ago(minutes = 0) {
  if (minutes < 1) return { short: 'now', long: 'just now' };
  if (minutes < 60) {
    return { short: `${minutes}m`, long: `${minutes} minute${minutes === 1 ? '' : 's'} ago` };
  }
  const hours = Math.round(minutes / 60);
  return { short: `${hours}h`, long: `${hours} hour${hours === 1 ? '' : 's'} ago` };
}

/** The list at first, newest first. */
export const NOTICES: Notice[] = [
  {
    id: 'reply',
    kind: 'comment',
    title: 'New reply',
    text: 'Maya: “Let’s keep the blue.”',
  },
  {
    id: 'review',
    kind: 'reminder',
    title: 'Design review',
    text: 'Starts in 10 minutes',
    minutes: 4,
  },
  {
    id: 'sync',
    kind: 'system',
    title: 'Sync complete',
    text: 'All three devices are up to date',
    minutes: 18,
  },
  {
    id: 'checklist',
    kind: 'mention',
    title: 'You were mentioned',
    text: 'In the launch checklist',
    minutes: 60,
  },
];

/** What comes in, one at a time, in turn. */
export const INCOMING: Notice[] = [
  {
    id: 'comments',
    kind: 'comment',
    title: '2 new comments',
    text: 'On “Homepage, round 3”',
  },
  {
    id: 'focus',
    kind: 'reminder',
    title: 'Focus time is over',
    text: 'Stretch, then back at it',
  },
  {
    id: 'backup',
    kind: 'system',
    title: 'Backup finished',
    text: '1,284 photos are safe',
  },
  {
    id: 'copy',
    kind: 'mention',
    title: 'Leo mentioned you',
    text: '“Can you check the copy?”',
  },
  {
    id: 'lunch',
    kind: 'comment',
    title: 'New message',
    text: 'Ana: “Lunch at one?”',
  },
  {
    id: 'standup',
    kind: 'reminder',
    title: 'Stand-up',
    text: 'In 5 minutes, room 2',
  },
  {
    id: 'update',
    kind: 'system',
    title: 'Update installed',
    text: 'Version 4.2 is ready to use',
  },
  {
    id: 'roadmap',
    kind: 'mention',
    title: 'Mentioned in Roadmap',
    text: 'Sam replied to your note',
  },
];
```

### notification-pills/icons.tsx

```tsx
import type { JSX, SVGProps } from 'react';
import type { Kind } from './notices';

/*
 * The bell and the icons of the four kinds of notification, drawn on a 24 × 24 grid with 1.75px
 * strokes in `currentColor`, so they take the colour of the text around them.
 */

type IconProps = SVGProps<SVGSVGElement>;

function Icon({ children, ...props }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      width="24"
      height="24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      {children}
    </svg>
  );
}

export function BellIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M12 3.6a5.3 5.3 0 0 0-5.3 5.3v2.9c0 1-.3 2-.9 2.8l-.8 1.1c-.4.6 0 1.4.7 1.4h12.6c.7 0 1.1-.8.7-1.4l-.8-1.1a4.9 4.9 0 0 1-.9-2.8V8.9A5.3 5.3 0 0 0 12 3.6Z" />
      <path d="M9.9 19.6a2.2 2.2 0 0 0 4.2 0" />
    </Icon>
  );
}

/** A speech bubble. */
function CommentIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M12 4.75c4.3 0 7.5 2.85 7.5 6.5s-3.2 6.5-7.5 6.5c-.95 0-1.85-.13-2.7-.4L5.5 19l.95-3.3C5.2 14.5 4.5 12.95 4.5 11.25c0-3.65 3.2-6.5 7.5-6.5Z" />
    </Icon>
  );
}

/** An alarm clock. */
function ReminderIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="12" cy="13" r="6.5" />
      <path d="M12 9.75V13l2.1 1.4M4.25 6.6l2.6-2.35M19.75 6.6l-2.6-2.35M7.6 18.1 6.3 19.6M16.4 18.1l1.3 1.5" />
    </Icon>
  );
}

/** A cog with eight teeth. */
function SystemIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M10.52 5.57 10.48 3.38a8.75 8.75 0 0 1 3.04 0l-.04 2.19a6.6 6.6 0 0 1 2.02.83l1.52-1.57a8.75 8.75 0 0 1 2.15 2.15L17.6 8.5a6.6 6.6 0 0 1 .83 2.02l2.19-.04a8.75 8.75 0 0 1 0 3.04l-2.19-.04a6.6 6.6 0 0 1-.83 2.02l1.57 1.52a8.75 8.75 0 0 1-2.15 2.15L15.5 17.6a6.6 6.6 0 0 1-2.02.83l.04 2.19a8.75 8.75 0 0 1-3.04 0l.04-2.19a6.6 6.6 0 0 1-2.02-.83l-1.52 1.57a8.75 8.75 0 0 1-2.15-2.15L6.4 15.5a6.6 6.6 0 0 1-.83-2.02l-2.19.04a8.75 8.75 0 0 1 0-3.04l2.19.04A6.6 6.6 0 0 1 6.4 8.5L4.83 6.98a8.75 8.75 0 0 1 2.15-2.15L8.5 6.4a6.6 6.6 0 0 1 2.02-.83Z" />
      <circle cx="12" cy="12" r="2.6" />
    </Icon>
  );
}

/** An at sign. */
function MentionIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="12" cy="12" r="3.4" />
      <path d="M15.4 12v1.3a2.3 2.3 0 0 0 4.6 0V12a8 8 0 1 0-3.1 6.3" />
    </Icon>
  );
}

const KIND_ICONS = {
  comment: CommentIcon,
  reminder: ReminderIcon,
  system: SystemIcon,
  mention: MentionIcon,
} satisfies Record<Kind, (props: IconProps) => JSX.Element>;

export function KindIcon({ kind, ...props }: IconProps & { kind: Kind }) {
  const Glyph = KIND_ICONS[kind];
  return <Glyph {...props} />;
}

/** A tick: a notification read, or none left. */
export function CheckIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="m7.5 12.4 3 3 6-6.3" />
    </Icon>
  );
}

export function PlusIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M12 6.5v11M6.5 12h11" />
    </Icon>
  );
}
```

### notification-pills/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```

### notification-pills/success-sound.ts

```tsx
/* A soft ascending confirmation chime, made only after the reader confirms a notification.
   No file, autoplay audio or permanent audio thread: suspend at rest and close on unmount. */

export function successSound() {
  let context: AudioContext | null = null;
  let rest = 0;
  let disposed = false;

  return {
    play() {
      if (disposed || typeof AudioContext === 'undefined') return;
      try {
        context ??= new AudioContext();
        const audio = context;
        if (audio.state === 'suspended') void audio.resume().catch(() => {});
        window.clearTimeout(rest);
        const start = audio.currentTime + 0.005;
        [660, 880].forEach((frequency, index) => {
          const at = start + index * 0.11;
          const tone = audio.createOscillator();
          const gain = audio.createGain();
          tone.type = 'sine';
          tone.frequency.setValueAtTime(frequency, at);
          gain.gain.setValueAtTime(0, at);
          gain.gain.linearRampToValueAtTime(index ? 0.055 : 0.045, at + 0.012);
          gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.22);
          tone.connect(gain).connect(audio.destination);
          tone.start(at);
          tone.stop(at + 0.24);
          tone.onended = () => {
            tone.disconnect();
            gain.disconnect();
          };
        });
        rest = window.setTimeout(() => {
          if (audio.state === 'running') void audio.suspend().catch(() => {});
        }, 1000);
      } catch {
        // Sound is optional; confirming still works where audio is unavailable.
      }
    },
    dispose() {
      disposed = true;
      window.clearTimeout(rest);
      if (context && context.state !== 'closed') void context.close().catch(() => {});
      context = null;
    },
  };
}

export type SuccessSound = ReturnType<typeof successSound>;
```