# Toast stack

> Notifications that pile up like a deck of cards and fan out into a list when you reach for them.

A toaster for an app’s notifications. New toasts spring in from below and stack up, each one behind a little smaller, higher and fainter; hover or tab into the stack and it fans out into a list, with every timer on hold. Swipe a toast sideways to send it away, or let its time run out.

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

Category: Feedback.

## Anatomy

1. **Stage**: The area the toasts live in, light or dark, tall enough for the stack fanned out to its limit.
2. **Toolbar**: Add a toast, clear them all, and a switch between the light and dark stage. Clear dims when there is nothing to clear.
3. **Front toast**: The newest, in front and full size. Swipe it sideways past a third of its width, or flick it, and it flies off.
4. **Kind tile**: Success, error, info or warning, in the kind’s accent, which also tints the toast’s corner.
5. **Title and description**: A title on one line and up to two lines under it, so every toast is the same height.
6. **Close**: Dismisses the toast; keyboard focus moves on to its neighbour, or to Add.
7. **Timer**: A hairline along the bottom edge that runs down with the time left. It waits while the stack is open or off screen.
8. **Toasts behind**: Each one a peek higher, 5% smaller and fainter; only the card shows. A fourth waits out of sight.

## When to use

- Short news about something that just happened in the background: saved, sent, uploaded, failed.
- Messages that may arrive in bursts, where a pile reads better than a column taking over the screen.
- Feedback that needs no answer and can go away on its own after a few seconds.
- Status worth a glance but not a stop: the reader hovers the stack to read the older ones.

## When not to use

- Errors the person must fix before going on. Show them inline, next to the field or the action.
- Anything that needs a decision. Use a dialog or a confirmation with the action named on its button.
- Information the reader will want later: toasts leave. Keep it in an inbox or an activity list.
- Long messages or links to act on: a toast holds a title and two lines, and its timer may run out first.
- A single, permanent notice about the page. Use a banner at the top instead.

## Installation

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

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

