# Effect timer

> A game HUD’s stack of timed effects: icon, name, hourglass and countdown, warning in the last seconds.

The list a game shows while a meal or a potion is working on the player: each effect’s icon in its colour, its name, an hourglass and the time left. New effects slide in on a dark banner, taking one again renews it with a flip of the hourglass, the last seconds pulse red, and a spent effect blinks out while the stack closes up. Take supplies from the chips under it to try it.

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

Category: Games · HUD.

## Anatomy

1. **List**: One grid of four columns, alone on the page; every row is a subgrid of it, so icons, names and counts line up. Its type is 5.2 % of the width, up to 30 px.
2. **Effect row**: One effect on its own dark banner, fading out at both ends (`backing`), so it reads on a light page as on a dark one. It slides in from the left as its height opens.
3. **Icon**: The effect’s shape in its own colour (heat red, cold blue, shock yellow…), with a thin dark outline, popping in with an overshoot.
4. **Name**: The effect’s label, in light italics with a thin dark outline, so it reads even without the banner.
5. **Hourglass**: Turns over on each renewal; in the last `warnAt` seconds it turns red and shakes once a second.
6. **Count**: Minutes and seconds left, rounded up, in tabular figures so the row never jitters; past an hour, `1:02:05`.
7. **Supplies**: Uncontrolled only: a row of plain chips under the list, each taking or renewing one effect.

## When to use

- A game HUD that lists the temporary effects on the player (a meal against the cold, a potion of speed) with the time each has left.
- Several timed boosts at once, where the reader compares them at a glance: the counts sit in one column.
- Effects that can be renewed before they end, so taking the same one again should read as a refill, not a second row.
- A countdown that should warn before it ends and say when it ran out (`onExpire`), without the game polling it.
- A timer that must stop with the game: under a pause menu (`paused`), off screen or in a hidden tab.

## When not to use

- A single countdown to an event (a match start, a sale). Use one large timer with its own label.
- Cooldowns on abilities the player triggers. Use ability icons with a radial sweep over each.
- Effects without a duration (a status that stays until cured). Use a row of status icons.
- Health, stamina or any meter that goes up and down. Use a gauge or a wheel.
- Timing work outside a game, such as a session expiry or an upload. Use a toast or an inline notice in the product’s own style.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/effect-timer.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 { EffectTimer, type EffectTimerItem } from './effect-timer';

