# Game toasts

> A game’s notices at the side of the screen: quest started, quest updated, a tip, an item picked up, each sliding in, holding and fading.

The column of short notices an adventure game shows while you play: a quest that began or moved on, a control just learned with its key phrase in red, a count of something picked up, an item with its icon, each with where to read more in the menu and a small + to open it. They slide in under one another, hold a few seconds (paused while you point at or focus them), fade, and the rest close up. Picking up more of the same thing counts on in one toast. Send each kind from the chips, or press a +.

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

Category: Games · Overlays.

## Anatomy

1. **Column**: The ordered list of toasts, right-aligned, newest at the bottom; 258 u wide (361 px at the 1.4 px unit), the last `max` of them. Escape in it clears it.
2. **Plate**: A 22 u dark plate with round ends, solid at its left and fading to nothing at its right; it carries its own ground, so it reads on any page.
3. **Title**: The toast’s text, 15 u italic medium in warm white, cut with an ellipsis on a one-line plate.
4. **Where line**: Where in the menu to read more (`where`), 13 u italic under the plate’s right side; dark ink on a light page, light with a soft shadow on a dark one.
5. **+ button**: A real button with a 24 u hit area around a 14 u disc: pressing it dips the disc with a ring of light, calls `onOpen`, and the toast leaves.
6. **Tag**: A small yellow word in the gap above the plate (`tag`), such as “Updated”, so a tagged toast keeps the column’s rhythm.
7. **Icon**: The item’s art, 31 u, overlapping the plate’s left end (`icon`); decorative, since the title names the item.
8. **System band**: A `notice`: a wide 58 u band with a thin light rim, apart from the column and centred, holding 2.6 s.

## When to use

- Telling the player, mid-play, that something just changed: a quest began or moved on, a control was learned, an item was picked up.
- Notices the player glances at and keeps playing: each leaves on its own after `hold`, and the column pauses while it is pointed at or focused.
- Pickups that come in bursts: a counter toast with a stable `id` counts on (three coins are one toast that bumps to 3) instead of stacking three.
- Pointing to where to read more: a `where` line with a + button that opens that page of the menu (`onOpen`).
- A one-line system confirmation apart from the column, such as “Progress saved”: a `notice` band in the middle.

## When not to use

- Anything the player must answer or confirm before going on. Use a game modal or a dialog box that holds the game.
- Lines a character speaks. Use a dialog box with the speaker’s name, paced by the player.
- State that must stay on screen, such as coins held or health. Use a HUD counter or meter; a toast leaves.
- Errors and notices in an app or on a website. Use the product’s own toast or an inline message, with plain type and a close button.
- A long list of what happened, to be read later. Use a quest log or a journal page; the column keeps only the last `max`.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/game-toasts.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 { useState } from 'react';
import { GameToasts, type GameToast } from './game-toasts';

let next = 0;

