# Heart meter

> A game HUD heart meter: quarter hearts, yellow extra hearts, a hit flash and shake, a beating last heart, and heals that fill one quarter at a time.

The row of hearts in a game HUD’s corner, on its own dark plate. Health is counted in quarters, so a partial heart fills by quadrants; yellow extra hearts follow the red ones and are lost first. A hit flashes the lost quarters white and shakes the row, one heart or less makes the last heart beat red, a heal fills quarter by quarter with a pop per heart, and pickups show their recovery chip: hearts restored, Full Recovery, or + 10 extra hearts.

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

Category: Games · HUD.

## Anatomy

1. **Plate**: The `role="meter"` box: a dark plate 0.7 heart in at the sides and a third of one at the top and bottom, with a hairline just inside its edge, so the row reads on a light page and a dark one.
2. **Row**: A grid of one column per heart, 3/13 of a heart apart, wrapping every `perRow` (15). Red hearts come first, then the yellow ones.
3. **Full heart**: One heart of health: a red fill over its dark container, 13 wide by 12 tall in the kit’s proportions (`size` sets the width, 24 px by default).
4. **Partial heart**: Health counts in quarters: a partial heart is clipped to its quadrants, top left first, then bottom left, bottom right and top right. Here a quarter.
5. **Empty container**: A heart container with no health in it, drawn dark: `max` sets how many there are.
6. **Extra heart**: A temporary yellow heart (`extra`) after the red ones. A hit takes these first; one spent whole flashes out from its place.
7. **Recovery chip**: What a pickup restores, on the same plate: one to five hearts, a heart with the count past five, Full Recovery, or a yellow heart and + 10.

## When to use

- Health in a game HUD that the player reads at a glance as a count of hits left, not as a percentage.
- Damage and healing in small, countable steps: a quarter heart per graze, a heart per hit, three per meal.
- A health pool that grows over the game, one heart container at a time, up to 30.
- Temporary bonus health from food or potions that should read as different and be lost first: the yellow hearts.
- A HUD that has to warn without words: the last heart beats and glows red at one heart or less.

## When not to use

- Health in the hundreds or with fine-grained damage. Use a health bar, with a number if it matters.
- A resource the player spends and that refills on its own, such as sprint. Use a stamina wheel.
- Shields, armour or anything that should read as a second layer over health. Use a separate bar or a ring over the hearts.
- A rating or a score out of five outside a game. Use a star rating, which a reader sets and does not lose.
- Progress through a task or a level. Use a progress bar; hearts read as something that can run out.

## Installation

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

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