// The stage, its toolbar and the stack, with a demo that plays while it is on screen.
// The sample toasts are in samples.ts: change them there.
export default function Notifications() {
  return <ToastStack duration={6000} limit={4} />;
}
```

## States

- **Stacked**: Three toasts, the newest in front. Only the front one shows its words.
- **Fanned out**: Hover or tab into the stack: every toast gets a row of its own, and the timers wait.
- **One toast**: Alone on the stage, with nothing behind it.
- **Swiping**: The toast follows the pointer and fades as it goes; short of a third of its width it springs back.
- **Empty**: No notifications: a quiet line where the stack was, and Clear dimmed.
- **Dark stage**: Set by `colorScheme` or the switch; without either, the stage follows the page.

## How it works

- Every toast is the same height, so its place in the stack is all the stylesheet needs. Each toast gets its index as a custom property, --i, and the CSS turns it into a transform: stacked (a peek higher, 5% smaller, fainter) or fanned out (a row each). Nothing is measured, and the server’s frame is already in place.
- Every move is a CSS transition of transform and opacity on a spring: a damped oscillation sampled into a linear() easing, with an overshooting cubic-bezier as the fallback. A new toast springs in from below the stage’s edge with @starting-style.
- A toast leaves in two steps. Marked as leaving, it plays its exit from where it stood (a fade, or a flight off the side it was swiped to) while the others close up at once; it is removed when the exit is over.
- Each toast keeps its own timer and what is left of it. Timers wait while the stack is open, off screen or in a hidden tab. The front toast draws its time as a hairline, run by the Web Animations API in step with its timer; the others hold theirs still, so only one animation runs.
- A press that moves sideways becomes a swipe: the toast captures the pointer and follows it, fading. Past a third of its width, or with a flick, it flies off; otherwise it springs back. Vertical moves are left to the page.
- Its light and dark looks are light-dark() tokens, so the stage opens in the page’s color-scheme, or the colorScheme prop, and the toolbar’s switch shows which one it is in. Flipping it is a pick that holds until it is flipped again; defaultTheme makes the same pick from the start.

## API reference

- **duration** (`number`, default `6000`): How long a toast stays, in ms, while nothing pauses it; Infinity keeps it until dismissed.
- **limit** (`number`, default `4`): The most toasts the stack holds; a new one past it sends the oldest away. The stage grows to fit them fanned out.
- **defaultTheme** (`'light' | 'dark'`): The theme to begin with, as if picked on the toolbar’s switch. Without it the stage follows the page’s color-scheme, or colorScheme.
- **defaultToasts** (`readonly ToastContent[]`): The toasts on the stage as it loads, newest first, each a kind, a title and a description; the first three samples without it. Setting it turns the demo off.
- **defaultOpen** (`boolean`, default `false`): Opens fanned out, the timers waiting, until the pointer leaves the stack or a tap lands outside it. Turns the demo off.
- **autoplay** (`boolean`, default `true`): Send a toast every few seconds, fan the stack and flick one away, while visible and untouched.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The toasts are a list labelled “Notifications”. Each one says its kind before its title (“Error: Upload failed”) and has a Dismiss button, so swiping is never the only way to dismiss.
- Tabbing into the stack fans it out. Dismissing a toast from the keyboard hands focus to the next toast, or to Add.
- Toasts added from the toolbar are announced in a polite live region; the demo’s own toasts are not, so it never talks over the page. Timers stop while the stack is open, off screen or in a hidden tab.
- Under reduced motion there is no demo, no spring and no countdown animation: toasts appear and leave at once, and still time out.

## Source

### toast-stack/toast-stack.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type RefObject,
} from 'react';
import { ClearIcon, MoonIcon, PlusIcon, SunIcon } from './icons';
import { KIND_LABELS, SAMPLES, type ToastContent } from './samples';
import { Toast, type Leave } from './toast';
import { useAutoplay } from './use-autoplay';
import styles from './toast-stack.module.css';

/*
 * Toast stack: notifications that pile up at the bottom of a stage like a deck of cards, the
 * newest in front and each one behind a little smaller, higher and fainter. Hover the stack, or
 * tab into it, and it fans out into a list; the timers wait while it is open.
 *
 * - Every toast is the same height, so its place in the stack (`--i`) is all the stylesheet needs
 *   to position it, stacked or fanned out: no measuring, and the server's frame is already right.
 * - Every move is a CSS transition of transform and opacity, on a spring written as a linear()
 *   easing. A new toast springs in from below the stage's edge (@starting-style).
 * - A toast leaves in two steps: marked as leaving, it plays its exit while the others close up;
 *   then it is removed.
 * - Timers run while the stack is closed, on screen and in a visible tab: a toast waits for
 *   someone to see it.
 * - Toasts added from the toolbar are announced to screen readers, politely.
 * - While on screen and untouched, the demo adds a toast every few seconds, now and then fans
 *   the stack out, and once in a while flicks a toast away.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the stage is drawn in the
 *   page's color-scheme (or the colorScheme prop's) from the first frame. The toolbar's switch
 *   shows that scheme, read once mounted, and flipping it sets the stage's own.
 */

type Theme = 'light' | 'dark';

/**
 * The colour scheme `root` is drawn in: the CSS gives it an unseen colour, white in light and
 * black in dark, read once mounted and again whenever the scheme may have changed: an attribute
 * of an ancestor (where pages keep their theme), the system's setting, or `colorScheme`. null
 * until read: the server cannot know it.
 */
function useScheme(root: RefObject<HTMLElement | null>, colorScheme?: string) {
  const [scheme, setScheme] = useState<Theme | null>(null);
  useLayoutEffect(() => {
    const element = root.current;
    if (!element) return;
    const read = () =>
      setScheme(getComputedStyle(element).outlineColor === 'rgb(0, 0, 0)' ? 'dark' : 'light');
    read();
    const ancestors = new MutationObserver(read);
    for (let node = element.parentElement; node; node = node.parentElement) {
      ancestors.observe(node, { attributes: true });
    }
    const system = window.matchMedia('(prefers-color-scheme: dark)');
    system.addEventListener('change', read);
    return () => {
      ancestors.disconnect();
      system.removeEventListener('change', read);
    };
  }, [root, colorScheme]);
  return scheme;
}

type Entry = ToastContent & {
  id: number;
  leaving?: Leave;
  /** Its place in the stack when it started leaving: it plays its exit from there. */
  at?: number;
};

/** How many toasts show while the stack is closed: the front one and two behind it. */
const VISIBLE = 3;
/** How long a toast takes to leave, in ms; then it is removed. */
const EXIT_DURATION = 380;

/** The stage as it loads: the first three samples, the newest first. */
const INITIAL: Entry[] = SAMPLES.slice(0, VISIBLE)
  .map((content, i) => ({ ...content, id: i + 1 }))
  .reverse();

export function ToastStack({
  className = '',
  duration = 6000,
  limit = 4,
  defaultTheme,
  colorScheme,
  autoplay = true,
  defaultToasts,
  defaultOpen = false,
}: {
  /** Extra class for the root, a stage of its own. */
  className?: string;
  /** How long a toast stays, in ms, while nothing pauses it; Infinity keeps it until dismissed. */
  duration?: number;
  /** The most toasts the stack holds: a new one past it sends the oldest away. */
  limit?: number;
  /** The theme to begin with, as if picked on the switch; without it, it follows colorScheme. */
  defaultTheme?: Theme;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Play the demonstration while visible and untouched; false leaves it all to the reader. */
  autoplay?: boolean;
  /**
   * The toasts on the stage as it loads, the newest first (the first three samples without it).
   * Setting it turns the demonstration off.
   */
  defaultToasts?: readonly ToastContent[];
  /**
   * Opens fanned out, the timers waiting, until the pointer leaves the stack or a tap lands
   * outside it. Turns the demonstration off.
   */
  defaultOpen?: boolean;
}) {
  const { ref: root, playing: unattended, take } = useAutoplay<HTMLDivElement>(6000);
  // The demonstration plays only when allowed, on screen and untouched, and not in a set state.
  const playing = autoplay && !defaultToasts && !defaultOpen && unattended;
  const [entries, setEntries] = useState<Entry[]>(() =>
    defaultToasts ? defaultToasts.map((content, i) => ({ ...content, id: i + 1 })) : INITIAL,
  );
  /** The theme picked on the switch; until then, the stage follows its colour scheme. */
  const [picked, setPicked] = useState<Theme | undefined>(defaultTheme);
  const scheme = useScheme(root, colorScheme);
  const theme = picked ?? scheme ?? (colorScheme === 'dark' ? 'dark' : 'light');
  /** What opens the stack: a mouse over it, keyboard focus in it, a tap on it, or the demo. */
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [tapped, setTapped] = useState(false);
  const [fanned, setFanned] = useState(false);
  /** Open as it loads (defaultOpen), until the pointer leaves or a tap lands outside. */
  const [held, setHeld] = useState(defaultOpen);
  /** On screen, in a visible tab: the timers run only then. */
  const [seen, setSeen] = useState(false);
  const [message, setMessage] = useState('');

  const list = useRef<HTMLOListElement>(null);
  const addButton = useRef<HTMLButtonElement>(null);
  const nextId = useRef((defaultToasts ?? INITIAL).length + 1);
  const nextSample = useRef(INITIAL.length);
  const removals = useRef(new Set<number>());

  const open = hovered || focused || tapped || fanned || held;

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

  /** Marks toasts as leaving, so they play their exit while the rest close up; then removes them. */
  const leave = useCallback((ids: ReadonlySet<number>, how: Leave) => {
    setEntries((now) => {
      let index = 0;
      return now.map((entry) => {
        if (entry.leaving) return entry;
        const at = index++;
        return ids.has(entry.id) ? { ...entry, leaving: how, at } : entry;
      });
    });
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const timer = window.setTimeout(
      () => {
        removals.current.delete(timer);
        setEntries((now) => now.filter((entry) => !ids.has(entry.id)));
      },
      reduce ? 0 : EXIT_DURATION,
    );
    removals.current.add(timer);
  }, []);

  /** Adds the next sample toast, in front. */
  const add = useCallback(() => {
    const content = SAMPLES[nextSample.current % SAMPLES.length];
    nextSample.current += 1;
    const id = nextId.current++;
    setEntries((now) => [{ ...content, id }, ...now]);
    return content;
  }, []);

  /** A toast's timer ran out, its close button was pressed, or it was swiped away. */
  const dismiss = useCallback(
    (id: number, how: Leave) => {
      // Keyboard focus in the toast would drop to the page: hand it to a neighbour, or to Add.
      const items = Array.from(
        list.current?.querySelectorAll<HTMLElement>('[data-id]:not([data-leaving])') ?? [],
      );
      const at = items.findIndex((item) => item.dataset.id === String(id));
      if (at >= 0 && items[at].contains(document.activeElement)) {
        const neighbour = items[at + 1] ?? items[at - 1];
        (neighbour?.querySelector('button') ?? addButton.current)?.focus();
      }
      leave(new Set([id]), how);
    },
    [leave],
  );

  const clear = () => {
    const ids = entries.filter((entry) => !entry.leaving).map((entry) => entry.id);
    if (!ids.length) return;
    leave(new Set(ids), 'fade');
    announce('Notifications cleared.');
  };

  // Past the limit, the oldest toasts leave.
  useEffect(() => {
    const staying = entries.filter((entry) => !entry.leaving);
    if (staying.length > limit) {
      leave(new Set(staying.slice(limit).map((entry) => entry.id)), 'fade');
    }
  }, [entries, limit, leave]);

  // Whether anyone can see the stack: on screen, in a visible tab.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setSeen(onScreen && !document.hidden);
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
    };
  }, [root]);

  // The demo: a toast every few seconds; every third turn the stack fans out for a moment, and
  // every fifth the front toast is flicked away, as a swipe sends it. It waits while the reader
  // has the stack open.
  const demo = playing && !hovered && !focused && !tapped;
  useEffect(() => {
    if (!demo) return;
    let alive = true;
    let timer = 0;
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        timer = window.setTimeout(resolve, ms);
      });
    (async () => {
      for (let turn = 1; alive; turn += 1) {
        await wait(turn === 1 ? 1400 : 2400);
        if (!alive) return;
        const staying = list.current?.querySelectorAll<HTMLElement>(
          '[data-id]:not([data-leaving])',
        );
        // A flick never empties the stack: it takes the front toast only when another remains.
        if (turn % 5 === 0 && staying && staying.length > 1) {
          dismiss(Number(staying[0].dataset.id), 'right');
        } else {
          add();
        }
        if (turn % 3 === 0) {
          await wait(1000);
          if (!alive) return;
          setFanned(true);
          await wait(2600);
          if (!alive) return;
          setFanned(false);
        }
      }
    })();
    return () => {
      alive = false;
      window.clearTimeout(timer);
      setFanned(false);
    };
  }, [demo, add, dismiss]);

  // Nothing is left waiting once the stack is gone.
  useEffect(() => {
    const timers = removals.current;
    return () => timers.forEach((timer) => window.clearTimeout(timer));
  }, []);

  // Each toast's place: the ones staying count from 0, in front; the leaving keep theirs.
  let count = 0;
  const rows = entries.map((entry) => ({
    entry,
    index: entry.leaving ? (entry.at ?? 0) : count++,
  }));

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={picked ?? colorScheme}
      onPointerDownCapture={(event) => {
        take();
        // A tap outside the stack closes it.
        if (!list.current?.contains(event.target as Node)) {
          setTapped(false);
          setHeld(false);
        }
      }}
      onKeyDownCapture={take}
    >
      <div data-part="stage" className={styles.stage} style={{ '--limit': limit } as CSSProperties}>
        <div
          data-part="toolbar"
          className={styles.toolbar}
          role="group"
          aria-label="Toast controls"
        >
          <button
            ref={addButton}
            type="button"
            data-part="tool"
            className={styles.tool}
            onClick={() => {
              const content = add();
              announce(`${KIND_LABELS[content.kind]}: ${content.title}. ${content.description}`);
            }}
          >
            <PlusIcon />
            <span>
              Add<span className={styles.long}> toast</span>
            </span>
          </button>
          <button
            type="button"
            data-part="tool"
            className={styles.tool}
            aria-disabled={count === 0 || undefined}
            onClick={clear}
          >
            <ClearIcon />
            <span>
              Clear<span className={styles.long}> all</span>
            </span>
          </button>
          <span className={styles.divider} aria-hidden="true" />
          <button
            type="button"
            role="switch"
            aria-checked={theme === 'dark'}
            aria-label="Dark theme"
            data-part="switch"
            className={styles.switch}
            onClick={() => setPicked(theme === 'dark' ? 'light' : 'dark')}
          >
            <span className={styles.knob} aria-hidden="true">
              <SunIcon className={styles.sun} />
              <MoonIcon className={styles.moon} />
            </span>
          </button>
        </div>

        {count === 0 && (
          <p data-part="empty" className={styles.empty}>
            No notifications
          </p>
        )}

        <ol
          ref={list}
          data-part="stack"
          className={styles.stack}
          aria-label="Notifications"
          data-open={open || undefined}
          onPointerEnter={(event) => {
            if (event.pointerType === 'touch') return;
            setHovered(true);
            take();
          }}
          onPointerLeave={(event) => {
            if (event.pointerType === 'touch') return;
            setHovered(false);
            setHeld(false);
          }}
          onPointerDown={(event) => {
            if (event.pointerType === 'touch') setTapped(true);
          }}
          onFocus={(event) => {
            // Keyboard focus opens the stack; a click on a close button does not keep it open.
            if (event.target.matches(':focus-visible')) setFocused(true);
          }}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
          }}
        >
          {rows.map(({ entry, index }) => (
            <Toast
              key={entry.id}
              id={entry.id}
              content={entry}
              index={index}
              hidden={index >= VISIBLE}
              open={open}
              paused={open || !seen}
              duration={duration}
              leaving={entry.leaving}
              onDismiss={dismiss}
            />
          ))}
        </ol>
      </div>

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

### toast-stack/toast-stack.module.css

```css
/* Toast stack (toast-stack.tsx): a stage, light or dark, with a floating toolbar at the top and
   the toasts at the bottom. A toast is placed by its index alone, `--i` (0 for the newest):
   every toast is the same height, so the stylesheet can work out where each one goes, stacked
   or fanned out. Narrow stages (a container query) give the description a third line.

   Colours are the tokens below, light-dark(light, dark): the stage takes the color-scheme it
   inherits from the page, or the one the colorScheme prop or the toolbar's switch sets
   (data-color-scheme on the root). */