export function QuestNotices() {
  const [toasts, setToasts] = useState<GameToast[]>([]);
  const push = (toast: GameToast) => setToasts((now) => [...now, { id: `n${next++}`, ...toast }]);
  return (
    <>
      <button onClick={() => push({ title: 'The Lantern Keeper', where: 'Journal' })}>
        Start quest
      </button>
      <GameToasts
        bare
        toasts={toasts}
        onDismiss={(id) => setToasts((now) => now.filter((toast) => toast.id !== id))}
        onOpen={(toast) => console.log('open', toast.where)}
      />
    </>
  );
}
```

## States

- **Quest**: A `line`: a title on the plate and, under it, `where="Journal"` with its + button.
- **Updated**: `tag="Updated"`: the yellow word pops in above the plate of a quest that moved on.
- **Item**: `icon="mushroom"`: the art overlaps the plate’s left end and the title moves right to clear it.
- **Tip**: A `tip`: up to three lines on a rounder plate, with `highlight` drawing the phrase it teaches in red.
- **Counter**: A `counter` with `count={3}`: an emblem and a number on a short plate; sending the same `id` again counts on.
- **Notice**: A `notice`: the wide band apart from the column, with no where line, for a system confirmation.
- **Full column**: Four toasts of different kinds stacked, oldest at the top; past `max` the oldest would leave early.
- **Long title**: A one-line title wider than the plate is cut with an ellipsis; the plate never grows past the column.
- **Light**: `colorScheme="light"`, on the light page it is made for: the where line, its disc and the tag in dark ink; the plate stays solid longer.
- **Dark**: `colorScheme="dark"`, on the dark page it is made for: the where line in the game’s light ink with a soft shadow, a light disc, a gold tag.

## How it works

- Each toast is a dark plate that fades to nothing on its right: a gradient from the plate colour, solid for the first 10 %, to transparent at the right end, with round ends. Every size is a multiple of --u, one design unit (a 22 u plate, a 15 u italic title, a 14 u + disc), which the root sets from its own width with a container query unit, up to 1.4 px, so the column scales as one.
- Kinds share that plate: a line (one title, cut with an ellipsis), a tip (up to three lines with one phrase drawn in red), a counter (a short plate with an emblem and a number), and a notice, a wide band with a thin light rim apart from the column. Any toast can carry a small yellow tag in the gap above it (“Updated”), an icon overlapping the plate’s left end, and a where line under its right side with the + button.
- The rows follow the list shown (the last max toasts, and the latest notice). A newcomer’s plate slides 36 u in from the right with a slight overshoot in 300 ms, its where line follows 110 ms later, and its tag and icon pop in. A toast with an id already on screen updates it: a counter’s number remounts and bumps from gold, its emblem glints, and its clock restarts.
- Each toast has a clock of hold ms (2.6 s for a notice). All clocks pause while the pointer or the focus is on the column, keeping what is left, and resume after. When one runs out, the toast is asked away (onDismiss, or the own list), fades out drifting right in 420 ms, and is then removed: the rows under it are measured before and after, and glide up from where they were by FLIP with the Web Animations API.
- The + is a real button: pressing it remounts its disc, which dips and springs back as a ring of light spreads, calls onOpen, and the toast leaves 240 ms later, as a game would open that page.
- The plates carry their own dark ground, so they read on any page. What sits straight on the stage (the where line, its disc, the tag) is dark ink on a light page and the game’s light ink with a soft shadow on a dark one, and on a light page the plate stays solid longer before it fades, so the end of a long title keeps its dark ground, while on a dark page the plate is a step lighter than the page, so its edge still shows. All of it is light-dark() tokens on the root.
- Uncontrolled, it keeps its own list, with chips under it to send each kind, and a demo plays while it is on screen and untouched: a quest, a tip, three coins counted in one toast, an update opened with its +, an item, a notice. Given toasts, it shows those and asks onDismiss to remove one.

## API reference

- **toasts** (`GameToast[]`): The toasts shown, oldest first (controlled). A toast is { id?, kind?, title, highlight?, tag?, where?, icon?, count?, hold? }: kind is line, tip, counter or notice; icon is emblem, mushroom, fruit or crystal. A toast with an id already shown updates it (a counter counts on). Without it, the component keeps its own list, with chips and a demo.
- **onDismiss** (`(id: string) => void`): Asked to remove a toast: its time is up, its + was pressed, or Escape cleared the column. Controlled, remove it from toasts.
- **onOpen** (`(toast: GameToast) => void`): Called when a toast’s + is pressed: open the menu page it names.
- **hold** (`number`, default `4200`): How long a toast stays, in ms, unless it sets its own. A notice holds 2600.
- **max** (`number`, default `5`): How many toasts the column shows; past it, the oldest leaves early.
- **bare** (`boolean`, default `false`): The column and band alone, without the room kept for them and the chips, to lay over a game of your own. Sizes follow --toasts-unit (default up to 1.4 px).
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run of play sends toasts and opens one. Uncontrolled only.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for what sits on the page (where lines, discs, tags, band, chips); by default it follows the page. The plates keep their dark ground.
- **className** (`string`): Added to the root.

## Accessibility

- The column is an ordered list labelled Notifications, and the band a list labelled System messages; a polite live region reads each new toast (“Updated: A light in the tower. Journal.”) and a counter’s new count.
- Each + is a real button named by its page and toast (“Open Journal: The Lantern Keeper”), with a hit area larger than its 14 u disc; the icons and the emblem are aria-hidden.
- Hovering or focusing the column pauses every toast’s clock, so nothing disappears while it is being read or reached (WCAG 2.2.1); Escape clears the column.
- Leaving toasts are hidden from assistive technology and their buttons disabled at once.
- The chips are buttons in a labelled group; everything shows a :focus-visible ring.
- Reduced motion: toasts and the band only fade; no slides, pops, bumps, rings or glides. The demo never plays.

## Source

### game-toasts/game-toasts.tsx

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

'use client';

import {
  Fragment,
  useCallback,
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
} from 'react';
import { Icon, type ToastIcon } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './game-toasts.module.css';

/*
 * Game toasts: the column of short notices at the right of an adventure game's screen. Each says
 * what just changed (a quest began or moved on, a control was learned, something was picked up)
 * and, under it, where in the menu to read more, with a small + button that opens that page.
 * The player glances and keeps playing; the notice leaves on its own.
 *
 * - Kinds: `line` (a name on a dark plate that fades out to its right), `tip` (a few lines with
 *   one phrase in red), `counter` (a short plate with an emblem and a number), and `notice` (a
 *   wide band with a light rim, apart from the column, for a system confirmation). Any of them can
 *   carry a `tag` above the plate ("Updated"), an `icon` overlapping the plate's left end, and a
 *   `where` line with its + button.
 * - A toast arrives under the others: its plate slides in from the right with a hint of overshoot
 *   and its where line follows a beat later; a tag and an icon pop in. A toast with the id of one
 *   already shown updates it instead: a counter's number bumps, its emblem glints, and its clock
 *   restarts, so picking up three coins is one toast that counts to three.
 * - Each holds for `hold` ms, the clocks paused while the pointer or the focus is on the column.
 *   Then it fades, drifting right, and once it is gone the ones under it glide up to close the gap
 *   (measured before and after, played back by FLIP with the Web Animations API). Past `max`
 *   toasts, the oldest leaves early.
 * - The + is a real button: pressing it dips the disc with a ring of light, calls `onOpen`, and
 *   the toast leaves (a game would open that page). Escape, with the focus in the column, clears it.
 * - Uncontrolled, it keeps its own list, with a row of chips under it to send each kind, and a
 *   demo plays a short run of play while it is on screen and untouched. Given `toasts`, it shows
 *   those and asks `onDismiss` to remove one when its time is up or it is opened.
 * - No game behind it here: the plates carry their own dark ground, and what sits straight on the
 *   stage (the where line, its disc, the tag) turns dark on a light page and light on a dark one.
 */

export type { ToastIcon };

export type GameToast = {
  /** Stable id. A toast with the id of one on screen updates it (a counter counts on). */
  id?: string;
  /** The shape: a one-line plate, a few lines, an emblem and number, or a wide band. */
  kind?: 'line' | 'tip' | 'counter' | 'notice';
  /** The text on the plate (a counter's is read to screen readers only). */
  title: string;
  /** A phrase inside the title drawn in red: the thing the tip teaches. */
  highlight?: string;
  /** A small yellow word above the plate, such as "Updated". */
  tag?: string;
  /** Where in the menu to read more, shown under the plate with a + button. */
  where?: string;
  /** Art at the plate's left end. */
  icon?: ToastIcon;
  /** A counter's number. */
  count?: number;
  /** How long it stays, in ms (defaults to the component's hold, or 2.6 s for a notice). */
  hold?: number;
};

type Item = GameToast & { id: string };
type Phase = 'in' | 'stay' | 'out';
type Row = { id: string; toast: Item; phase: Phase; bump: number };
type Clock = { left: number; since: number; timer: number; bump: number };

/** How long a leaver takes to fade, then the glide of the ones under it. */
const LEAVE = 420;
const GLIDE = 320;
/** A press is seen before the toast leaves. */
const OPEN_AFTER = 240;
const NOTICE_HOLD = 2600;

/** The chips' samples, by kind, taken in turn. */
const SAMPLES: Record<string, GameToast[]> = {
  quest: [
    { kind: 'line', title: 'The Lantern Keeper', where: 'Journal' },
    { kind: 'line', title: 'Echoes Under the Bridge', where: 'Journal' },
    { kind: 'line', title: 'A Map with No Coast', where: 'Journal' },
  ],
  tip: [
    {
      kind: 'tip',
      title: 'Press B just before an attack lands to roll aside and earn a Clean Dodge.',
      highlight: 'Clean Dodge',
      where: 'Controls',
    },
    {
      kind: 'tip',
      title: 'Hold ZL while gliding to dive, then let go near the ground for a Soft Landing.',
      highlight: 'Soft Landing',
      where: 'Controls',
    },
  ],
  update: [
    { kind: 'line', tag: 'Updated', title: 'A light in the tower', where: 'Journal' },
    { kind: 'line', tag: 'Updated', title: 'The ferryman’s debt', where: 'Journal' },
  ],
  item: [
    { kind: 'line', icon: 'mushroom', title: 'Glowcap Mushroom', where: 'Inventory' },
    { kind: 'line', icon: 'fruit', title: 'Ember Peach', where: 'Inventory' },
    { kind: 'line', icon: 'crystal', title: 'Tide Crystal', where: 'Inventory' },
  ],
  notice: [
    { kind: 'notice', title: 'Inventory sorted' },
    { kind: 'notice', title: 'Progress saved' },
  ],
};

const CHIPS = [
  { id: 'quest', label: 'Quest' },
  { id: 'tip', label: 'Tip' },
  { id: 'update', label: 'Update' },
  { id: 'coin', label: 'Coin' },
  { id: 'item', label: 'Item' },
  { id: 'notice', label: 'Notice' },
] as const;

/** The demo: a short run of play, as steps at ms from its start. */
const DEMO: { at: number; send?: string; open?: boolean }[] = [
  { at: 300, send: 'quest' },
  { at: 1500, send: 'tip' },
  { at: 3300, send: 'coin' },
  { at: 3700, send: 'coin' },
  { at: 4050, send: 'coin' },
  { at: 5600, send: 'update' },
  { at: 7300, open: true },
  { at: 8100, send: 'item' },
  { at: 9300, send: 'notice' },
];
const DEMO_LENGTH = 13000;

const said = (toast: GameToast) =>
  toast.kind === 'counter'
    ? `${toast.title}: ${toast.count ?? 0}`
    : [toast.tag, toast.title].filter(Boolean).join(': ') +
      (toast.where ? `. ${toast.where}.` : '');

export type GameToastsProps = {
  className?: string;
  /** The toasts shown (controlled), oldest first. Without it, the component keeps its own. */
  toasts?: GameToast[];
  /** Asked to remove a toast: its time is up, it was opened, or Escape cleared the column. */
  onDismiss?: (id: string) => void;
  /** Called when a toast's + is pressed. */
  onOpen?: (toast: GameToast) => void;
  /** How long a toast stays, in ms. */
  hold?: number;
  /** How many fit in the column; past it, the oldest leaves early. */
  max?: number;
  /** The column and band alone, without the room kept around them and the chips. */
  bare?: boolean;
  /** Plays a short run of play while on screen and untouched (uncontrolled only). */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

export function GameToasts({
  className,
  toasts: toastsProp,
  onDismiss,
  onOpen,
  hold = 4200,
  max = 5,
  bare = false,
  autoplay = true,
  colorScheme,
}: GameToastsProps) {
  const controlled = toastsProp !== undefined;
  const [own, setOwn] = useState<Item[]>([]);
  const serial = useRef(0);
  const source: Item[] = controlled
    ? toastsProp.map((toast, index) => ({ ...toast, id: toast.id ?? `toast-${index}` }))
    : own;

  const { ref, take, playing } = useAutoplay<HTMLDivElement>();
  const demo = playing && autoplay && !controlled;

  // What is shown: the last `max` of the column, and the latest notice.
  const column = source.filter((toast) => toast.kind !== 'notice').slice(-max);
  const band = source.filter((toast) => toast.kind === 'notice').slice(-1);
  const shown = [...column, ...band];

  const [rows, setRows] = useState<Row[]>([]);
  const [live, setLive] = useState('');
  const [presses, setPresses] = useState<Record<string, number>>({});
  const rowRefs = useRef(new Map<string, HTMLLIElement>());
  const columnRef = useRef<HTMLOListElement>(null);
  const before = useRef(new Map<string, DOMRect>());
  const timers = useRef<number[]>([]);
  const later = useCallback((ms: number, fn: () => void) => {
    timers.current.push(window.setTimeout(fn, ms));
  }, []);
  useEffect(() => () => timers.current.forEach((id) => window.clearTimeout(id)), []);

  // Remove a toast from the list: our own, or ask the owner.
  const dismiss = useCallback(
    (id: string) => {
      if (!controlled) setOwn((now) => now.filter((toast) => toast.id !== id));
      onDismiss?.(id);
    },
    [controlled, onDismiss],
  );

  // Rows follow what is shown: newcomers enter, missing ones leave, the rest update (a counter
  // that counted on bumps).
  const shownKey = shown.map((t) => `${t.id}:${t.count ?? ''}:${t.title}:${t.tag ?? ''}`).join('|');
  const rowsRef = useRef(rows);
  useLayoutEffect(() => {
    rowsRef.current = rows;
  }, [rows]);
  useLayoutEffect(() => {
    const wanted = new Map(shown.map((toast) => [toast.id, toast]));
    let announce = '';
    const next: Row[] = [];
    const seen = new Set<string>();
    for (const row of rowsRef.current) {
      const toast = wanted.get(row.id);
      if (!toast) {
        next.push(row.phase === 'out' ? row : { ...row, phase: 'out' });
        continue;
      }
      seen.add(row.id);
      if (row.phase === 'out') {
        next.push({ ...row, toast, phase: 'in', bump: 0 });
        continue;
      }
      const counted = toast.count !== row.toast.count;
      if (counted) announce = said(toast);
      next.push({ ...row, toast, bump: counted ? row.bump + 1 : row.bump });
    }
    for (const toast of shown) {
      if (seen.has(toast.id)) continue;
      next.push({ id: toast.id, toast, phase: 'in', bump: 0 });
      announce = said(toast);
    }
    rowsRef.current = next;
    setRows(next);
    if (announce) setLive(announce);
    // If the focus is in a toast that is leaving, it moves to the next toast's +, or the column.
    const focused = document.activeElement;
    const from = [...rowRefs.current].find(([, element]) => element.contains(focused))?.[0];
    if (from && !wanted.has(from)) {
      const after = next.slice(next.findIndex((row) => row.id === from) + 1);
      const target = [...after, ...next].find((row) => row.phase !== 'out' && row.toast.where);
      const button = target && rowRefs.current.get(target.id)?.querySelector('button');
      (button ?? columnRef.current)?.focus({ preventScroll: true });
    }
  }, [shownKey]);

  // A leaver is dropped once its fade is done; the ones under it glide up from where they were.
  const outKey = rows
    .filter((row) => row.phase === 'out')
    .map((row) => row.id)
    .join('|');
  useEffect(() => {
    if (!outKey) return;
    const ids = outKey.split('|');
    const id = window.setTimeout(() => {
      before.current.clear();
      for (const [key, element] of rowRefs.current)
        before.current.set(key, element.getBoundingClientRect());
      setRows((now) => now.filter((row) => !(row.phase === 'out' && ids.includes(row.id))));
    }, LEAVE);
    return () => window.clearTimeout(id);
  }, [outKey]);

  useLayoutEffect(() => {
    if (!before.current.size) return;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    for (const [key, rect] of before.current) {
      const element = rowRefs.current.get(key);
      if (!element || reduced) continue;
      const dy = rect.top - element.getBoundingClientRect().top;
      if (Math.abs(dy) < 0.5) continue;
      element.animate([{ translate: `0 ${dy}px` }, { translate: '0 0' }], {
        duration: GLIDE,
        easing: 'cubic-bezier(0.25, 0.9, 0.3, 1)',
        composite: 'add',
      });
    }
    before.current.clear();
  }, [rows]);

  // The clocks: each shown toast is dismissed after its hold, restarted when it updates, and
  // paused while the reader is on the column.
  const [paused, setPaused] = useState(false);
  const clocks = useRef(new Map<string, Clock>());
  const clockKey = rows
    .filter((row) => row.phase !== 'out')
    .map((row) => `${row.id}:${row.bump}`)
    .join('|');
  const expire = useEffectEvent((id: string) => {
    clocks.current.delete(id);
    dismiss(id);
  });
  useEffect(() => {
    const clockMap = clocks.current;
    const wanted = new Map(
      rows.filter((row) => row.phase !== 'out').map((row) => [row.id, row] as const),
    );
    for (const [id, clock] of clockMap) {
      if (wanted.has(id)) continue;
      window.clearTimeout(clock.timer);
      clockMap.delete(id);
    }
    for (const [id, row] of wanted) {
      const full = row.toast.hold ?? (row.toast.kind === 'notice' ? NOTICE_HOLD : hold);
      const clock = clockMap.get(id);
      if (clock && clock.bump === row.bump) continue;
      if (clock) window.clearTimeout(clock.timer);
      const next: Clock = { left: full, since: performance.now(), timer: 0, bump: row.bump };
      if (!paused) next.timer = window.setTimeout(() => expire(id), full);
      clockMap.set(id, next);
    }
  }, [clockKey, hold]);
  useEffect(() => {
    const now = performance.now();
    for (const [id, clock] of clocks.current) {
      window.clearTimeout(clock.timer);
      if (paused) clock.left = Math.max(400, clock.left - (now - clock.since));
      else {
        clock.since = now;
        clock.timer = window.setTimeout(() => expire(id), clock.left);
      }
    }
  }, [paused]);
  useEffect(() => {
    const clockMap = clocks.current;
    return () => clockMap.forEach((clock) => window.clearTimeout(clock.timer));
  }, []);

  // Sending a toast (the chips and the demo): a coin counts on in the toast already shown.
  const turns = useRef<Record<string, number>>({});
  const send = useCallback(
    (kind: string) => {
      if (kind === 'coin') {
        setOwn((now) => {
          const shownCoin = now.find((toast) => toast.id === 'coins');
          const coin: Item = {
            id: 'coins',
            kind: 'counter',
            icon: 'emblem',
            title: 'Star seals',
            count: (shownCoin?.count ?? 0) + 1,
          };
          return shownCoin
            ? now.map((toast) => (toast.id === 'coins' ? coin : toast))
            : [...now, coin];
        });
        return;
      }
      const list = SAMPLES[kind];
      const turn = turns.current[kind] ?? 0;
      turns.current[kind] = turn + 1;
      const toast = { ...list[turn % list.length], id: `t${++serial.current}` };
      setOwn((now) => {
        const rest = toast.kind === 'notice' ? now.filter((t) => t.kind !== 'notice') : now;
        const kept = rest.filter((t) => t.kind !== 'notice');
        // Past max, the oldest of the column leaves early.
        const drop = new Set(kept.slice(0, Math.max(0, kept.length + 1 - max)).map((t) => t.id));
        return [...rest.filter((t) => toast.kind === 'notice' || !drop.has(t.id)), toast];
      });
    },
    [max],
  );

  const open = useCallback(
    (row: Row) => {
      if (row.phase === 'out') return;
      setPresses((now) => ({ ...now, [row.id]: (now[row.id] ?? 0) + 1 }));
      setLive(`Opening ${row.toast.where ?? row.toast.title}`);
      onOpen?.(row.toast);
      later(OPEN_AFTER, () => dismiss(row.id));
    },
    [onOpen, later, dismiss],
  );

  // The demo, on its own clock: it starts from a clear column each time it is handed back.
  const step = useEffectEvent((item: (typeof DEMO)[number]) => {
    if (item.send) send(item.send);
    if (item.open) {
      const row = rows.find((r) => r.phase !== 'out' && r.toast.tag && r.toast.where);
      if (row) open(row);
    }
  });
  useEffect(() => {
    if (!demo) return;
    const ids: number[] = [];
    const run = (start: number) => {
      for (const item of DEMO) ids.push(window.setTimeout(() => step(item), start + item.at));
      ids.push(window.setTimeout(() => run(0), start + DEMO_LENGTH));
    };
    run(0);
    return () => ids.forEach((id) => window.clearTimeout(id));
  }, [demo]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!controlled) take();
    if (event.key !== 'Escape') return;
    const ids = rows.filter((row) => row.phase !== 'out').map((row) => row.id);
    if (!ids.length) return;
    event.preventDefault();
    ids.forEach(dismiss);
    setLive('Notifications cleared');
  };

  const columnRows = rows.filter((row) => row.toast.kind !== 'notice');
  const bandRows = rows.filter((row) => row.toast.kind === 'notice');

  const renderToast = (row: Row) => {
    const { toast } = row;
    const kind = toast.kind ?? 'line';
    const out = row.phase === 'out';
    const count = presses[row.id] ?? 0;
    return (
      <li
        key={row.id}
        ref={(element) => {
          if (element) rowRefs.current.set(row.id, element);
          else rowRefs.current.delete(row.id);
        }}
        className={styles.toast}
        data-kind={kind}
        data-phase={row.phase}
        data-icon={toast.icon ? '' : undefined}
        aria-hidden={out || undefined}
        data-part="toast"
      >
        {toast.tag && (
          <span className={styles.tag} data-part="tag">
            {toast.tag}
          </span>
        )}
        <div className={styles.plate} data-part="plate">
          {toast.icon && (
            <span
              className={styles.icon}
              key={`i${row.bump}`}
              data-bump={row.bump > 0 || undefined}
              data-part="icon"
            >
              <Icon name={toast.icon} />
            </span>
          )}
          {kind === 'counter' ? (
            <span className={styles.count} data-part="count">
              <span className={styles.sr}>{toast.title}: </span>
              <span
                key={toast.count}
                className={styles.digits}
                data-bump={row.bump > 0 || undefined}
              >
                {toast.count ?? 0}
              </span>
            </span>
          ) : (
            <p className={styles.title} data-part="title">
              <Title text={toast.title} highlight={toast.highlight} />
            </p>
          )}
        </div>
        {toast.where && (
          <div className={styles.where} data-part="where">
            <span>{toast.where}</span>
            <button
              type="button"
              className={styles.open}
              aria-label={`Open ${toast.where}: ${toast.title}`}
              tabIndex={out ? -1 : undefined}
              disabled={out}
              onClick={() => {
                if (!controlled) take();
                open(row);
              }}
              data-part="open"
            >
              <span className={styles.disc} key={count} data-pressed={count > 0 || undefined}>
                <svg viewBox="0 0 14 14" aria-hidden="true" focusable="false">
                  <path d="M7 3v8M3 7h8" />
                </svg>
              </span>
              {count > 0 && <span className={styles.ring} key={`r${count}`} aria-hidden="true" />}
            </button>
          </div>
        )}
      </li>
    );
  };

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-bare={bare || undefined}
      data-part="root"
      onPointerDown={controlled ? undefined : take}
      onKeyDown={onKeyDown}
    >
      <div
        className={styles.screen}
        data-part="screen"
        onPointerEnter={() => setPaused(true)}
        onPointerLeave={() => setPaused(false)}
        onFocus={() => setPaused(true)}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setPaused(false);
        }}
      >
        <ol
          ref={columnRef}
          className={styles.column}
          aria-label="Notifications"
          tabIndex={-1}
          data-part="column"
        >
          {columnRows.map(renderToast)}
        </ol>
        <ul className={styles.band} aria-label="System messages" data-part="band">
          {bandRows.map((row) => (
            <li
              key={row.id}
              className={styles.notice}
              data-phase={row.phase}
              aria-hidden={row.phase === 'out' || undefined}
              data-part="notice"
            >
              {row.toast.title}
            </li>
          ))}
        </ul>
      </div>
      {!bare && !controlled && (
        <div
          className={styles.chips}
          role="group"
          aria-label="Send a notification"
          data-part="chips"
        >
          {CHIPS.map((chip) => (
            <button
              key={chip.id}
              type="button"
              className={styles.chip}
              onClick={() => {
                take();
                send(chip.id);
              }}
            >
              {chip.label}
            </button>
          ))}
        </div>
      )}
      <p className={styles.sr} aria-live="polite">
        {live}
      </p>
    </div>
  );
}

/** The title, with its highlighted phrase (its first occurrence) in red. */
function Title({ text, highlight }: { text: string; highlight?: string }) {
  const at = highlight ? text.indexOf(highlight) : -1;
  if (!highlight || at < 0) return <>{text}</>;
  return (
    <Fragment>
      {text.slice(0, at)}
      <strong className={styles.highlight}>{highlight}</strong>
      {text.slice(at + highlight.length)}
    </Fragment>
  );
}
```