export function PlayerHud({ health, containers, extra }: { health: number; containers: number; extra: number }) {
  return <HeartGauge value={health} max={containers} extra={extra} />;
}
```

## States

- **Full**: `value` equal to `max`: every container full.
- **Damaged**: `value={3.5}` of 5: the hearts empty from the right, the partial one by quadrants.
- **Quarters**: One heart at ¼, ½, ¾ and full: top left, then the left half, then all but the top right.
- **Low**: `value={0.75}`: at `low` hearts or fewer with no yellow ones, the last heart double-beats with a red glow and the hairline blinks.
- **Down**: `value={0}`: every container dark, nothing beats; the meter says “down”.
- **Extra hearts**: `extra={2.5}`: yellow hearts after the red ones, also counted in quarters and lost first.
- **Low with extra**: `value={0.5}` with `extra={1}`: a yellow heart still stands in front, so the last red heart does not beat.
- **Two rows**: `max={20}` with `perRow={10}`: the row wraps; containers, red and yellow together stop at 30.
- **No plate**: HeartGauge with `plate={false}`, for a HUD whose own scene or panel gives the hearts their footing.
- **Recovery chips**: HeartChip for each kind of pickup: three hearts, eight (a count), Full Recovery and + 10 extra.
- **Playground**: HeartMeter with `autoplay={false}`: the row, the chip’s place and the five actions, by button or key.

## How it works

- Every heart is one original SVG path in a 26 × 24 box, drawn three times: the dark container, the coloured fill clipped to 0–4 quadrants, and for a moment after a hit a white copy clipped to what it held before. The four quadrant clips are defined once per row; quarters fill top left, bottom left, bottom right, then top right, split at the notch and just above the middle.
- The row keeps what it shows apart from its value: a drop is shown at once, a rise steps one quarter every 45 ms. Each render compares every heart’s quarters with the last one, by heart, to find hits (a white flash that fades over 460 ms where the lost quarters were), completions (the fill pops from 1.24× with a brief brightening) and newcomers (a new container or yellow heart pops in with an overshoot, 40 ms after the one before, a container with a soft spark).
- A hit also shakes the whole row once, a 280 ms horizontal shake that settles, through the Web Animations API. At one heart or less, with no yellow hearts left, the last red heart gives a double beat every second with a red glow and the plate’s hairline blinks red, until healed.
- Yellow extra hearts follow the red ones in the same grid and are spent first; one spent whole flashes out from its place before it is dropped. Containers, red and yellow hearts together stop at 30, wrapping every 15 (the perRow prop).
- Proportions come from the kit: a heart 13 wide and 12 tall, 3 apart, on a plate padded 0.7 heart at the sides and a third of one at the top and bottom, with a hairline just inside its edge. Everything is a multiple of one heart’s width, 24 px by default and less when the stage is too narrow for a full row (a container query unit).
- The recovery chip is the same plate: one to five hearts, or a heart with the count past five, or a heart and a green label, “Full Recovery” or “+ 10”. In the playground it wipes in from the left, holds for 1.9 s and fades.
- The playground runs a 23.5-second scripted fight while on screen and untouched: three hits down to one heart, a meal, a golden dish, two hits into the yellow, a new container, a heavy hit through the yellow and another to under a heart, then an elixir. Any real press hands it over.
- The red, yellow, dark containers, plate and green label are the same on any scheme, as a game’s are; only the plate’s outer edge and the buttons follow the page through light-dark() tokens.

## API reference

- **value** (`number`): HeartGauge: health in hearts, by quarters, from 0 to max. On HeartMeter it turns the playground into a plain display of this value.
- **max** (`number`, default `3 (HeartMeter: 5)`): Heart containers, 1 to 30: how much health there can be. On HeartMeter without value, the count the fight starts with.
- **extra** (`number`, default `0`): Temporary extra hearts, drawn yellow after the red ones and lost first.
- **perRow** (`number`, default `15`): Hearts per row before the row wraps.
- **low** (`number`, default `1`): At this many hearts or fewer (and no yellow ones), the last heart beats and glows red.
- **plate** (`boolean`, default `true`): HeartGauge: the dark plate behind the row.
- **size** (`number`): One heart’s width in px. Without it, 32 on a stage 520 px or wider, else 24, and less when the stage is too narrow for a full row.
- **label** (`string`, default `'Health'`): HeartGauge: what a screen reader calls the meter.
- **pickup** (`{ kind: 'hearts'; amount: number } | { kind: 'full' } | { kind: 'extra'; amount: number }`): HeartChip: what a pickup gives: hearts restored (a count past five), Full Recovery, or + extra hearts.
- **autoplay** (`boolean`, default `true`): HeartMeter: while on screen and untouched, a scripted fight plays the actions by itself.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark plate edge and buttons, or the reader’s (system); without it, the page’s color-scheme.
- **className** (`string`): Added to the root element.

## Accessibility

- The row is a meter (role="meter") with its value in hearts and a value text such as “3¾ of 5 hearts, plus 10 extra, low”; the hearts themselves are aria-hidden SVG.
- A recovery chip is an image with a label (“Restores 3 hearts”, “Full recovery”, “10 extra hearts”).
- The actions are real buttons with their keys in aria-keyshortcuts. The stage can take focus: then H takes a hit, E eats, R drinks an elixir, G eats a golden dish and U adds a heart container.
- What each action did is announced through a polite live region, only for the reader’s own play, never for the scripted fight.
- Under reduced motion the fight does not play, heals jump straight to the value, there is no shake, pop or beat (a steady red glow marks low health) and chips only fade.

## Source

### heart-meter/heart-meter.tsx

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

'use client';

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './heart-meter.module.css';

/*
 * Heart meter: the row of hearts in a game HUD's corner that tells the player how much damage they
 * can still take.
 *
 * - HeartGauge is the row alone, driven by props: `value` hearts of health out of `max` heart
 *   containers, counted in quarters, and `extra` temporary yellow hearts after the red ones, which
 *   a hit takes first. An empty container is a dark heart; a partial one fills by quadrants, top
 *   left first, then bottom left, bottom right and top right, so a quarter left is the top-left
 *   corner.
 * - Every heart is the same small SVG path drawn three times: the dark container, the coloured
 *   fill clipped to 0–4 quadrants (four shared clip paths), and, for a moment after a hit, a white
 *   copy clipped to what it held before, which flashes and fades where the lost quarters were.
 * - The gauge keeps what it shows apart from `value`: a drop is shown at once (the white flash and
 *   a short shake of the whole row), a rise fills quarter by quarter, 45 ms each, and every heart
 *   that completes gives a small pop. New containers and new yellow hearts pop in, one after the
 *   other. At one heart or less (and no yellow hearts), the last heart beats and glows red.
 * - Like the kit's menus, the row sits on its own dark plate (a hairline inside its edge), so the
 *   red, the yellow and the dark containers read the same on a white page and a black one.
 * - HeartChip is the recovery chip a pickup shows: hearts restored (one heart and a count past
 *   five), "Full Recovery", or a yellow heart and "+ 10" for extra hearts.
 * - HeartMeter is a playground around them: the row on a plain stage, the last pickup's chip
 *   under it, and five actions (take a hit, eat, drink an elixir, eat a golden dish, gain a heart
 *   container), by buttons or keys. While on screen and untouched, a short scripted fight plays
 *   them by itself. Give it `value` and it is a plain display instead.
 */

/** The heart, in a 26 × 24 box: wide lobes, a shallow notch, a near-point at the bottom. */
const HEART =
  'M13 23.4 C12.6 23.4 12.2 23.2 11.9 22.9 L2.6 13.6 C0.9 11.9 0 9.7 0 7.3 C0 3.2 3.1 0.4 6.8 0.4 C9.4 0.4 11.6 1.7 13 3.8 C14.4 1.7 16.6 0.4 19.2 0.4 C22.9 0.4 26 3.2 26 7.3 C26 9.7 25.1 11.9 23.4 13.6 L14.1 22.9 C13.8 23.2 13.4 23.4 13 23.4 Z';
/** Where the quadrants meet: the notch's x, and a little above the middle. */
const MID_X = 13;
const MID_Y = 11.5;
/** The clip of each quarter count: top left; left half; left half and bottom right. */
const QUARTERS = [
  '',
  `M0 0 H${MID_X} V${MID_Y} H0 Z`,
  `M0 0 H${MID_X} V24 H0 Z`,
  `M0 0 H${MID_X} V${MID_Y} H26 V24 H0 Z`,
];

/** A heal fills one quarter this often, in ms. */
const STEP = 45;
/** The most hearts (red and yellow together) the row draws. */
const MOST = 30;

type Kind = 'red' | 'gold';

/** One heart's art: the dark container, the white flash of a hit, and the fill. */
function HeartArt({
  q,
  clip,
  ghost,
  pop,
  bare = false,
}: {
  q: number;
  clip: string;
  ghost?: { key: number; from: number };
  pop?: number;
  /** No dark container: a spent yellow heart's last flash. */
  bare?: boolean;
}) {
  const clipOf = (quarters: number) => (quarters >= 4 ? undefined : `url(#${clip}-${quarters})`);
  return (
    <svg className={styles.art} viewBox="0 0 26 24" aria-hidden="true" focusable="false">
      {!bare && <path d={HEART} className={styles.empty} />}
      {ghost && ghost.from > q && (
        <path
          key={`g${ghost.key}`}
          d={HEART}
          className={styles.ghost}
          clipPath={clipOf(ghost.from)}
        />
      )}
      {q > 0 && (
        <path
          key={`f${pop ?? 0}`}
          d={HEART}
          className={styles.fill}
          data-pop={pop ? '' : undefined}
          clipPath={clipOf(q)}
        />
      )}
    </svg>
  );
}

