Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Ally tracker

New

A game HUD’s column of companions: glowing creatures with charges, turning red and refilling while they recharge.

ally-tracker/ally-tracker.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

  • A game HUD with a few companions or summons whose powers the player can call on a limited number of times, then must wait for.
  • Abilities with charges and a cooldown, where the player has to read at a glance which are ready, how many uses are left and how far a spent one has come back.
  • A column at the screen’s edge that stays out of the way of play: a dark plate and no labels until hover or focus, a number key per ally.
  • Calls the game makes as well as the player: given allies, it animates whatever the game does to the charges.

When not to use

  • A single ability on a cooldown. Use a round cooldown timer or a radial fill on its button.
  • An ability bar of eight or more actions bound to keys. Use a hotbar with key hints along the bottom.
  • Showing an ally’s health or status. Use a portrait with a health bar; this shows only charges and recharge.
  • Buffs and effects that run out on their own. Use an effect timer with a countdown ring.
  • Interfaces outside a game: a quota of credits or seats left. Use a meter with a count.

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

Into components/ally-tracker of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/ally-tracker.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

companions.tsx
import { AllyTracker, type AllyTrackerItem } from './ally-tracker';export function Companions({  allies,  onCall,}: {  allies: AllyTrackerItem[];  onCall: (id: string) => void;}) {  return <AllyTracker bare allies={allies} onUse={onCall} />;}

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 column a game keeps at the edge of the screen for the allies whose powers the player can call on. Each creature glows in its own colour with its charges at its top right; a call squashes it, bursts it with a ring and pops the count, and the last charge drops it into red, dark and empty, filling from the bottom as it recharges until it flashes back. Click an ally, or press 1 to 4.

  • Each creature is an original SVG on a 48-unit grid, drawn in stacked layers of the same shapes: a thick stroke in the ally’s colour (a crisp rim with no seams where shapes overlap), the pale body with its engraving in the colour, and, while spent, a copy in the recharge colours. The halo is one more copy of the silhouette behind them, the only layer with a filter (two drop-shadows), so the creature itself stays sharp at any pixel ratio.
  • A spent ally swaps its tokens to red: a dark body with bright red marks under a red rim. The recharge copy is cut by clip-path: inset() from the top down to the fill line, worked out on the silhouette’s own top and height (--top, --span) so the pale red rises over the creature and not over empty box. It eases between the clock’s steps with a 140 ms linear transition.
  • The moments are keyframes on the creature, which is remounted for each one so it plays again: a call squashes to 0.86 (anticipation), bursts to 1.16 with a brighter, wider glow and a ring, and settles; the last charge flashes, then shakes and flickers into the red; a call on a spent ally is refused with a shake; recharged, it flashes white in its colour from 0.72 with a ring, and the full count pops in with an overshoot.
  • The glow and the flash are registered custom properties (@property --ally-glow and --ally-flash) animated on the halo layer, its blur and its mix toward white, so a spent ally’s halo can breathe slowly on its own while it works.
  • Moments are read from changes in charges, so a controlled tracker animates what the game does: fewer charges is a call (or a spend at 0), a return from 0 is a recharge.
  • Uncontrolled, it keeps its own allies and recharges them on a clock advanced every 100 ms only while it is on screen in a visible tab and not paused. A demo plays a short run of calls while it is untouched; any input hands it to the reader for 8 s.
  • The column sits centred on a plain stage, on its own dark plate (a gradient with a hairline edge and a soft lift), as a game keeps a shade behind its glows: it reads the same on the light and the dark stage, whose colours, with the hint chip’s, are light-dark() tokens. Sizes are in em of the count’s 24 px type (a 60 px creature on a 90 px row), so a container query draws the column smaller on narrow screens, and a bare column takes its size from --ally-size.