export function PlayerEffects({ effects, paused }: { effects: EffectTimerItem[]; paused: boolean }) {
  return (
    <EffectTimer
      effects={effects}
      paused={paused}
      warnAt={15}
      onExpire={(id) => console.log(id, 'wore off')}
    />
  );
}
```

## States

- **One effect**: A heat ward with 05:38 left, held by `paused` so the count stands still.
- **Stack**: Three effects at once: the counts line up in one column whatever the names’ lengths.
- **Running out**: Within `warnAt` seconds (10 by default): the count and the hourglass turn red, and the hourglass shakes.
- **Renewed**: The same effect taken again (a new `seconds` or `renew`): the hourglass turns over and the count flashes.
- **Over an hour**: Counts of an hour or more add the hours: `1:02:05`.
- **No banner**: `backing={false}`: the words, icons and hourglasses with their dark outline alone, for a game scene that needs no banner.
- **Supplies**: Uncontrolled with `autoplay={false}`: the first effect, room for three more, and the supplies to take.

## How it works

- The counts run on a clock of the component’s own, advanced every 100 ms only while it is on screen in a visible tab and not paused. Each row keeps the clock time it ends at and shows what is left rounded up to the second, so hiding the tab never eats time, and the list redraws only when a shown second changes.
- The rows share one grid and each is a subgrid, so the hourglasses and counts of a stack line up in a column whatever the names. Digits are tabular, so a tick never shifts the row.
- A new row enters from 0 to auto height (interpolate-size) with @starting-style, sliding 1.4 em in from the left as its banner fades in; the icon pops 90 ms later, overshooting to 1.18 before it settles.
- Renewing an effect turns the hourglass over (a 180° rotate with a little anticipation and overshoot) and remounts the count, which flashes white in the effect’s colour and settles from 1.22 times its size.
- In the last warnAt seconds the count and the hourglass turn warning red; the count is keyed by the second, so its glow pulse plays again on every tick, while the hourglass shakes once a second. At zero the row blinks three times in 720 ms, then slides out as its height, padding and margin close.
- Uncontrolled, it keeps its own list and a row of supply chips under it: taking one adds that effect or renews it to its full time. A demo plays a short run while it is on screen and untouched, and any input hands it to the reader.
- It stands alone on the page, with no scene behind it: each row carries its own dark banner and every word, icon and hourglass a thin dark outline (shadows drawn behind the sharp shapes), so the HUD keeps its game colours and reads on a light page as on a dark one. Its type is 5.2 % of its width, up to 30 px, or --effect-size; only the banner’s depth and the supply chips change with the colour scheme, through light-dark() tokens.

## API reference

- **effects** (`EffectTimerItem[]`): The effects to show, controlled: each is { id, kind, label, seconds, renew? }. A new id enters, a new seconds or renew renews it, a missing id leaves. Turns the demo and the supplies off.
- **defaultEffects** (`EffectTimerItem[]`, default `[Heat ward, 5:38]`): Uncontrolled, the effects it starts with, shown while the demo is not playing.
- **onExpire** (`(id: string) => void`): Called with an effect’s id when it runs out.
- **warnAt** (`number`, default `10`): From how many seconds left a row warns: red count and hourglass, a pulse each second.
- **paused** (`boolean`, default `false`): Stops every count, as a game’s pause menu does.
- **backing** (`boolean`, default `true`): A dark banner behind each row, fading out at both ends. Without it, the words, icons and hourglasses keep only their dark outline.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run of play: effects taken, one renewed, one running out.
- **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.

## Accessibility

- The effects are a list named “Active effects”; each row reads as its name and its time, the count a <time> element whose label spells it out (“5 minutes 38 seconds”).
- A polite live region says when an effect starts, is renewed, is running out and wears off, never on each tick.
- The supplies are buttons in a named group, reached with Tab and pressed with Enter or Space, with a visible focus ring; on a narrow stage their names stay for screen readers when only the icons show.
- Icons and the hourglass are decorative (aria-hidden): the row’s words say what the effect is. The warning is told by colour and by the live region.
- Under reduced motion rows appear and leave at once, nothing pulses, shakes or blinks, and the demo never plays; the counts still run.

## Source

### effect-timer/effect-timer.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import { EffectIcon, Hourglass, TINTS, type EffectKind } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './effect-timer.module.css';

/*
 * Effect timer: the game HUD's list of temporary effects on the player (a meal or a potion against
 * heat, cold or shock, a burst of speed...). Each row says which effect, by its icon and colour,
 * and how long it has left, beside an hourglass, in minutes and seconds.
 *
 * - The rows count down on a clock of their own that only runs while the component is on screen
 *   in a visible tab and not paused: every row keeps the clock time it ends at, and shows what is
 *   left, rounded up to the second. Nothing moves on a tick; the digits are tabular so the row
 *   never jitters.
 * - A new effect slides in from the left on a dark banner that fades out at both ends, its icon
 *   pops in with an overshoot, and the list opens room for it (its height eases from 0 to auto).
 * - Taking the same effect again renews it: the hourglass flips over and the count flashes.
 * - In its last `warnAt` seconds, the count and the hourglass turn warning red, the count pulses
 *   once a second and the hourglass shakes. At zero the row blinks three times and slides out,
 *   and the list closes the gap. An effect the game removes slides out at once.
 * - The rows line up in one grid (subgrid), so the counts of a stack sit in a column.
 * - Uncontrolled, it keeps its own list, with a row of supplies to take on the stage, and a demo
 *   plays a short run while on screen and untouched. Given `effects`, it shows those: a new id
 *   enters, a changed `seconds` or `renew` renews, a missing id leaves; `onExpire` says when one
 *   ran out.
 * - It stands alone, centred on the page with nothing behind it: each row carries its own dark
 *   banner (`backing`), and its words, icon and hourglass a thin dark outline, so the HUD keeps
 *   its game colours and reads on a light page as on a dark one. Only the supplies, plain chips
 *   under the list, follow the colour scheme.
 */

export type { EffectKind };

export type EffectTimerItem = {
  /** Stable id: the same id is the same effect. */
  id: string;
  /** Its icon and colour. */
  kind: EffectKind;
  /** Its name, as the row shows it. */
  label: string;
  /** Seconds left when it is given. A new value renews it from there. */
  seconds: number;
  /** Change it to renew the effect with the same seconds. */
  renew?: number;
};

type Phase = 'live' | 'ending' | 'out';
type Row = {
  key: string;
  id: string;
  kind: EffectKind;
  label: string;
  seconds: number;
  renew: number | undefined;
  /** The clock time it runs out at, in ms. */
  ends: number;
  /** How many times it was renewed: each turns the hourglass over once more. */
  renewed: number;
  phase: Phase;
  warned: boolean;
};

/** The supplies on the stage, each one effect, and the demo's cast. */
const SUPPLIES: { kind: EffectKind; label: string; seconds: number }[] = [
  { kind: 'heat', label: 'Heat ward', seconds: 338 },
  { kind: 'cold', label: 'Cold ward', seconds: 250 },
  { kind: 'shock', label: 'Shock ward', seconds: 92 },
  { kind: 'swift', label: 'Quickstep', seconds: 20 },
];
const supply = (kind: EffectKind) => SUPPLIES.find((item) => item.kind === kind)!;

const DEFAULT_EFFECTS: EffectTimerItem[] = [{ id: 'heat', ...supply('heat') }];

/** How long a run-out row blinks, then how long it takes to slide out, in ms. */
const BLINK = 720;
const LEAVE = 340;

/**
 * The demo: a short run of play, as steps at ms from its start. The quickstep runs out on its own
 * (with its warning), the heat ward is renewed, and the stage clears to start again.
 */
const DEMO: { at: number; act: (list: EffectTimerItem[]) => EffectTimerItem[] }[] = [
  { at: 0, act: () => [] },
  { at: 600, act: (list) => [...list, { id: 'heat', ...supply('heat') }] },
  {
    at: 1700,
    act: (list) => [...list, { id: 'swift', ...supply('swift'), seconds: 13 }],
  },
  { at: 2800, act: (list) => [...list, { id: 'shock', ...supply('shock') }] },
  {
    at: 7400,
    act: (list) => list.map((item) => (item.id === 'heat' ? { ...item, seconds: 480 } : item)),
  },
  { at: 18200, act: () => [] },
];
const DEMO_LENGTH = 19600;

/** 338 → "05:38"; an hour or more → "1:02:05". */
function format(seconds: number) {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  const two = (n: number) => String(n).padStart(2, '0');
  return h ? `${h}:${two(m)}:${two(s)}` : `${two(m)}:${two(s)}`;
}

/** 338 → "5 minutes 38 seconds", for screen readers. */
function spoken(seconds: number) {
  const m = Math.floor(seconds / 60);
  const s = seconds % 60;
  const part = (n: number, unit: string) => (n ? `${n} ${unit}${n === 1 ? '' : 's'}` : '');
  return [part(m, 'minute'), part(s, 'second')].filter(Boolean).join(' ') || '0 seconds';
}

export function EffectTimer({
  effects,
  defaultEffects = DEFAULT_EFFECTS,
  onExpire,
  warnAt = 10,
  paused = false,
  backing = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The effects to show, controlled: a new id enters, a new seconds or renew renews it, a missing id leaves. Turns the demo and the supplies off. */
  effects?: EffectTimerItem[];
  /** Uncontrolled, the effects it starts with (and shows when the demo cannot play). */
  defaultEffects?: EffectTimerItem[];
  /** Called with an effect's id when it runs out. */
  onExpire?: (id: string) => void;
  /** From how many seconds left a row warns: red count, a pulse each second. */
  warnAt?: number;
  /** Stops every count, as a game's pause menu does. */
  paused?: boolean;
  /** A dark banner behind each row, fading out at both ends; without it, the outlines alone. */
  backing?: boolean;
  /** While on screen and untouched, a short run of play: effects taken, renewed and running out. */
  autoplay?: boolean;
  /** Light or dark stage, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(8000);
  const controlled = effects !== undefined;
  const [own, setOwn] = useState(defaultEffects);
  const list = effects ?? own;
  const demo = autoplay && playing && !controlled;

  const rows = useRef<Row[]>([]);
  /** The clock the counts run on, in ms; it stands still while hidden or paused. */
  const clock = useRef(0);
  /** Effects that ran out, with the seconds and renew they ran out with, until they change. */
  const expired = useRef(new Map<string, string>());
  const timers = useRef(new Set<number>());
  const serial = useRef(0);
  /** What the rows show: a copy of them with the seconds each has left, taken on each change. */
  const [view, setView] = useState<(Row & { left: number })[]>([]);
  /** What the rows showed last, to draw again only when a second changes. */
  const shown = useRef('');
  const [message, setMessage] = useState('');

  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));
  const later = (ms: number, run: () => void) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      run();
    }, ms);
    timers.current.add(timer);
  };
  useEffect(() => {
    const all = timers.current;
    return () => all.forEach((timer) => window.clearTimeout(timer));
  }, []);

  const left = (row: Row) =>
    row.phase === 'live' ? Math.max(0, Math.ceil((row.ends - clock.current) / 1000)) : 0;
  const signature = () => rows.current.map((row) => `${row.key}:${row.phase}:${left(row)}`).join();
  const render = () => {
    shown.current = signature();
    setView(rows.current.map((row) => ({ ...row, left: left(row) })));
  };

  /** Slides a row out, then forgets it. */
  const leave = (row: Row) => {
    row.phase = 'out';
    later(reduced ? 0 : LEAVE, () => {
      rows.current = rows.current.filter((other) => other !== row);
      render();
    });
  };

  const expire = useEffectEvent((row: Row) => {
    row.phase = 'ending';
    expired.current.set(row.id, `${row.seconds}|${row.renew}`);
    announce(`${row.label} wore off`);
    onExpire?.(row.id);
    if (!controlled) setOwn((now) => now.filter((item) => item.id !== row.id));
    later(BLINK, () => {
      leave(row);
      render();
    });
  });

  /** One step of the clock: runs out what is due, warns what is close, redraws on a new second. */
  const step = useEffectEvent(() => {
    for (const row of rows.current) {
      if (row.phase !== 'live') continue;
      if (row.ends <= clock.current) expire(row);
      else if (!row.warned && left(row) <= warnAt) {
        row.warned = true;
        announce(`${row.label} running out`);
      }
    }
    if (signature() !== shown.current) render();
  });

  const running = visible && !paused;
  useEffect(() => {
    if (!running) return;
    let last = performance.now();
    const advance = () => {
      const now = performance.now();
      clock.current += now - last;
      last = now;
    };
    const interval = window.setInterval(() => {
      advance();
      step();
    }, 100);
    return () => {
      window.clearInterval(interval);
      advance();
    };
  }, [running]);

  // The list changed: new effects enter, renewed ones restart, missing ones leave.
  const sync = useEffectEvent((items: EffectTimerItem[]) => {
    const ids = new Set(items.map((item) => item.id));
    for (const id of expired.current.keys()) if (!ids.has(id)) expired.current.delete(id);
    for (const item of items) {
      const stamp = `${item.seconds}|${item.renew}`;
      const row = rows.current.find((other) => other.id === item.id && other.phase === 'live');
      if (!row) {
        if (expired.current.get(item.id) === stamp) continue;
        expired.current.delete(item.id);
        rows.current.push({
          key: `${item.id}-${++serial.current}`,
          id: item.id,
          kind: item.kind,
          label: item.label,
          seconds: item.seconds,
          renew: item.renew,
          ends: clock.current + item.seconds * 1000,
          renewed: 0,
          phase: 'live',
          warned: item.seconds <= warnAt,
        });
        announce(`${item.label}, ${spoken(item.seconds)}`);
        continue;
      }
      row.kind = item.kind;
      row.label = item.label;
      if (item.seconds !== row.seconds || item.renew !== row.renew) {
        row.seconds = item.seconds;
        row.renew = item.renew;
        row.ends = clock.current + item.seconds * 1000;
        row.renewed++;
        row.warned = item.seconds <= warnAt;
        announce(`${item.label} renewed, ${spoken(item.seconds)}`);
      }
    }
    for (const row of rows.current) if (row.phase === 'live' && !ids.has(row.id)) leave(row);
    render();
  });
  useEffect(() => sync(list), [list]);

  // The demo: a short run of play, again and again while it may play.
  useEffect(() => {
    if (!demo) return;
    const steps = DEMO.map(({ at, act }) => window.setTimeout(() => setOwn(act), at));
    const loop = window.setInterval(() => {
      DEMO.forEach(({ at, act }) => steps.push(window.setTimeout(() => setOwn(act), at)));
    }, DEMO_LENGTH);
    return () => {
      steps.forEach((timer) => window.clearTimeout(timer));
      window.clearInterval(loop);
    };
  }, [demo]);

  /** Taking a supply: a new effect, or the same one renewed to its full time. */
  const takeSupply = useCallback(
    (kind: EffectKind) => {
      take();
      const { label, seconds } = supply(kind);
      setOwn((now) =>
        now.some((item) => item.id === kind)
          ? now.map((item) =>
              item.id === kind ? { ...item, seconds, renew: (item.renew ?? 0) + 1 } : item,
            )
          : [...now, { id: kind, kind, label, seconds }],
      );
    },
    [take],
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-stage={controlled ? undefined : ''}
      data-backing={backing || undefined}
      onPointerDown={controlled ? undefined : take}
      onKeyDown={controlled ? undefined : take}
    >
      <ul className={styles.list} aria-label="Active effects" data-part="list">
        {view.map((row) => {
          const seconds = row.left;
          const warn = row.phase === 'ending' || (row.phase === 'live' && seconds <= warnAt);
          return (
            <li
              key={row.key}
              className={styles.effect}
              data-part="effect"
              data-phase={row.phase}
              data-warn={warn || undefined}
              style={
                {
                  '--tint': TINTS[row.kind],
                  '--flip': `${row.renewed * 180}deg`,
                } as CSSProperties
              }
            >
              <EffectIcon kind={row.kind} className={styles.icon} part="icon" />
              <span className={styles.label} data-part="label">
                {row.label}
              </span>
              <Hourglass className={styles.glass} part="hourglass" />
              {/* Keyed so that its animations play again: each second while warning (a pulse),
                  and on each renewal (a flash). */}
              <time
                key={warn ? `w${seconds}` : `r${row.renewed}`}
                className={styles.time}
                data-part="time"
                data-flash={row.renewed > 0 && !warn ? '' : undefined}
                dateTime={`PT${seconds}S`}
                aria-label={spoken(seconds)}
              >
                {format(seconds)}
              </time>
            </li>
          );
        })}
      </ul>
      {!controlled && (
        <div className={styles.supplies} role="group" aria-label="Supplies" data-part="supplies">
          {SUPPLIES.map((item) => (
            <button
              key={item.kind}
              type="button"
              className={styles.supply}
              data-part="supply"
              style={{ '--tint': TINTS[item.kind] } as CSSProperties}
              onClick={() => takeSupply(item.kind)}
            >
              <EffectIcon kind={item.kind} className={styles.supplyIcon} />
              <span className={styles.supplyLabel}>{item.label}</span>
            </button>
          ))}
        </div>
      )}
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### effect-timer/effect-timer.module.css

```css
/* Effect timer (effect-timer.tsx): the HUD's list of effects, alone and centred, with a row of
   supplies to take under it when it keeps its own list. The rows' sizes are in em of their type
   (30 px at full size), so a narrow stage draws them smaller (a container query); --effect-size
   sets it.

   Colours are the tokens below. The HUD keeps its game colours on any page, as a game's does:
   light words over a dark banner, each word, icon and hourglass with a thin dark outline drawn
   as a shadow behind its sharp shape. Only the banner's depth and the supplies change with the
   scheme, through light-dark(light, dark).

   Motion: rows enter, leave and close up by transitions of height (auto, through
   interpolate-size), translate and opacity; the icon's pop, the renewal's flash, the warning's
   pulse and the run-out blinks are keyframes on the parts they move. */