/** The four quadrant clips, once per gauge, in the heart's own 26 × 24 units. */
function Clips({ id }: { id: string }) {
  return (
    <svg className={styles.defs} aria-hidden="true" focusable="false">
      <defs>
        {QUARTERS.map((d, quarters) =>
          d ? (
            <clipPath key={quarters} id={`${id}-${quarters}`} clipPathUnits="userSpaceOnUse">
              <path d={d} />
            </clipPath>
          ) : null,
        )}
      </defs>
    </svg>
  );
}

/** "3¾": hearts in quarters, as a player reads them. */
function hearts(value: number) {
  const whole = Math.floor(value + 1e-6);
  const part = ['', '¼', '½', '¾'][Math.round((value - whole) * 4) % 4];
  return whole || !part ? `${whole}${part}` : part;
}

type Effects = {
  /** Each slot's quarters at the last render, by slot key. */
  seen: Record<string, number>;
  /** A slot's last hit: what it held before, keyed so the flash restarts. */
  ghosts: Record<string, { key: number; from: number }>;
  /** A red slot's last completion, keyed so the pop restarts. */
  pops: Record<string, number>;
  /** Slots that arrived after the first render, with their place in the arrival. */
  born: Record<string, number>;
  /** Yellow hearts just spent: drawn as a fading flash where they were, then dropped. */
  leaving: { key: string; from: number; tick: number }[];
  /** Counts the hits, for the shake. */
  hits: number;
  tick: number;
};

export type HeartGaugeProps = {
  /** Health, in hearts, by quarters: from 0 to `max`. */
  value: number;
  /** Heart containers: how much health there can be (1 to 30). */
  max?: number;
  /** Temporary extra hearts, drawn yellow after the red ones and lost first. */
  extra?: number;
  /** Hearts per row before the row wraps. */
  perRow?: number;
  /** At this many hearts or fewer (and no yellow ones), the last heart beats. */
  low?: number;
  /** The dark plate behind the row. */
  plate?: boolean;
  /** One heart's width in px; without it, 24 (it shrinks to fit a narrow stage). */
  size?: number;
  /** What a screen reader calls it. */
  label?: string;
  className?: string;
};