### game-toasts/icons.tsx

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

/*
 * The toasts' item art: small flat drawings in a 32-unit box, bold and a little glossy, the way a
 * game's inventory icons read at a glance. They are decorative (the toast's text names the item),
 * so each is aria-hidden and sized by its parent in CSS.
 */

export type ToastIcon = 'emblem' | 'mushroom' | 'fruit' | 'crystal';

export function Icon({ name }: { name: ToastIcon }) {
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true" focusable="false">
      {name === 'emblem' && (
        // A gold seal: a four-point star over a ring of small rays, with a bright facet.
        <g>
          <path
            d="M16 3.2l2.3 7.6 7.5-2.4-4.8 6.1 6.5 4.6-7.9.4.4 8L16 21.4l-4 6.1.4-8-7.9-.4 6.5-4.6-4.8-6.1 7.5 2.4z"
            fill="#c8920c"
          />
          <path
            d="M16 5.6l1.9 7.4 7.4 1.9-7.4 1.9L16 24.2l-1.9-7.4-7.4-1.9 7.4-1.9z"
            fill="#fcc413"
          />
          <path d="M16 5.6l1.9 7.4L16 14.9l-1.9-1.9z" fill="#ffe8a0" />
          <circle cx="16" cy="14.9" r="2.1" fill="#7a5508" />
        </g>
      )}
      {name === 'mushroom' && (
        <g>
          <path
            d="M12.6 17h6.8l1.1 9.4c.1 1.3-1 2.3-2.3 2.3h-4.4c-1.3 0-2.4-1-2.3-2.3z"
            fill="#ece0c2"
          />
          <path d="M17.6 17h1.8l1.1 9.4c.1 1.3-1 2.3-2.3 2.3h-.9z" fill="#cdbf9d" />
          <path
            d="M3.6 17.6C3.6 9.8 9.2 4 16 4s12.4 5.8 12.4 13.6c0 1.2-.9 1.9-2 1.9H5.6c-1.1 0-2-.7-2-1.9z"
            fill="#e2603a"
          />
          <path
            d="M16 4c6.8 0 12.4 5.8 12.4 13.6 0 1.2-.9 1.9-2 1.9h-3.8C25 15 22.6 7.2 16 4z"
            fill="#b8432a"
          />
          <ellipse cx="11" cy="10.4" rx="2.4" ry="1.8" fill="#f8ead0" />
          <ellipse cx="18.6" cy="8.6" rx="1.6" ry="1.2" fill="#f8ead0" />
          <ellipse cx="8.2" cy="15.2" rx="1.5" ry="1.2" fill="#f8ead0" />
          <ellipse cx="15.4" cy="14.4" rx="2" ry="1.5" fill="#f8ead0" />
          <ellipse cx="22.6" cy="13.6" rx="1.4" ry="1.1" fill="#f3d9bf" />
        </g>
      )}
      {name === 'fruit' && (
        <g>
          <path
            d="M16.4 8.6c-.2-2.2.4-4.2 1.8-5.6"
            stroke="#5b4426"
            strokeWidth="1.8"
            strokeLinecap="round"
            fill="none"
          />
          <path d="M17.4 6.4c2.6-2.6 6.6-2.8 8.8-1.6-1.4 3-5.2 4.2-8.8 1.6z" fill="#7fb446" />
          <path
            d="M16 8.4c-2.6-1.4-6.4-1.4-8.8 1C4.6 12 4 16.2 5.4 20.4c1.6 4.6 5 7.6 8.2 7.6 1 0 1.6-.4 2.4-.4s1.4.4 2.4.4c3.2 0 6.6-3 8.2-7.6 1.4-4.2.8-8.4-1.8-11-2.4-2.4-6.2-2.4-8.8-1z"
            fill="#f09a32"
          />
          <path
            d="M18.4 8c2.4-.6 5 .1 6.4 1.4 2.6 2.6 3.2 6.8 1.8 11-1.6 4.6-5 7.6-8.2 7.6-.6 0-1-.1-1.5-.2 3-1.4 5.6-5 6.2-9.6.6-4.4-1-8.2-4.7-10.2z"
            fill="#c96f1c"
          />
          <ellipse
            cx="10.4"
            cy="13.2"
            rx="2"
            ry="3.2"
            transform="rotate(20 10.4 13.2)"
            fill="#ffd38a"
          />
        </g>
      )}
      {name === 'crystal' && (
        <g>
          <path d="M16 2.8l8.6 9.6L16 29.2 7.4 12.4z" fill="#3fa6d8" />
          <path d="M16 2.8l8.6 9.6L16 29.2z" fill="#2a7fb4" />
          <path d="M7.4 12.4h17.2L16 15.6z" fill="#8fe0ff" />
          <path d="M16 2.8l-3.4 9.6h-5.2z" fill="#c4f1ff" />
        </g>
      )}
    </svg>
  );
}
```

### game-toasts/game-toasts.module.css

```css
/* Game toasts (game-toasts.tsx): the notices' column and the system band, alone on the page's
   stage, with a row of chips below to send each kind.

   Every size is a multiple of --u, one design unit: a toast's plate is 22 u tall with a 15 u
   italic title, a tip's lines are 20 u apart, the + disc is 14 u. The root sets --u from its own
   width (a container query unit), up to 1.4 px, so the whole column scales as one; --toasts-unit
   fixes it.

   The plates carry their own dark ground: a game's toast is a dark plate that fades out to its
   right, and it reads the same over a light page or a dark one. What sits straight on the stage
   (the where line, its disc, the tag) follows the colour scheme through light-dark(): dark ink on
   a light page, the game's light ink with a soft shadow on a dark one. On a light page the plate
   also stays solid longer before it fades, so the end of a long title keeps its dark ground; on a
   dark page it is a step lighter than the page, so its edge still shows.

   Motion: arrivals and leavers are keyframes on their row; the where line, a tag and an icon
   follow with their own; a counter's bump, an icon's glint, the disc's dip and ring are keyframes
   on parts remounted per event; rows that close a gap glide by a FLIP the script plays with the
   Web Animations API. */

