Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Heart meter

New

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.

heart-meter/heart-meter.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/heart-meter of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/heart-meter.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

player-hud.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} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
valuenumber—HeartGauge: health in hearts, by quarters, from 0 to max. On HeartMeter it turns the playground into a plain display of this value.
maxnumber3 (HeartMeter: 5)Heart containers, 1 to 30: how much health there can be. On HeartMeter without value, the count the fight starts with.
extranumber0Temporary extra hearts, drawn yellow after the red ones and lost first.
perRownumber15Hearts per row before the row wraps.
lownumber1At this many hearts or fewer (and no yellow ones), the last heart beats and glows red.
platebooleantrueHeartGauge: the dark plate behind the row.
sizenumber—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.
labelstring'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.
autoplaybooleantrueHeartMeter: 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.
classNamestring—Added to the root element.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

heart-meter.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
heart-meter.module.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;
use-autoplay.ts
'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]) => {
  • 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.