PropTypeDefaultDescription
alliesAllyTrackerItem[]—The allies to show, controlled: each is { id, kind, label, charges, maxCharges, recharge? }, with kind one of drake, ray, fox or stag and recharge from 0 to 1 while charges is 0. The game owns the values; presses call onUse. Turns the demo off.
defaultAlliesAllyTrackerItem[]drake ×1, ray ×1, fox ×2, stag ×3Uncontrolled, the allies it starts with.
onUse(id: string) => void—Called with an ally’s id when the player calls on it while it has a charge.
onRecharged(id: string) => void—Uncontrolled, called with an ally’s id when it has recharged.
rechargeSecondsnumber12Uncontrolled, how long a spent ally takes to recharge, in seconds.
pausedbooleanfalseStops recharging, as a game’s pause menu does.
barebooleanfalseThe column on its plate alone, without the stage and the hint chip, to lay over a game of your own. Its type is 20 px, or --ally-size.
autoplaybooleantrueWhile on screen and untouched, a short run of play: calls, spent allies, recharges. Any input hands it to the reader.
colorScheme'light' | 'dark' | 'system'—Light or dark stage, or the reader’s; without it, the page’s color-scheme. The HUD keeps its colours on its plate on both.
classNamestring—Classes for the root.

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

ally-tracker.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,} from 'react';import { BeastArt, type AllyKind } from './beasts';import { useAutoplay } from './use-autoplay';import styles from './ally-tracker.module.css';/* * Ally tracker: the game HUD's column of companions, on its own dark plate so it reads on a light * stage as well as a dark one. Each ally lends a power the player can call * on a few times (its charges); once spent, it needs time to come back. At a glance: * * - Ready: the creature glows in its own colour (a thick stroke of the silhouette under a pale *   body, over a halo that is a blurred copy of the silhouette behind it, so the creature itself *   stays sharp), with "×N" charges at its top right.
beasts.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The four allies' art: original creatures of myth, each drawn on a 48 × 48 grid as a few filled * shapes (the silhouette) and a few marks inside it (its engraving). One component draws a creature * in one of three layers, so the tracker can stack them: * * - 'edge': the silhouette alone, stroked thick in the ally's colour. Under the body, it leaves a *   crisp coloured rim all round, with no seams where the shapes overlap. * - 'body': the silhouette filled with --body, and the engraving in --mark. * - 'fill': the same as 'body', in the recharge colours; the tracker clips it from the top, so it *   rises from the bottom as the ally recharges. */export type AllyKind = 'drake' | 'ray' | 'fox' | 'stag';type Art = {  /** The silhouette: filled shapes, which may overlap. */  shapes: string[];  /** The engraving: stroked lines inside the silhouette. */  lines: string;  /** Dots of the engraving: [x, y, r]. */  dots: [number, number, number][];  /** Rings of the engraving: [x, y, r]. */
use-autoplay.ts
// 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);
ally-tracker.module.css
/* Ally tracker (ally-tracker.tsx): the HUD's column of allies, alone and centred on a plain stage.   Sizes are in em of the count's type (24 px at full size: a 60 px creature on a 90 px row), so   the stage draws them smaller on a narrow screen (a container query), and a bare column takes   its size from --ally-size.   The column sits on its own dark plate, as a game HUD keeps a shade behind its glows, so the   creatures read the same on a light stage and a dark one. Colours are the tokens below,   light-dark(light, dark) where the two differ: the stage, the plate's edge and lift, and the   hint chip; the HUD keeps its colours on both, as a game's does. Each ally sets --tint (its   colour), --top and --span (its silhouette's height in its box) and --fill (its recharge).   Crisp at any pixel ratio: the creatures are SVG with no filter on them; their halo is a copy   of the silhouette behind them, and only that copy is blurred.   Motion: the moments (call, spent, refused, recharged) are keyframes on the creature, which is   remounted for each so they play again; the recharge fill is a clip-path that eases between the   clock's steps; a spent ally's halo breathes through a registered custom property. */@property --ally-glow {  syntax: '<length>';  inherits: true;  initial-value: 0px;}@property --ally-flash {
  • A list labelled Allies; each ally is a real button named with its state, such as “Gale ray, 1 of 1 charge” or “Gale ray, recharging, 40%” (in steps of 10, so it does not chatter), with aria-keyshortcuts for its number.
  • Click, Enter or Space calls an ally; 1 to 4 call them while focus is inside. Focus shows a white ring on the dark plate and the ally’s name on a dark tag, which also shows on hover.
  • A polite live region says what happened: called and how many are left, spent and recharging, refused while recharging, ready again.
  • The creatures, the count and the name tag are decorative (aria-hidden); the button’s name carries their meaning, and spent is shown by colour and by the missing count and the fill, not by colour alone.
  • Under reduced motion the states change at once: no bursts, rings, shakes, pops or breathing, no autoplay, and the fill steps without easing.