.root {
  container-type: inline-size;
  min-width: 0;
  /* For the script: a colour the root never shows (it has no outline), white in light and black
     in dark, which tells the switch the scheme the root is drawn in. */
  outline-color: #fff;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    outline-color: light-dark(#fff, #000);
  }
}
/* The colorScheme prop, or the reader's pick on the switch. Without either, 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;
}

.stage {
  --stage: #e7e7e8;
  --edge: rgb(0 0 0 / 0.05);
  --surface: #fff;
  --well: #e9e9eb;
  --line: rgb(0 0 0 / 0.08);
  --ink: #1d1d1f;
  --muted: #6e6e73;
  --faint: #8e8e93;
  /* Text straight on the stage (darker than --muted on light, for contrast on the grey). */
  --hint: #616166;
  --hover: rgb(0 0 0 / 0.05);
  /* The lift under the toolbar and the toasts: a hairline, a contact shadow and a soft drop,
     deeper and a little lower on dark (each scheme's drop is transparent in the other). */
  --raise:
    0 0 0 1px rgb(0 0 0 / 0.04), 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 32px -14px rgb(0 0 0 / 0.2),
    0 14px 34px -14px transparent;
  --focus: #0a84ff;
  --toast-width: 360px;
  --toast-height: 82px;
  --gap: 10px;
  /* How far each toast behind shows above the one in front of it. */
  --peek: 10px;
  /* The moves' spring: a curve that overshoots a little, or (below) the real thing. */
  --spring: cubic-bezier(0.25, 1.2, 0.4, 1);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  isolation: isolate;
  overflow: hidden;
  overflow: clip;
  /* Room for the toolbar and the stack fanned out to its limit. */
  min-height: calc(var(--limit, 4) * (var(--toast-height) + var(--gap)) + 112px);
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font:
    400 14px/1.4 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 0.3s ease;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --stage: light-dark(#e7e7e8, #0e0e10);
    --edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --surface: light-dark(#fff, #1a1a1d);
    --well: light-dark(#e9e9eb, #2a2a2e);
    --line: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --ink: light-dark(#1d1d1f, #f2f2f4);
    --muted: light-dark(#6e6e73, #a1a1a9);
    --faint: light-dark(#8e8e93, #6e6e76);
    --hint: light-dark(#616166, #8e8e96);
    --hover: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --raise:
      0 0 0 1px light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.07)),
      0 1px 2px light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.5)),
      0 12px 32px -14px light-dark(rgb(0 0 0 / 0.2), transparent),
      0 14px 34px -14px light-dark(transparent, rgb(0 0 0 / 0.85));
  }
}
/* A damped spring (about 5% overshoot), sampled into an easing, where linear() is supported. */
@supports (transition-timing-function: linear(0, 1)) {
  .stage {
    --spring: linear(
      0,
      0.047,
      0.161,
      0.308,
      0.462,
      0.607,
      0.734,
      0.838,
      0.918,
      0.976,
      1.016,
      1.039,
      1.05,
      1.053,
      1.05,
      1.044,
      1.036,
      1.028,
      1.02,
      1.014,
      1.008,
      1.004,
      1.001,
      0.999,
      0.998,
      0.997,
      0.998,
      0.999,
      1
    );
  }
}

/* The toolbar, floating at the top. */
.toolbar {
  position: absolute;
  top: 14px;
  left: 0;
  right: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  gap: 2px;
  width: max-content;
  margin: 0 auto;
  padding: 4px;
  border-radius: 999px;
  background: var(--surface);
  box-shadow: var(--raise);
  transition:
    background-color 0.3s ease,
    box-shadow 0.3s ease;
}
.tool {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 32px;
  padding: 0 13px 0 10px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  color: inherit;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.tool svg {
  width: 15px;
  height: 15px;
}
.tool:hover {
  background: var(--hover);
}
.tool[aria-disabled='true'] {
  color: var(--faint);
  cursor: default;
}
.tool[aria-disabled='true']:hover {
  background: transparent;
}
.divider {
  width: 1px;
  height: 18px;
  margin: 0 5px;
  background: var(--line);
}

/* The theme switch: a knob that slides from the sun to the moon. */
.switch {
  display: flex;
  align-items: center;
  width: 46px;
  height: 28px;
  margin-right: 2px;
  padding: 3px;
  border: 0;
  border-radius: 999px;
  background: var(--well);
  color: inherit;
  cursor: pointer;
  transition: background-color 0.3s ease;
}
.knob {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.18);
  transition:
    transform 0.32s var(--ease-out),
    background-color 0.3s ease;
}
.switch[aria-checked='true'] .knob {
  transform: translateX(18px);
}
.knob svg {
  grid-area: 1 / 1;
  width: 13px;
  height: 13px;
  transition:
    opacity 0.25s ease,
    transform 0.32s var(--ease-out);
}
.switch[aria-checked='true'] .sun,
.switch[aria-checked='false'] .moon {
  opacity: 0;
  transform: rotate(-60deg) scale(0.6);
}
/* Following the page, the knob is simply where the scheme puts it (also as the page loads, before
   the script has read the scheme); it slides, and its icons turn, when the reader flips it. */
.root:not([data-color-scheme]) .knob,
.root:not([data-color-scheme]) .knob svg {
  transition: none;
}

.tool:focus-visible,
.switch:focus-visible,
.close:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* With the stack empty. */
.empty {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(24px + var(--toast-height) / 2 - 10px);
  margin: 0;
  font-size: 13px;
  color: var(--hint);
  text-align: center;
  transition: opacity 0.4s ease;
}
@starting-style {
  .empty {
    opacity: 0;
  }
}

/* The stack: a box the size of one toast, at the bottom. The toasts sit in it and move out of
   it, up, as the stack fans out. */
.stack {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 24px;
  width: min(var(--toast-width), 100% - 32px);
  height: var(--toast-height);
  margin: 0 auto;
  padding: 0;
  list-style: none;
}

.toast {
  /* Stacked: each toast behind sits a peek higher, 5% smaller and fainter. */
  --y: calc(var(--i) * var(--peek) * -1);
  --scale: calc(1 - var(--i) * 0.05);
  --opacity: calc(1 - var(--i) * 0.16);
  --accent: #1f9d55;
  position: absolute;
  inset: 0;
  z-index: calc(50 - var(--i));
  border-radius: 16px;
  background:
    radial-gradient(
      200px 140% at 0 0,
      color-mix(in srgb, var(--accent) 8%, transparent),
      transparent
    ),
    var(--surface);
  box-shadow: var(--raise);
  opacity: calc(var(--opacity) * var(--swipe-fade, 1));
  transform: translate(var(--swipe, 0px), var(--y)) scale(var(--scale));
  transform-origin: 50% 0;
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
  transition:
    transform 0.66s var(--spring),
    opacity 0.35s ease,
    background-color 0.3s ease,
    box-shadow 0.3s ease;
}
@supports (color: light-dark(#fff, #000)) {
  .toast {
    --accent: light-dark(#1f9d55, #3ecf7f);
  }
}
.toast[data-kind='error'] {
  --accent: #e5484d;
}
@supports (color: light-dark(#fff, #000)) {
  .toast[data-kind='error'] {
    --accent: light-dark(#e5484d, #ff6467);
  }
}
.toast[data-kind='info'] {
  --accent: #2f7cf6;
}
@supports (color: light-dark(#fff, #000)) {
  .toast[data-kind='info'] {
    --accent: light-dark(#2f7cf6, #5c9dff);
  }
}
.toast[data-kind='warning'] {
  --accent: #c97500;
}
@supports (color: light-dark(#fff, #000)) {
  .toast[data-kind='warning'] {
    --accent: light-dark(#c97500, #f7b23b);
  }
}

/* Deeper than the third: out of sight until the stack opens. */
.toast[data-hidden] {
  --opacity: 0;
}

/* Fanned out: each toast in a row of its own, full size. The pseudo-element bridges the gap
   below each one, so the pointer moving between toasts never leaves the stack. */
.stack[data-open] .toast {
  --y: calc(var(--i) * (var(--toast-height) + var(--gap)) * -1);
  --scale: 1;
  --opacity: 1;
}
.toast::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  height: calc(var(--gap) + 1px);
}

/* A new toast springs in from below the stage's edge. */
@starting-style {
  .toast {
    transform: translate(0, calc(var(--toast-height) + 48px));
  }
}

/* Following a swipe: no transition, the toast is where the pointer is, on a layer of its own. */
.toast[data-swiping] {
  transition: none;
  cursor: grabbing;
  will-change: transform;
}

/* Leaving: it fades and sinks a little, or flies off the side it was swiped to. */
.toast[data-leaving] {
  pointer-events: none;
  opacity: 0;
  transition:
    transform 0.34s cubic-bezier(0.4, 0, 0.9, 0.6),
    opacity 0.3s ease;
}
.toast[data-leaving='fade'] {
  transform: translate(0, calc(var(--y) + 14px)) scale(calc(var(--scale) * 0.94));
}
.toast[data-leaving='left'],
.toast[data-leaving='right'] {
  transition:
    transform 0.34s cubic-bezier(0.2, 0.7, 0.3, 1),
    opacity 0.34s ease;
}
.toast[data-leaving='left'] {
  transform: translate(calc(-100% - 48px), var(--y)) scale(var(--scale));
}
.toast[data-leaving='right'] {
  transform: translate(calc(100% + 48px), var(--y)) scale(var(--scale));
}

/* What is on a toast. Behind the front one, only the card shows until the stack opens. */
.content {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: 12px;
  height: 100%;
  padding: 14px 40px 14px 14px;
  transition: opacity 0.3s ease;
}
.stack:not([data-open]) .toast:not([data-front]) .content {
  opacity: 0;
}

.tile {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 10px;
  background: color-mix(in srgb, var(--accent) 13%, var(--surface));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
  color: var(--accent);
}
.tile svg {
  width: 18px;
  height: 18px;
}

.text {
  min-width: 0;
}
.title {
  margin: 0;
  overflow: hidden;
  font-size: 13.5px;
  font-weight: 600;
  line-height: 18px;
  letter-spacing: -0.005em;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.description {
  display: -webkit-box;
  margin: 2px 0 0;
  overflow: hidden;
  font-size: 13px;
  line-height: 17px;
  color: var(--muted);
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}

.close {
  position: absolute;
  top: 8px;
  right: 8px;
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: var(--faint);
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.close svg {
  width: 10px;
  height: 10px;
}
.close:hover {
  background: var(--hover);
  color: var(--ink);
}

/* The time left: a hairline along the bottom edge, scaled by the toast (toast.tsx). */
.timer {
  position: absolute;
  left: 16px;
  right: 16px;
  bottom: 0;
  height: 2px;
  border-radius: 2px;
  background: var(--accent);
  opacity: 0.4;
  transform-origin: 0 50%;
}

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

/* Narrow stages: shorter labels in the toolbar, and room for a third line on each toast. */
@container (max-width: 340px) {
  .long {
    display: none;
  }
}
@container (max-width: 300px) {
  .stage {
    --toast-height: 99px;
  }
  .description {
    -webkit-line-clamp: 3;
    line-clamp: 3;
  }
}

@media (prefers-reduced-motion: reduce) {
  .stage,
  .toolbar,
  .tool,
  .switch,
  .knob,
  .knob svg,
  .empty,
  .toast,
  .toast[data-leaving],
  .toast[data-leaving='left'],
  .toast[data-leaving='right'],
  .content,
  .close {
    transition: none;
  }
  .timer {
    display: none;
  }
}
```

### toast-stack/toast.tsx

```tsx
'use client';

import { useEffect, useRef, type CSSProperties, type PointerEvent } from 'react';
import { CloseIcon, KindIcon } from './icons';
import { KIND_LABELS, type ToastContent } from './samples';
import styles from './toast-stack.module.css';

/*
 * One toast: a tinted icon, a title, a description, a close button, a timer, and a swipe to send
 * it away. Where it sits is the stylesheet's job: the toast only hands on its place in the stack
 * (`--i`, 0 for the newest) and its state, as data attributes.
 *
 * - The timer counts down while `paused` is false and keeps what is left across pauses. A
 *   hairline along the bottom edge shows what is left: on the toast in front it runs down with the
 *   timer (the Web Animations API, started and stopped with it); on the others it holds still,
 *   hidden until the stack opens, so only one animation ever runs.
 * - A press that travels sideways becomes a swipe: the toast follows the pointer (pointer capture),
 *   fading as it goes. Let go past a third of its width, or with a flick, and it flies off that
 *   side; short of that, it springs back. Up and down are left to the page, so a phone still
 *   scrolls (touch-action: pan-y).
 */

/** How a toast leaves: it fades (its timer, its close button, Clear), or flies off a side (a swipe). */
export type Leave = 'fade' | 'left' | 'right';

/** How far a press travels before it turns into a swipe, in px. */
const SWIPE_START = 6;
/** How far a swipe must go to send the toast away, as a share of its width... */
const SWIPE_AWAY = 0.35;
/** ...or how fast, in px per ms. */
const FLICK_SPEED = 0.45;

type Swipe = {
  pointerId: number;
  startX: number;
  startY: number;
  /** The toast's width, in its own px. */
  width: number;
  /** Screen px per the toast's own px, should it sit in a scaled container. */
  scale: number;
  /** Past SWIPE_START: the toast follows the pointer. */
  active: boolean;
  dx: number;
  /** The last move, for the speed at release. */
  lastX: number;
  lastTime: number;
  speed: number;
};

export function Toast({
  id,
  content,
  index,
  hidden,
  open,
  paused,
  duration,
  leaving,
  onDismiss,
}: {
  id: number;
  content: ToastContent;
  /** Its place in the stack: 0 for the newest, in front. */
  index: number;
  /** Too deep in the stack to show until it opens. */
  hidden: boolean;
  /** The stack is fanned out. */
  open: boolean;
  /** The timer waits. */
  paused: boolean;
  /** How long it stays, in ms, while nothing pauses it. */
  duration: number;
  /** On its way out: the stack removes it once its exit has played. */
  leaving?: Leave;
  onDismiss: (id: number, how: Leave) => void;
}) {
  const remaining = useRef(duration);
  const line = useRef<HTMLSpanElement>(null);
  const swipe = useRef<Swipe | null>(null);
  /** The click that ends a swipe is not a press on the close button. */
  const swiped = useRef(false);
  const front = index === 0 && !leaving;

  // The timer: started, stopped and started again as `paused` changes, with what is left. The
  // hairline shows the same: still, or running down with it on the toast in front.
  useEffect(() => {
    const hairline = line.current;
    const left = () => Math.max(0, remaining.current);
    // Infinity: no timer, the toast stays until dismissed, its hairline full.
    const lasting = !Number.isFinite(duration);
    const hold = () =>
      hairline?.style.setProperty('transform', `scaleX(${lasting ? 1 : left() / duration})`);
    hold();
    if (paused || leaving || lasting) return;
    const started = performance.now();
    const timer = window.setTimeout(() => onDismiss(id, 'fade'), left());
    const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const run =
      front && !still
        ? hairline?.animate([{ transform: hairline.style.transform }, { transform: 'scaleX(0)' }], {
            duration: left(),
            fill: 'forwards',
          })
        : undefined;
    return () => {
      window.clearTimeout(timer);
      remaining.current -= performance.now() - started;
      run?.cancel();
      hold();
    };
  }, [paused, leaving, front, id, duration, onDismiss]);

  const onPointerDown = (event: PointerEvent<HTMLLIElement>) => {
    swiped.current = false;
    // Until the stack opens, only the front toast moves: a press on one behind opens the stack.
    if (leaving || event.button !== 0 || (!open && index > 0)) return;
    const width = event.currentTarget.offsetWidth;
    swipe.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      width,
      scale: event.currentTarget.getBoundingClientRect().width / width || 1,
      active: false,
      dx: 0,
      lastX: event.clientX,
      lastTime: event.timeStamp,
      speed: 0,
    };
  };

  const onPointerMove = (event: PointerEvent<HTMLLIElement>) => {
    const current = swipe.current;
    if (!current || current.pointerId !== event.pointerId) return;
    const element = event.currentTarget;
    const dx = (event.clientX - current.startX) / current.scale;
    const dy = (event.clientY - current.startY) / current.scale;
    if (!current.active) {
      // Up or down first: a scroll, not a swipe.
      if (Math.abs(dy) > SWIPE_START && Math.abs(dy) >= Math.abs(dx)) {
        swipe.current = null;
        return;
      }
      if (Math.abs(dx) < SWIPE_START) return;
      current.active = true;
      element.setPointerCapture(event.pointerId);
      element.dataset.swiping = '';
    }
    const elapsed = event.timeStamp - current.lastTime;
    if (elapsed > 0) current.speed = (event.clientX - current.lastX) / current.scale / elapsed;
    current.lastX = event.clientX;
    current.lastTime = event.timeStamp;
    current.dx = dx;
    // Straight to the element: no render per move.
    element.style.setProperty('--swipe', `${dx}px`);
    element.style.setProperty('--swipe-fade', `${Math.max(0, 1 - Math.abs(dx) / current.width)}`);
  };

  /** Back into place, on the stylesheet's spring. */
  const springBack = (element: HTMLElement) => {
    delete element.dataset.swiping;
    element.style.removeProperty('--swipe');
    element.style.removeProperty('--swipe-fade');
  };

  const onPointerUp = (event: PointerEvent<HTMLLIElement>) => {
    const current = swipe.current;
    if (!current || current.pointerId !== event.pointerId) return;
    swipe.current = null;
    if (!current.active) return;
    swiped.current = true;
    // A pause before letting go is no flick.
    const speed = event.timeStamp - current.lastTime > 90 ? 0 : current.speed;
    const far = Math.abs(current.dx) > current.width * SWIPE_AWAY;
    const flick = Math.abs(speed) > FLICK_SPEED && Math.sign(speed) === Math.sign(current.dx);
    if (far || flick) {
      // It flies on from where it is: the exit transition starts from the swiped position.
      delete event.currentTarget.dataset.swiping;
      onDismiss(id, current.dx > 0 ? 'right' : 'left');
    } else {
      springBack(event.currentTarget);
    }
  };

  /** The pointer was taken away (a scroll took over, the window lost focus...). */
  const onPointerCancel = (event: PointerEvent<HTMLLIElement>) => {
    if (swipe.current?.pointerId !== event.pointerId) return;
    swipe.current = null;
    springBack(event.currentTarget);
  };

  return (
    <li
      data-part="toast"
      className={styles.toast}
      style={{ '--i': index } as CSSProperties}
      data-id={id}
      data-kind={content.kind}
      data-front={front || undefined}
      data-hidden={hidden || undefined}
      data-leaving={leaving}
      inert={!!leaving || undefined}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerCancel}
    >
      <div className={styles.content}>
        <span data-part="tile" className={styles.tile}>
          <KindIcon kind={content.kind} />
        </span>
        <div className={styles.text}>
          <p data-part="title" className={styles.title}>
            <span className={styles.hidden}>{KIND_LABELS[content.kind]}: </span>
            {content.title}
          </p>
          <p data-part="description" className={styles.description}>
            {content.description}
          </p>
        </div>
        <button
          type="button"
          data-part="close"
          className={styles.close}
          aria-label={`Dismiss: ${content.title}`}
          onClick={(event) => {
            // Keyboard clicks have no detail: never the end of a swipe.
            if (event.detail > 0 && swiped.current) return;
            onDismiss(id, 'fade');
          }}
        >
          <CloseIcon />
        </button>
        <span ref={line} data-part="timer" className={styles.timer} aria-hidden="true" />
      </div>
    </li>
  );
}
```

### toast-stack/samples.ts

```tsx
/*
 * What a toast says, and the demo's sample toasts. The first three are on the stage when it
 * loads (the third in front); the toolbar's Add button and the demo go on from the fourth, round
 * and round, so the four kinds take turns.
 */

export type ToastKind = 'success' | 'error' | 'info' | 'warning';

export type ToastContent = {
  kind: ToastKind;
  title: string;
  /** Up to two lines: every toast is the same height, which keeps the stack's maths in CSS. */
  description: string;
};

/** How each kind is named to screen readers, before the title. */
export const KIND_LABELS: Record<ToastKind, string> = {
  success: 'Success',
  error: 'Error',
  info: 'Info',
  warning: 'Warning',
};

export const SAMPLES: readonly ToastContent[] = [
  {
    kind: 'warning',
    title: 'Storage almost full',
    description: 'You’ve used 92% of your space. Archive old files soon.',
  },
  {
    kind: 'info',
    title: 'Update available',
    description: 'A new version is ready. Reload to get the latest fixes.',
  },
  {
    kind: 'success',
    title: 'Changes saved',
    description: 'Your edits are safe. Pick up where you left off anytime.',
  },
  {
    kind: 'error',
    title: 'Upload failed',
    description: 'This file is over 25 MB. Try compressing it first.',
  },
  {
    kind: 'warning',
    title: 'Unsaved changes',
    description: 'Leave now and your last few edits will be lost.',
  },
  {
    kind: 'info',
    title: 'Meeting in 10 minutes',
    description: 'The weekly review starts at 3:00 PM in room 4.',
  },
  {
    kind: 'success',
    title: 'Invite sent',
    description: 'Your teammate will get an email with a link to join.',
  },
  {
    kind: 'error',
    title: 'Connection lost',
    description: 'The server didn’t answer. Retrying in a few seconds.',
  },
];
```

### toast-stack/icons.tsx

```tsx
import type { ReactNode } from 'react';
import type { ToastKind } from './samples';

/*
 * The toast stack's icons: a glyph for each kind of toast (drawn in the kind's colour by the
 * stylesheet, on a tile of the same colour, lighter), the close button's cross, and the toolbar's.
 * All decorative: the buttons and toasts around them carry the words.
 */

const GLYPHS: Record<ToastKind, ReactNode> = {
  success: <path d="M5.5 10.4 8.6 13.4 14.5 7" />,
  error: <path d="M6.8 6.8 13.2 13.2M13.2 6.8 6.8 13.2" />,
  info: (
    <>
      <path d="M10 9.4v5" />
      <circle cx="10" cy="6.2" r="1.15" fill="currentColor" stroke="none" />
    </>
  ),
  warning: (
    <>
      <path d="M10 5.6v5" />
      <circle cx="10" cy="14" r="1.15" fill="currentColor" stroke="none" />
    </>
  ),
};

export function KindIcon({ kind }: { kind: ToastKind }) {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      {GLYPHS[kind]}
    </svg>
  );
}

export function CloseIcon() {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <path d="m3 3 6 6M9 3 3 9" />
    </svg>
  );
}

export function PlusIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.6"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <path d="M8 3.25v9.5M3.25 8h9.5" />
    </svg>
  );
}

export function ClearIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.6"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <path d="m4.6 4.6 6.8 6.8M11.4 4.6l-6.8 6.8" />
    </svg>
  );
}

export function SunIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <circle cx="8" cy="8" r="2.6" />
      <path d="M8 1.9v1.3M8 12.8v1.3M14.1 8h-1.3M3.2 8H1.9M12.3 3.7l-.9.9M4.6 11.4l-.9.9M12.3 12.3l-.9-.9M4.6 4.6l-.9-.9" />
    </svg>
  );
}

export function MoonIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M13.2 9.7A5.4 5.4 0 0 1 6.3 2.8a5.4 5.4 0 1 0 6.9 6.9Z" />
    </svg>
  );
}
```

### toast-stack/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 };
}
```