export function HeartGauge({
  value,
  max = 3,
  extra = 0,
  perRow = 15,
  low = 1,
  plate = true,
  size,
  label = 'Health',
  className = '',
}: HeartGaugeProps) {
  const id = useId().replace(/:/g, '');
  const most = Math.min(MOST, Math.max(1, Math.round(max)));
  const target = Math.round(Math.min(most, Math.max(0, value)) * 4);
  const extraQ = Math.round(Math.min(MOST - most, Math.max(0, extra)) * 4);

  // What is shown: a drop at once, a rise quarter by quarter (all at once under reduced motion).
  const [shown, setShown] = useState(target);
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const read = () => setReduced(motion.matches);
    read();
    motion.addEventListener('change', read);
    return () => motion.removeEventListener('change', read);
  }, []);
  if (shown > target || (reduced && shown !== target)) setShown(target);
  useEffect(() => {
    if (shown >= target) return;
    const timer = window.setTimeout(() => setShown((q) => Math.min(target, q + 1)), STEP);
    return () => window.clearTimeout(timer);
  }, [shown, target]);
  const now = Math.min(shown, target);

  // The slots, red then yellow, each with its quarters.
  const slots: { key: string; kind: Kind; q: number }[] = [];
  for (let i = 0; i < most; i++)
    slots.push({ key: `r${i}`, kind: 'red', q: Math.min(4, Math.max(0, now - i * 4)) });
  for (let i = 0; i * 4 < extraQ; i++)
    slots.push({ key: `g${i}`, kind: 'gold', q: Math.min(4, extraQ - i * 4) });

  // What changed since the last render: hits flash, completions pop, newcomers pop in.
  const [fx, setFx] = useState<Effects>(() => ({
    seen: Object.fromEntries(slots.map((slot) => [slot.key, slot.q])),
    ghosts: {},
    pops: {},
    born: {},
    leaving: [],
    hits: 0,
    tick: 0,
  }));
  const changed =
    slots.length !== Object.keys(fx.seen).length ||
    slots.some((slot) => fx.seen[slot.key] !== slot.q);
  if (changed) {
    const next: Effects = {
      seen: {},
      ghosts: { ...fx.ghosts },
      pops: { ...fx.pops },
      born: {},
      leaving: fx.leaving.filter((gone) => !slots.some((slot) => slot.key === gone.key)),
      hits: fx.hits,
      tick: fx.tick,
    };
    let hit = false;
    let arrival = 0;
    for (const slot of slots) {
      const before = fx.seen[slot.key];
      next.seen[slot.key] = slot.q;
      if (before === undefined) {
        next.born[slot.key] = arrival++;
        continue;
      }
      if (fx.born[slot.key] !== undefined) next.born[slot.key] = fx.born[slot.key];
      if (slot.q < before) {
        next.ghosts[slot.key] = { key: ++next.tick, from: before };
        hit = true;
      } else if (slot.q === 4 && before < 4 && slot.kind === 'red') {
        next.pops[slot.key] = ++next.tick;
      }
    }
    // A yellow heart spent whole flashes out from its last place (a red container that goes
    // away, as when the playground starts over, just goes).
    for (const [key, before] of Object.entries(fx.seen))
      if (!(key in next.seen) && key[0] === 'g' && before > 0) {
        next.leaving.push({ key, from: before, tick: ++next.tick });
        hit = true;
      }
    next.leaving.sort((a, b) => Number(a.key.slice(1)) - Number(b.key.slice(1)));
    if (hit) next.hits++;
    setFx(next);
  }

  // Spent yellow hearts are dropped once their flash is over.
  useEffect(() => {
    if (!fx.leaving.length) return;
    const timer = window.setTimeout(() => setFx((f) => ({ ...f, leaving: [] })), 480);
    return () => window.clearTimeout(timer);
  }, [fx.leaving]);

  // A hit shakes the row: short, sharp, settling.
  const row = useRef<HTMLDivElement>(null);
  const shakes = useRef(fx.hits);
  useEffect(() => {
    if (fx.hits === shakes.current) return;
    shakes.current = fx.hits;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    row.current?.animate(
      [
        { translate: '0 0' },
        { translate: 'calc(var(--heart) * -0.16) 0' },
        { translate: 'calc(var(--heart) * 0.13) 0' },
        { translate: 'calc(var(--heart) * -0.08) 0' },
        { translate: 'calc(var(--heart) * 0.04) 0' },
        { translate: '0 0' },
      ],
      { duration: 280, easing: 'cubic-bezier(0.3, 0.6, 0.4, 1)' },
    );
  }, [fx.hits]);

  const reds = Math.min(4 * most, now);
  const isLow = reds > 0 && reds <= low * 4 && extraQ === 0;
  const beat = isLow ? `r${Math.ceil(reds / 4) - 1}` : '';
  const columns = Math.max(1, Math.min(perRow, slots.length + fx.leaving.length));

  const text =
    `${hearts(target / 4)} of ${most} ${most === 1 ? 'heart' : 'hearts'}` +
    (extraQ ? `, plus ${hearts(extraQ / 4)} extra` : '') +
    (target === 0 ? ', down' : isLow ? ', low' : '');

  return (
    <div
      className={`${styles.gauge} ${className}`}
      data-part="gauge"
      data-plate={plate || undefined}
      data-low={isLow || undefined}
      role="meter"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={most}
      aria-valuenow={target / 4}
      aria-valuetext={text}
      style={
        {
          '--columns': columns,
          ...(size ? { '--heart-prop': `${size}px` } : {}),
        } as CSSProperties
      }
    >
      <Clips id={id} />
      <div ref={row} className={styles.row} data-part="row">
        {slots.map((slot) => {
          const born = fx.born[slot.key];
          return (
            <span
              key={slot.key}
              className={styles.slot}
              data-part="heart"
              data-kind={slot.kind}
              data-q={slot.q}
              data-born={born !== undefined ? '' : undefined}
              data-beat={slot.key === beat || undefined}
              style={born !== undefined ? ({ '--i': born } as CSSProperties) : undefined}
            >
              <HeartArt q={slot.q} clip={id} ghost={fx.ghosts[slot.key]} pop={fx.pops[slot.key]} />
            </span>
          );
        })}
        {fx.leaving.map((gone) => (
          <span key={gone.key} className={styles.slot} data-kind="gold" data-leaving="">
            <HeartArt q={0} clip={id} ghost={{ key: gone.tick, from: gone.from }} bare />
          </span>
        ))}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* The recovery chip                                                                           */
/* ------------------------------------------------------------------------------------------ */

export type Pickup =
  { kind: 'hearts'; amount: number } | { kind: 'full' } | { kind: 'extra'; amount: number };

/** One heart for a chip: red or yellow, full, with an optional count on it. */
function ChipHeart({ kind, count }: { kind: Kind; count?: number }) {
  return (
    <span className={styles.chipHeart} data-kind={kind}>
      <svg className={styles.art} viewBox="0 0 26 24" aria-hidden="true" focusable="false">
        <path d={HEART} className={styles.fill} />
      </svg>
      {count !== undefined && (
        <span className={styles.count} data-part="count">
          {count}
        </span>
      )}
    </span>
  );
}

/** What a pickup gives, on its dark plate: hearts (a count past five), Full Recovery, or + extra. */
export function HeartChip({ pickup, className = '' }: { pickup: Pickup; className?: string }) {
  let art: ReactNode;
  let words = '';
  let spoken = '';
  if (pickup.kind === 'hearts') {
    const n = Math.max(1, Math.round(pickup.amount));
    art =
      n > 5 ? (
        <ChipHeart kind="red" count={n} />
      ) : (
        Array.from({ length: n }, (_, i) => <ChipHeart key={i} kind="red" />)
      );
    spoken = `Restores ${n} ${n === 1 ? 'heart' : 'hearts'}`;
  } else if (pickup.kind === 'full') {
    art = <ChipHeart kind="red" />;
    words = 'Full Recovery';
    spoken = 'Full recovery';
  } else {
    art = <ChipHeart kind="gold" />;
    words = `+ ${Math.round(pickup.amount)}`;
    spoken = `${Math.round(pickup.amount)} extra hearts`;
  }
  return (
    <span className={`${styles.chip} ${className}`} data-part="chip" role="img" aria-label={spoken}>
      <span className={styles.chipHearts}>{art}</span>
      {words && (
        <span className={styles.chipText} data-part="label">
          {words}
        </span>
      )}
    </span>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* The playground                                                                              */
/* ------------------------------------------------------------------------------------------ */

type Action = 'hit' | 'eat' | 'elixir' | 'golden' | 'container';

type World = { max: number; value: number; extra: number };

const START: World = { max: 5, value: 5, extra: 0 };

const ACTIONS: { action: Action; glyph: string; label: string; key: string }[] = [
  { action: 'hit', glyph: 'X', label: 'Take a hit', key: 'H' },
  { action: 'eat', glyph: 'A', label: 'Eat', key: 'E' },
  { action: 'elixir', glyph: 'Y', label: 'Elixir', key: 'R' },
  { action: 'golden', glyph: 'B', label: 'Golden dish', key: 'G' },
  { action: 'container', glyph: 'L', label: 'New heart', key: 'U' },
];

/** What a hit by the reader takes, in hearts, cycling. */
const BLOWS = [1.25, 0.75, 2, 0.5, 1.5, 1];

/**
 * The scripted fight, in s from its start: three hits down to one heart (it beats), a meal, a
 * golden dish's ten yellow hearts, two hits into them, a new container (it refills), a heavy hit
 * through the yellow, another down to under a heart, and an elixir.
 */
const SCRIPT: [number, Action, number?][] = [
  [1.0, 'hit', 1.25],
  [2.3, 'hit', 0.75],
  [3.5, 'hit', 2],
  [6.6, 'eat'],
  [8.6, 'golden'],
  [10.4, 'hit', 2.5],
  [11.6, 'hit', 1],
  [13.0, 'container'],
  [15.2, 'hit', 7.5],
  [16.6, 'hit', 4.25],
  [19.6, 'elixir'],
];
const SCRIPT_LENGTH = 23.5;
/** How long a pickup's chip stays, in ms. */
const CHIP_TIME = 1900;

export function HeartMeter({
  value,
  max,
  extra,
  perRow,
  low,
  size,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** Shows this health instead of playing: the playground becomes a plain display. */
  value?: number;
  /** Heart containers; in the playground, the count it starts with (5). */
  max?: number;
  /** With `value`: extra yellow hearts. */
  extra?: number;
  /** Hearts per row before the row wraps (15). */
  perRow?: number;
  /** At this many hearts or fewer, the last one beats (1). */
  low?: number;
  /** One heart's width in px; without it, 32 on a stage 520 px or wider, else 24 (and less to fit). */
  size?: number;
  /** While on screen and untouched, a scripted fight plays the actions by itself. */
  autoplay?: boolean;
  /** Light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const controlled = value !== undefined;
  const start: World = { ...START, max: max ?? START.max, value: max ?? START.value };
  const [world, setWorld] = useState<World>(start);
  // A new starting count starts the playground over with it.
  const [startMax, setStartMax] = useState(max);
  if (startMax !== max) {
    setStartMax(max);
    setWorld(start);
  }
  const [chip, setChip] = useState<{ pickup: Pickup; key: number } | null>(null);
  const [pressed, setPressed] = useState<Action | null>(null);
  const [message, setMessage] = useState('');
  const reader = useRef(false);
  const blow = useRef(0);
  const worldRef = useRef(world);
  worldRef.current = world;

  // The chip goes away on its own.
  useEffect(() => {
    if (!chip) return;
    const timer = window.setTimeout(() => setChip(null), CHIP_TIME);
    return () => window.clearTimeout(timer);
  }, [chip]);

  const quiet = useRef(0);
  const say = (text: string) => {
    if (!reader.current) return;
    setMessage(text);
    window.clearTimeout(quiet.current);
    quiet.current = window.setTimeout(() => setMessage(''), 3000);
  };
  useEffect(() => () => window.clearTimeout(quiet.current), []);

  const status = (w: World) =>
    `${hearts(w.value)} of ${w.max} hearts` + (w.extra ? `, ${hearts(w.extra)} extra` : '');

  const act = (action: Action, amount?: number) => {
    const w = { ...worldRef.current };
    let pickup: Pickup | null = null;
    if (action === 'hit') {
      const damage = amount ?? BLOWS[blow.current++ % BLOWS.length];
      const fromExtra = Math.min(w.extra, damage);
      w.extra -= fromExtra;
      w.value = Math.max(0, w.value - (damage - fromExtra));
      say(w.value === 0 ? 'Down: no hearts left.' : `Hit. ${status(w)}.`);
    } else if (action === 'eat') {
      w.value = Math.min(w.max, w.value + 3);
      pickup = { kind: 'hearts', amount: 3 };
      say(`Restored 3 hearts. ${status(w)}.`);
    } else if (action === 'elixir') {
      w.value = w.max;
      pickup = { kind: 'full' };
      say(`Full recovery. ${status(w)}.`);
    } else if (action === 'golden') {
      w.extra = Math.min(MOST - w.max, w.extra + 10);
      w.value = w.max;
      pickup = { kind: 'extra', amount: 10 };
      say(`Ten extra hearts. ${status(w)}.`);
    } else {
      if (w.max < MOST) {
        w.max += 1;
        w.extra = Math.min(w.extra, MOST - w.max);
      }
      w.value = w.max;
      say(`New heart container. ${status(w)}.`);
    }
    worldRef.current = w;
    setWorld(w);
    if (pickup) setChip((c) => ({ pickup: pickup!, key: (c?.key ?? 0) + 1 }));
    setPressed(action);
    window.setTimeout(() => setPressed((p) => (p === action ? null : p)), 140);
  };

  // The scripted fight: replays SCRIPT on a loop while playing, from the starting state.
  const run = autoplay && playing && !controlled;
  useEffect(() => {
    if (!run) return;
    reader.current = false;
    const timers: number[] = [];
    const loop = () => {
      const fresh = { ...START, max: max ?? START.max, value: max ?? START.value };
      worldRef.current = fresh;
      setWorld(fresh);
      blow.current = 0;
      for (const [at, action, amount] of SCRIPT)
        timers.push(window.setTimeout(() => act(action, amount), at * 1000));
      timers.push(window.setTimeout(loop, SCRIPT_LENGTH * 1000));
    };
    loop();
    return () => timers.forEach((timer) => window.clearTimeout(timer));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [run, max]);

  const real = () => {
    reader.current = true;
    take();
  };

  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (event.repeat || event.metaKey || event.ctrlKey || event.altKey) return;
    const found = ACTIONS.find(({ key }) => key.toLowerCase() === event.key.toLowerCase());
    if (!found) return;
    event.preventDefault();
    real();
    act(found.action);
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onKeyDown={controlled ? undefined : onKeyDown}
    >
      <div
        className={styles.stage}
        data-part="stage"
        role="group"
        aria-label={
          controlled
            ? 'Heart meter'
            : 'Heart meter playground: H takes a hit, E eats, R drinks an elixir, G eats a golden dish, U adds a heart container'
        }
        tabIndex={controlled ? undefined : 0}
      >
        <div className={styles.hud}>
          <HeartGauge
            value={controlled ? value : world.value}
            max={controlled ? (max ?? 3) : world.max}
            extra={controlled ? extra : world.extra}
            perRow={perRow}
            low={low}
            size={size}
          />
          {!controlled && (
            <div className={styles.pickup} data-part="pickup">
              {chip && <HeartChip key={chip.key} pickup={chip.pickup} className={styles.toast} />}
            </div>
          )}
        </div>
      </div>
      {!controlled && (
        <div className={styles.controls} data-part="controls" role="group" aria-label="Actions">
          {ACTIONS.map(({ action, glyph, label, key }) => (
            <button
              key={action}
              type="button"
              className={styles.action}
              data-part="action"
              data-active={pressed === action || undefined}
              aria-keyshortcuts={key}
              title={`Press ${key}`}
              onClick={() => {
                real();
                act(action);
              }}
            >
              <span className={styles.glyph} data-part="glyph">
                {glyph}
              </span>
              {label}
            </button>
          ))}
        </div>
      )}
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### heart-meter/heart-meter.module.css

```css
/* Heart meter (heart-meter.tsx): the row of hearts (.gauge), the recovery chip (.chip), and the
   playground around them (.root): the row alone on a plain stage, the last pickup's chip under
   it, and a row of action buttons.

   The hearts' red, yellow and dark containers are their own and the same on any scheme, as a
   game's are. With no game scene behind them they bring their own footing, as the kit's menus
   do: a dark plate with a hairline just inside its edge. Only the plate's outer edge (which keeps
   it apart from a black page) and the buttons follow the scheme, as light-dark() tokens. The
   component takes the color-scheme it inherits from the page, or the one its colorScheme prop
   sets (data-color-scheme).

   Sizes: everything is a multiple of --heart, one heart's width (24px, or the size prop, and less
   on a stage too narrow for a full row). The kit's proportions: a heart 13 wide and 12 tall,
   3 apart; a plate 0.7 heart in from the sides and a third of one from top and bottom. */

.gauge,
.chip {
  --red: #f1362f;
  --gold: #ffe465;
  --hollow: #363930;
  --plate: #292825;
  --hairline: #3a3936;
  --edge: #1f1e1b;
  --flash: #fff;
  --text: #6fd49c;
  --badge: #fff;
  --warn: rgb(241 54 47 / 0.85);
  --spark: rgb(255 244 200 / 0.9);
}
@supports (color: light-dark(#fff, #000)) {
  .gauge,
  .chip {
    --edge: light-dark(#1f1e1b, #4a4842);
  }
}

.gauge {
  --heart: min(
    var(--heart-prop, 24px),
    calc((100cqi - 8px) / (var(--columns) * 1.23 - 0.23 + 1.4))
  );
  --gap: calc(var(--heart) * 3 / 13);
  position: relative;
  display: inline-block;
  max-width: 100%;
  vertical-align: middle;
  line-height: 0;
}
.gauge[data-plate] {
  padding: calc(var(--heart) * 0.36) calc(var(--heart) * 0.7);
  border-radius: calc(var(--heart) * 0.12);
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--edge),
    inset 0 0 0 calc(var(--heart) * 0.08) var(--plate),
    inset 0 0 0 calc(var(--heart) * 0.08 + 1px) var(--hairline);
}

.defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.row {
  display: grid;
  grid-template-columns: repeat(var(--columns), var(--heart));
  gap: var(--gap);
}

.slot {
  position: relative;
  display: block;
  width: var(--heart);
  height: calc(var(--heart) * 12 / 13);
}
.art {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.empty {
  fill: var(--hollow);
}
.fill {
  fill: var(--red);
  transform-box: view-box;
  transform-origin: 50% 55%;
}
.slot[data-kind='gold'] .fill,
.chipHeart[data-kind='gold'] .fill {
  fill: var(--gold);
}

/* A hit: what was lost flashes white where it was and fades. */
.ghost {
  fill: var(--flash);
  animation: ghost 460ms cubic-bezier(0.3, 0, 0.6, 1) forwards;
}
@keyframes ghost {
  0%,
  30% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/* A heal: each heart that fills up pops. */
.fill[data-pop] {
  animation: pop 300ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
@keyframes pop {
  0% {
    scale: 1.24;
    filter: brightness(1.35);
  }
}

/* A newcomer (a heart container, a yellow heart) pops in, one after another, with a ring. */
.slot[data-born] {
  animation: born 380ms cubic-bezier(0.3, 1.6, 0.5, 1) calc(var(--i, 0) * 40ms) both;
}
.slot[data-born]::after {
  content: '';
  position: absolute;
  inset: calc(var(--heart) * -0.25);
  border-radius: 50%;
  box-shadow: 0 0 calc(var(--heart) * 0.4) var(--spark);
  opacity: 0;
  pointer-events: none;
  animation: spark 700ms ease-out calc(var(--i, 0) * 40ms);
}
.slot[data-born][data-kind='gold']::after {
  display: none;
}
@keyframes born {
  0% {
    scale: 0;
    opacity: 0;
  }
  40% {
    opacity: 1;
  }
}
@keyframes spark {
  20% {
    opacity: 1;
  }
}

/* Low: the last heart beats (a double beat) and glows red. */
.slot[data-beat] {
  animation: beat 1000ms ease-out infinite;
}
@keyframes beat {
  0%,
  45%,
  100% {
    scale: 1;
    filter: drop-shadow(0 0 0 transparent);
  }
  10% {
    scale: 1.26;
    filter: drop-shadow(0 0 calc(var(--heart) * 0.3) var(--warn));
  }
  22% {
    scale: 1.04;
  }
  30% {
    scale: 1.18;
    filter: drop-shadow(0 0 calc(var(--heart) * 0.24) var(--warn));
  }
}
.gauge[data-low][data-plate] {
  animation: alarm 1000ms ease-out infinite;
}
@keyframes alarm {
  0%,
  45%,
  100% {
    box-shadow:
      0 0 0 1px var(--edge),
      inset 0 0 0 calc(var(--heart) * 0.08) var(--plate),
      inset 0 0 0 calc(var(--heart) * 0.08 + 1px) var(--hairline);
  }
  12% {
    box-shadow:
      0 0 0 1px var(--edge),
      inset 0 0 0 calc(var(--heart) * 0.08) var(--plate),
      inset 0 0 0 calc(var(--heart) * 0.08 + 1px) rgb(241 54 47 / 0.6);
  }
}

/* ---- The recovery chip ---- */

.chip {
  --heart: var(--chip-heart, 24px);
  display: inline-flex;
  align-items: center;
  gap: calc(var(--heart) * 0.18);
  padding: calc(var(--heart) * 0.36) calc(var(--heart) * 0.7) calc(var(--heart) * 0.36)
    calc(var(--heart) * 0.7);
  border-radius: calc(var(--heart) * 0.12);
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--edge),
    inset 0 0 0 calc(var(--heart) * 0.08) var(--plate),
    inset 0 0 0 calc(var(--heart) * 0.08 + 1px) var(--hairline);
  line-height: 1;
  white-space: nowrap;
}
.chipHearts {
  display: flex;
  gap: var(--gap, calc(var(--heart) * 3 / 13));
}
.chipHeart {
  position: relative;
  display: block;
  width: var(--heart);
  height: calc(var(--heart) * 12 / 13);
}
.count {
  position: absolute;
  left: 50%;
  top: 46%;
  translate: -50% -50%;
  color: var(--badge);
  font-size: calc(var(--heart) * 0.5);
  font-weight: 700;
  letter-spacing: -0.04em;
}
.chipText {
  color: var(--text);
  font-size: calc(var(--heart) * 1.02);
  font-weight: 500;
  letter-spacing: 0.005em;
}

/* ---------------------------------------------------------------------------------------- */
/* The playground                                                                            */
/* ---------------------------------------------------------------------------------------- */

.root {
  /* The buttons, as quiet lab chips: a surface, a hairline, and a round inverted button glyph. */
  --chip: #fff;
  --chip-active: #ececec;
  --chip-line: rgb(0 0 0 / 0.1);
  --chip-ink: #1b1d1c;
  --glyph: #1d1f1e;
  --glyph-ink: #fff;
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  width: 100%;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --chip: light-dark(#fff, #1f2120);
    --chip-active: light-dark(#ececec, #2e3130);
    --chip-line: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1));
    --chip-ink: light-dark(#1b1d1c, #eceeed);
    --glyph: light-dark(#1d1f1e, #eceeed);
    --glyph-ink: light-dark(#fff, #151716);
    --focus: light-dark(#387bff, #7aa7ff);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The stage: transparent, the row in its middle and the chip's place under it. */
.stage {
  display: grid;
  place-items: center;
  min-height: 220px;
  padding: 24px 16px 12px;
  box-sizing: border-box;
  border-radius: 14px;
  outline: none;
}
.stage:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}
.hud {
  display: grid;
  justify-items: center;
  gap: 14px;
  max-width: 100%;
  container-type: inline-size;
  width: min(100%, 560px);
}
/* On a wide stage the playground draws its hearts larger (the size prop still wins). */
@container (min-width: 520px) {
  .hud {
    --heart-prop: 32px;
    --pickup-heart: 26px;
  }
}
.pickup {
  --chip-heart: var(--pickup-heart, 20px);
  min-height: 34px;
  font-family: inherit;
}
.toast {
  animation:
    toast-in 360ms cubic-bezier(0.2, 0.9, 0.25, 1.1) both,
    toast-out 260ms ease-in 1640ms forwards;
}
@keyframes toast-in {
  from {
    opacity: 0;
    translate: calc(var(--heart) * -0.6) 0;
    clip-path: inset(0 100% 0 0);
  }
  to {
    clip-path: inset(-4px -4px -4px -4px);
  }
}
@keyframes toast-out {
  to {
    opacity: 0;
    translate: 0 calc(var(--heart) * -0.25);
  }
}

/* The buttons, under the stage. */
.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  padding: 4px 8px 12px;
}
.action {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 36px;
  padding: 0 12px 0 5px;
  border: 0;
  border-radius: 999px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-line);
  color: var(--chip-ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.01em;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
  transition:
    background-color 120ms ease,
    transform 120ms ease;
}
.action[data-active] {
  background: var(--chip-active);
  transform: scale(0.96);
}
.action:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.glyph {
  display: grid;
  place-items: center;
  min-width: 26px;
  height: 26px;
  padding: 0 4px;
  box-sizing: border-box;
  border-radius: 13px;
  background: var(--glyph);
  color: var(--glyph-ink);
  font-size: 13px;
  font-weight: 800;
  transition: transform 120ms ease;
}
.action[data-active] .glyph {
  transform: scale(0.88);
}

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

@media (prefers-reduced-motion: reduce) {
  .fill[data-pop],
  .slot[data-born],
  .slot[data-born]::after {
    animation: none;
  }
  .ghost {
    animation-duration: 300ms;
  }
  .slot[data-beat] {
    animation: none;
    filter: drop-shadow(0 0 calc(var(--heart) * 0.24) var(--warn));
  }
  .gauge[data-low][data-plate] {
    animation: none;
  }
  .toast {
    animation:
      fade-in 160ms linear both,
      toast-out 200ms linear 1700ms forwards;
  }
  .action,
  .glyph {
    transition: none;
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
```

### heart-meter/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. `visible` and `reduced` are also returned, so
 * the component can pause its own clock offscreen and calm its effects.
 */
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 };
}
```