.root {
  /* The plates. */
  --plate: rgb(42 41 38);
  --plate-mid: rgb(42 41 38 / 0.5);
  --plate-late: rgb(42 41 38 / 0.24);
  --ink: #e9e1d1;
  --highlight: #f15050;
  --gold: #fcc413;
  /* On the stage itself: the where line, its disc, the tag. */
  --where-ink: #3d3b36;
  --where-shadow: rgb(0 0 0 / 0);
  --disc: #2a2926;
  --disc-ink: #f1ede2;
  --disc-shadow: rgb(20 18 12 / 0.28);
  --tag: #a87a00;
  /* The system band. */
  --band: #4a4944;
  --band-rim: #8c8b88;
  --shade: rgb(20 18 12 / 0.18);
  /* The chips. */
  --chip: #f2f1ee;
  --chip-hover: #e8e7e3;
  --chip-text: #4a4944;
  --chip-edge: rgb(0 0 0 / 0.08);
  --focus: #2f6fdf;
  --u: var(--toasts-unit, min(1.4px, calc((100cqi - 32px) / 330)));
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 24px;
  width: 100%;
  min-height: 360px;
  padding: 28px 16px;
  box-sizing: border-box;
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    /* On a dark page the plate is a step lighter than the page, so its edge still reads. */
    --plate: light-dark(rgb(42 41 38), rgb(64 62 57));
    --plate-mid: light-dark(rgb(42 41 38), rgb(64 62 57 / 0.6));
    --plate-late: light-dark(rgb(42 41 38 / 0.86), rgb(64 62 57 / 0.28));
    --where-ink: light-dark(#3d3b36, #e9e1d1);
    --where-shadow: light-dark(rgb(0 0 0 / 0), rgb(0 0 0 / 0.55));
    --disc: light-dark(#2a2926, #e2ded3);
    --disc-ink: light-dark(#f1ede2, #080808);
    --disc-shadow: light-dark(rgb(20 18 12 / 0.28), rgb(0 0 0 / 0.5));
    --tag: light-dark(#a87a00, #fcc413);
    --band: light-dark(#4a4944, #5a5954);
    --shade: light-dark(rgb(20 18 12 / 0.18), rgb(0 0 0 / 0.4));
    --chip: light-dark(#f2f1ee, #26262a);
    --chip-hover: light-dark(#e8e7e3, #303035);
    --chip-text: light-dark(#4a4944, #c9c8c3);
    --chip-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
/* 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[data-bare] {
  display: block;
  min-height: 0;
  padding: 0;
}

/* The screen's corner: room for the column (about four toasts) and the band under it. */
.screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(28 * var(--u));
  width: calc(320 * var(--u));
  max-width: 100%;
}

/* The column: toasts right-aligned on one edge, the newest at the bottom. */
.column {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  width: calc(258 * var(--u));
  min-height: calc(330 * var(--u));
  margin: 0;
  padding: 0;
  list-style: none;
}
/* It takes the focus only when the toast that had it leaves. */
.column:focus {
  outline: none;
}
.root[data-bare] .column {
  min-height: 0;
}
.toast {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  width: calc(241 * var(--u));
  padding-bottom: calc(24 * var(--u));
  font-size: calc(15 * var(--u));
  line-height: calc(20 * var(--u));
}
.toast[data-kind='tip'] {
  width: calc(252 * var(--u));
}
.toast[data-kind='counter'] {
  width: auto;
  padding-bottom: calc(18 * var(--u));
}

/* The plate: dark at its left end, fading to nothing at its right. */
.plate {
  position: relative;
  display: flex;
  align-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: calc(22 * var(--u));
  padding: calc(1 * var(--u)) calc(50 * var(--u)) calc(1 * var(--u)) calc(11 * var(--u));
  border-radius: calc(11 * var(--u));
  background: linear-gradient(
    to right,
    var(--plate) 10%,
    var(--plate-mid) 55%,
    var(--plate-late) 78%,
    transparent 100%
  );
}
.toast[data-kind='tip'] .plate {
  padding: calc(1.5 * var(--u)) calc(49 * var(--u)) calc(2.5 * var(--u)) calc(11 * var(--u));
  border-radius: calc(18 * var(--u));
}
.toast[data-kind='counter'] .plate {
  gap: calc(7 * var(--u));
  padding: 0 calc(100 * var(--u)) 0 calc(6 * var(--u));
}
.toast[data-icon]:not([data-kind='counter']) .plate {
  padding-left: calc(35 * var(--u));
}
.title {
  margin: 0;
  font-style: italic;
  font-weight: 500;
  letter-spacing: -0.005em;
  overflow-wrap: anywhere;
  text-wrap: pretty;
}
.toast[data-kind='tip'] .title {
  letter-spacing: -0.012em;
}
.toast:not([data-kind='tip']) .title {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.highlight {
  color: var(--highlight);
  font-weight: inherit;
}
.count {
  display: inline-flex;
  font-size: calc(15 * var(--u));
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}
.digits {
  display: inline-block;
}
.digits[data-bump] {
  animation: bump 380ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
@keyframes bump {
  from {
    color: var(--gold);
    scale: 1.45;
    translate: 0 calc(-2 * var(--u));
  }
}

/* An icon overlaps the plate's left end; a counter's emblem sits inside it. */
.icon {
  position: absolute;
  top: 50%;
  left: calc(-6 * var(--u));
  display: grid;
  width: calc(31 * var(--u));
  height: calc(31 * var(--u));
  translate: 0 -50%;
}
.icon svg {
  width: 100%;
  height: 100%;
  overflow: visible;
  filter: drop-shadow(0 calc(1 * var(--u)) calc(1.5 * var(--u)) rgb(0 0 0 / 0.45));
}
.toast[data-kind='counter'] .icon {
  position: relative;
  top: auto;
  left: auto;
  width: calc(22 * var(--u));
  height: calc(22 * var(--u));
  translate: none;
}
.icon[data-bump] {
  animation: glint 420ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
@keyframes glint {
  30% {
    scale: 1.25;
    rotate: -8deg;
  }
}
.icon[data-bump]::after {
  content: '';
  position: absolute;
  inset: -20%;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(255 232 150 / 0.75), rgb(255 232 150 / 0));
  opacity: 0;
  animation: shine 460ms ease-out;
  pointer-events: none;
}
@keyframes shine {
  20% {
    opacity: 1;
  }
}

/* The tag above the plate. */
.tag {
  /* It sits in the gap above its toast, so a tagged toast keeps the column's rhythm. */
  margin: calc(-14 * var(--u)) 0 calc(4 * var(--u)) calc(4 * var(--u));
  color: var(--tag);
  font-size: calc(11 * var(--u));
  font-style: italic;
  font-weight: 600;
  line-height: 1;
}

/* Where to read more, under the plate's right side, with its + disc. */
.where {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: calc(11 * var(--u));
  /* Its box hugs the words and the disc, under the plate's right side. */
  align-self: flex-end;
  margin-top: calc(4 * var(--u));
  padding-right: calc(28 * var(--u));
  color: var(--where-ink);
  font-size: calc(13 * var(--u));
  font-style: italic;
  font-weight: 500;
  line-height: calc(16 * var(--u));
  text-shadow: 0 calc(1 * var(--u)) calc(3 * var(--u)) var(--where-shadow);
  white-space: nowrap;
}
.open {
  position: relative;
  display: grid;
  place-items: center;
  /* A larger hit area than the 14 u disc. */
  width: calc(24 * var(--u));
  height: calc(24 * var(--u));
  margin: calc(-5 * var(--u));
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: pointer;
  touch-action: manipulation;
}
.open:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}
.disc {
  display: grid;
  place-items: center;
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
  border-radius: 50%;
  background: var(--disc);
  box-shadow: 0 calc(1 * var(--u)) calc(3 * var(--u)) var(--disc-shadow);
  transition: scale 150ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.disc svg {
  width: 100%;
  height: 100%;
  fill: none;
  stroke: var(--disc-ink);
  stroke-width: 2;
}
@media (hover: hover) {
  .open:hover .disc {
    scale: 1.15;
  }
}
.disc[data-pressed] {
  animation: dip 260ms cubic-bezier(0.3, 0.7, 0.4, 1);
}
@keyframes dip {
  35% {
    scale: 0.78;
  }
  70% {
    scale: 1.12;
  }
}
.ring {
  position: absolute;
  inset: calc(5 * var(--u));
  border: calc(1.5 * var(--u)) solid var(--disc);
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  animation: ring 420ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes ring {
  from {
    opacity: 0.9;
    scale: 0.9;
  }
  to {
    opacity: 0;
    scale: 2;
  }
}

/* Arriving: the plate slides in from the right with a hint of overshoot, the where line follows,
   a tag and an icon pop. Leaving: the whole toast fades, drifting right. */
.toast[data-phase='in'] .plate {
  animation: enter 300ms cubic-bezier(0.2, 0.9, 0.3, 1.12) both;
}
.toast[data-phase='in'] .where {
  animation: follow 260ms cubic-bezier(0.2, 0.9, 0.3, 1) 110ms both;
}
.toast[data-phase='in'] .tag {
  animation: pop 280ms cubic-bezier(0.3, 1.6, 0.5, 1) 140ms both;
}
.toast[data-phase='in'] .icon:not([data-bump]) {
  animation: pop 340ms cubic-bezier(0.3, 1.7, 0.5, 1) 90ms both;
}
.toast[data-phase='out'] {
  pointer-events: none;
  animation: leave 420ms cubic-bezier(0.4, 0, 0.8, 0.6) forwards;
}
@keyframes enter {
  from {
    opacity: 0;
    translate: calc(36 * var(--u)) 0;
  }
  40% {
    opacity: 1;
  }
}
@keyframes follow {
  from {
    opacity: 0;
    translate: calc(14 * var(--u)) 0;
  }
}
@keyframes pop {
  from {
    opacity: 0;
    scale: 0.4;
  }
}
@keyframes leave {
  to {
    opacity: 0;
    translate: calc(14 * var(--u)) 0;
  }
}

/* The system band: a wide plate with a thin light rim, one line in the middle. */
.band {
  display: grid;
  width: 100%;
  min-height: calc(58 * var(--u));
  margin: 0;
  padding: 0;
  list-style: none;
}
.root[data-bare] .band:empty {
  display: none;
}
.notice {
  grid-area: 1 / 1;
  display: grid;
  place-items: center;
  min-height: calc(58 * var(--u));
  padding: 0 calc(16 * var(--u));
  box-sizing: border-box;
  border: calc(1 * var(--u)) solid var(--band-rim);
  background: var(--band);
  box-shadow: 0 calc(2 * var(--u)) calc(10 * var(--u)) var(--shade);
  color: var(--ink);
  font-size: calc(15 * var(--u));
  font-style: italic;
  font-weight: 500;
  text-align: center;
}
.notice[data-phase='in'] {
  animation: open-band 320ms cubic-bezier(0.2, 0.9, 0.3, 1) both;
}
.notice[data-phase='out'] {
  animation: fade-out 420ms ease-in forwards;
}
@keyframes open-band {
  from {
    opacity: 0;
    scale: 1 0.6;
  }
}

/* The chips: small neutral buttons under the stage, to send each kind. */
.chips {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  max-width: 560px;
}
.chip {
  min-height: 30px;
  padding: 0 12px;
  border: 1px solid var(--chip-edge);
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-text);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 150ms,
    scale 150ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.chip:hover {
  background: var(--chip-hover);
}
.chip:active {
  scale: 0.95;
}
.chip:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
@container (max-width: 479px) {
  .chips {
    gap: 5px;
  }
  .chip {
    min-height: 28px;
    padding: 0 10px;
    font-size: 12px;
  }
}

.sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Reduced motion: toasts and the band only fade; no slides, pops, bumps or glides. */
@media (prefers-reduced-motion: reduce) {
  .toast[data-phase='in'] .plate,
  .toast[data-phase='in'] .where,
  .toast[data-phase='in'] .tag,
  .notice[data-phase='in'] {
    animation: fade-in 160ms ease-out both;
  }
  .toast[data-phase='in'] .icon:not([data-bump]),
  .icon[data-bump],
  .icon[data-bump]::after,
  .digits[data-bump],
  .disc[data-pressed],
  .ring {
    animation: none;
  }
  .toast[data-phase='out'],
  .notice[data-phase='out'] {
    animation: fade-out 200ms ease-in forwards;
  }
  .disc,
  .chip {
    transition: none;
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
```

### game-toasts/use-autoplay.ts

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

'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 };
}
```