Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Effect timer

New

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

effect-timer/effect-timer.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 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.

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

    Into components/effect-timer of any React project, with no setup:

    Terminal
    npx shadcn@latest add https://brunodelorence.com/r/effect-timer.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-effects.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')}    />  );}

    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 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.

    • 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.
    PropTypeDefaultDescription
    effectsEffectTimerItem[]—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.
    defaultEffectsEffectTimerItem[][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.
    warnAtnumber10From how many seconds left a row warns: red count and hourglass, a pulse each second.
    pausedbooleanfalseStops every count, as a game’s pause menu does.
    backingbooleantrueA dark banner behind each row, fading out at both ends. Without it, the words, icons and hourglasses keep only their dark outline.
    autoplaybooleantrueWhile 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.
    classNamestring—Extra class for the root.

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

    effect-timer.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
    effect-timer.module.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;
    icons.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',};
    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);
    • 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.