.root {
  /* The HUD: its words and hourglass, their outline, the warning, and the banner behind a row. */
  --ink: #ecebe2;
  --glass: #fdfdfb;
  --outline: rgb(8 8 6 / 0.9);
  --ink-shadow: rgb(0 0 0 / 0.35);
  --warn: #ff3b30;
  --plate: rgb(18 18 16 / 0.84);
  /* The supplies: plain chips of the page. */
  --chip: #fff;
  --chip-hover: #f3f3f1;
  --chip-edge: rgb(0 0 0 / 0.12);
  --chip-ink: #1d1d1b;
  --badge: #171715;
  --focus: #1d1d1b;
  position: relative;
  container-type: inline-size;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 28px;
  width: 100%;
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  interpolate-size: allow-keywords;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --plate: light-dark(rgb(18 18 16 / 0.84), rgb(54 54 48 / 0.8));
    --chip: light-dark(#fff, #1e1e1c);
    --chip-hover: light-dark(#f3f3f1, #282826);
    --chip-edge: light-dark(rgb(0 0 0 / 0.12), rgb(255 255 255 / 0.13));
    --chip-ink: light-dark(#1d1d1b, #ecebe6);
    --badge: light-dark(#171715, #0c0c0b);
    --focus: light-dark(#1d1d1b, #fff);
  }
}
/* 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;
}
/* Keeping its own list: a stage of its own height, so the supplies stay put as rows come and go. */
.root[data-stage] {
  min-height: 340px;
  padding: 32px 16px;
}

/* The list: one grid, so every row's parts line up in columns (each row is a subgrid). */
.list {
  display: grid;
  grid-template-columns: repeat(4, auto);
  align-content: start;
  justify-content: center;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--effect-size, clamp(16px, 5.2cqi, 30px));
  line-height: 1.2;
  /* The words' outline: four thin dark shadows around each letter, and a soft one under it. */
  --word-outline:
    0.035em 0 0 var(--outline), -0.035em 0 0 var(--outline), 0 0.035em 0 var(--outline),
    0 -0.035em 0 var(--outline), 0 0.05em 0.12em var(--ink-shadow);
}
/* Room for four rows, the list growing down from a fixed top left corner as a HUD's does, so
   nothing shifts as rows come and go. */
.root[data-stage] .list {
  justify-content: start;
  width: 14em;
  max-width: 100%;
  min-height: 7.4em;
}

.effect {
  display: grid;
  grid-column: 1 / -1;
  grid-template-columns: subgrid;
  align-items: center;
  box-sizing: border-box;
  height: auto;
  margin-bottom: 0.16em;
  padding: 0.24em 1.3em 0.24em 0.75em;
  overflow: clip;
  overflow-clip-margin: 0.4em;
  translate: 0 0;
  transition:
    height 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
    padding 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
    margin 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
    translate 340ms cubic-bezier(0.2, 0.9, 0.25, 1.15),
    opacity 240ms ease-out;
}
.root[data-backing] .effect {
  background: linear-gradient(
    90deg,
    transparent,
    var(--plate) 0.6em,
    var(--plate) calc(100% - 1.1em),
    transparent
  );
}
@starting-style {
  .effect {
    height: 0;
    margin-bottom: 0;
    padding-block: 0;
    opacity: 0;
    translate: -1.4em 0;
  }
}
.effect[data-phase='ending'] {
  animation: blink 720ms steps(1, end);
}
.effect[data-phase='out'] {
  height: 0;
  margin-bottom: 0;
  padding-block: 0;
  opacity: 0;
  translate: -1.4em 0;
  transition:
    height 300ms 40ms cubic-bezier(0.4, 0, 0.6, 1),
    padding 300ms 40ms cubic-bezier(0.4, 0, 0.6, 1),
    margin 300ms 40ms cubic-bezier(0.4, 0, 0.6, 1),
    translate 260ms cubic-bezier(0.5, 0, 0.9, 0.5),
    opacity 220ms ease-in;
}

/* The effect's icon, in its colour, popping in with the row. */
.icon {
  width: 1.144em;
  height: 1.144em;
  color: var(--tint);
  /* A thin dark outline: a shadow behind the sharp shape, never a blur of it. */
  filter: drop-shadow(0 0 0.04em var(--outline)) drop-shadow(0 0.03em 0.02em var(--outline));
  animation: pop 420ms 90ms cubic-bezier(0.3, 1.5, 0.5, 1) backwards;
}

.label {
  margin-left: 0.68em;
  font-style: italic;
  font-weight: 400;
  letter-spacing: 0.035em;
  white-space: nowrap;
  text-shadow: var(--word-outline);
}

.glass {
  width: 0.52em;
  height: 0.84em;
  margin-left: 0.74em;
  color: var(--glass);
  rotate: var(--flip);
  transition:
    rotate 520ms cubic-bezier(0.5, -0.4, 0.3, 1.4),
    color 200ms;
  filter: drop-shadow(0 0 0.04em var(--outline)) drop-shadow(0 0.03em 0.02em var(--outline));
}
.effect[data-warn] .glass {
  color: var(--warn);
  animation: shake 1s ease-in-out infinite;
}

.time {
  display: inline-block;
  margin-left: 0.26em;
  font-style: italic;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  text-shadow: var(--word-outline);
  transform-origin: 30% 55%;
}
.time[data-flash] {
  animation: flash 620ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.effect[data-warn] .time {
  color: var(--warn);
  text-shadow:
    var(--word-outline),
    0 0 0.35em color-mix(in srgb, var(--warn) 55%, transparent);
  animation: pulse 900ms cubic-bezier(0.2, 0.8, 0.3, 1);
}

@keyframes pop {
  from {
    scale: 0.3;
    opacity: 0;
  }
  60% {
    scale: 1.18;
    opacity: 1;
  }
}
@keyframes flash {
  from {
    scale: 1.22;
    color: #fff;
    text-shadow:
      0 0 0.3em var(--tint),
      0 0 0.8em var(--tint);
  }
}
@keyframes pulse {
  from {
    scale: 1.14;
    text-shadow:
      0 0 0.3em var(--warn),
      0 0 0.7em var(--warn);
  }
}
@keyframes shake {
  0%,
  30%,
  100% {
    translate: 0 0;
  }
  8% {
    translate: -0.06em 0;
  }
  16% {
    translate: 0.06em 0;
  }
  24% {
    translate: -0.03em 0;
  }
}
@keyframes blink {
  0%,
  33%,
  66% {
    opacity: 1;
  }
  16%,
  50%,
  83% {
    opacity: 0.15;
  }
}

/* The supplies: a row of plain round-ended chips under the list, each with its effect's icon on
   a small dark badge, so the icon keeps its game colour on any page. */
.supplies {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  max-width: 100%;
}
.supply {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 36px;
  padding: 0 14px 0 6px;
  border: 1px solid var(--chip-edge);
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-ink);
  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);
}
.supply:hover {
  background: var(--chip-hover);
}
.supply:active {
  scale: 0.95;
}
.supply:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.supplyIcon {
  box-sizing: border-box;
  width: 24px;
  height: 24px;
  padding: 4px;
  border-radius: 50%;
  background: var(--badge);
  color: var(--tint);
}
@container (max-width: 479px) {
  .supply {
    padding: 0 6px;
  }
  .supplyLabel {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

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

@media (prefers-reduced-motion: reduce) {
  .effect,
  .effect[data-phase='out'],
  .glass,
  .supply {
    transition: none;
  }
  .icon,
  .time[data-flash],
  .effect[data-phase='ending'],
  .effect[data-warn] .glass,
  .effect[data-warn] .time {
    animation: none;
  }
}
```

### effect-timer/icons.tsx

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

import type { ReactNode } from 'react';

/*
 * The effect icons and the hourglass: flat, bold shapes in a 24-unit box, filled with
 * currentColor so each row tints its own. Each kind has a colour that says its element at a
 * glance (red for heat, blue for cold, yellow for shock...). All decorative: the row's words say
 * what the effect is.
 */

export type EffectKind = 'heat' | 'cold' | 'shock' | 'swift' | 'stealth' | 'might' | 'guard';

/** Each kind's colour, read by the row as --tint. */
export const TINTS: Record<EffectKind, string> = {
  heat: '#fd1b2e',
  cold: '#36b6ff',
  shock: '#ffd21a',
  swift: '#3fe46b',
  stealth: '#b67cff',
  might: '#ff7a1a',
  guard: '#8ec3ff',
};

const SHAPES: Record<EffectKind, ReactNode> = {
  // A flame: a round bowl rising to a tall tip, with a bay cut between the tip and a small
  // lobe on its right.
  heat: (
    <path d="M12.8 1.6c.6 3.2-.5 5.6-.8 8-.3 3 .6 5.6 2.6 5.6 1.9 0 3.2-2.2 3.4-5.6 1.3 1.6 2 3.6 2 5.9 0 4.7-3.6 7.5-8 7.5s-8-2.8-8-7.5c0-6 9-8 8.8-13.9Z" />
  ),
  // A six-armed snowflake, in strokes.
  cold: (
    <g fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
      <path d="M12 2.5v19M3.8 7.25l16.4 9.5M3.8 16.75l16.4-9.5" />
      <path d="M9.4 3.9 12 6.3l2.6-2.4M9.4 20.1l2.6-2.4 2.6 2.4M3.4 10.6l3.4-1.1-.8-3.4M20.6 13.4l-3.4 1.1.8 3.4M6 17.9l.8-3.4-3.4-1.1M18 6.1l-.8 3.4 3.4 1.1" />
    </g>
  ),
  // A lightning bolt.
  shock: <path d="M14.2 1.5 4.8 13.6h6.3L9.6 22.5l9.6-12.4h-6.4l1.4-8.6Z" />,
  // Two chevrons, leaning forward.
  swift: (
    <path d="M3 4.5h4.6l7.3 7.5-7.3 7.5H3l7.3-7.5L3 4.5Zm7.4 0H15l7 7.5-7 7.5h-4.6l7-7.5-7-7.5Z" />
  ),
  // A crescent moon.
  stealth: <path d="M14.6 2.4a9.8 9.8 0 1 0 7 16.7A8 8 0 0 1 14.6 2.4Z" />,
  // An upright blade with its guard and grip.
  might: (
    <path d="M12 1.5 14.4 5v9.6H9.6V5L12 1.5ZM6.5 15.4h11v2.4h-11v-2.4Zm4.2 3.2h2.6v2.6l1 .9-2.3 1.4-2.3-1.4 1-.9v-2.6Z" />
  ),
  // A shield.
  guard: (
    <path d="M12 1.8 20.2 5v6.6c0 5.1-3.5 9-8.2 10.6-4.7-1.6-8.2-5.5-8.2-10.6V5L12 1.8Zm0 3v14.6c3.1-1.4 5.3-4.2 5.3-7.8V6.9L12 4.8Z" />
  ),
};

export function EffectIcon({
  kind,
  className,
  part,
}: {
  kind: EffectKind;
  className?: string;
  /** A data-part name, for the docs to find it. */
  part?: string;
}) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      data-part={part}
    >
      {SHAPES[kind]}
    </svg>
  );
}

/** A solid hourglass: two caps, the glass, and the sand settled in two lines at the bottom. */
export function Hourglass({ className, part }: { className?: string; part?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 13 21"
      fill="currentColor"
      aria-hidden="true"
      data-part={part}
    >
      <path d="M0 0h13v2.2H0V0Zm1.5 2.2h10c0 3.3-1.7 5.6-3.7 8.3 2 2.7 3.7 5 3.7 7.3h-10c0-2.3 1.7-4.6 3.7-7.3-2-2.7-3.7-5-3.7-8.3Z" />
      <path d="M0 18.7h13V21H0v-2.3Z" />
    </svg>
  );
}
```

### effect-timer/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. `visible` alone says whether it is on screen
 * in a visible tab, for clocks that should stop when nobody can see them.
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```