zeroUI

A health bar that shows the size of a hit

When a health bar drops, leave the old fill behind as a pale trail for half a second before it catches up: the player reads the size of the hit from the trail. Here is how that works, with zeroUI's Gauge or with two divs and a transition delay.

Try it

Hull
x

Take a hit: the fill falls at once, a pale band stays where it was, waits, then shrinks to meet it. Patch up and it runs the other way: a faint band leads and the fill climbs into it. Below a quarter the bar turns red and breathes.

With the Gauge component

zeroUI's Gauge does all of this. It is a Base UI Meter, so screen readers hear the value and its range, named by its label. low is the share under which it turns red; segments splits it into cells for shields or charges.

hull.tsx
<Gauge value={hull} max={120} low={0.25}>  <GaugeLabel>Hull</GaugeLabel>  <GaugeValue /></Gauge>

How the damage trail works

Two layers sit in the same well with the same width: the trail behind, the fill in front. Both animate their width on the same transition. The trick is that one of them starts late: on a drop the trail waits, so for a moment it still shows the old value behind the new fill; on a gain the fill waits, so the trail runs ahead and the fill climbs into it.

So the only thing the component has to know is which way the value last moved. Keep the previous value in state and compare during render (React's pattern for information from previous renders): no effect, no extra paint.

health-bar.tsx
function HealthBar({ value, max }: { value: number; max: number }) {  const share = value / max;  // which way it last moved, kept with React's "previous render" pattern  const [last, setLast] = useState(share);  const [dropped, setDropped] = useState(true);  if (share !== last) {    setLast(share);    setDropped(share < last);  }  const width = `${share * 100}%`;  return (    <div className="bar" role="meter" aria-label="Health"      aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>      <div className={dropped ? "trail" : "trail gain"}        style={{ width, transitionDelay: dropped ? "480ms" : "0s" }} />      <div className="fill"        style={{ width, transitionDelay: dropped ? "0s" : "480ms" }} />    </div>  );}
health-bar.css
.bar { position: relative; height: 12px; overflow: hidden; background: #000; }.trail, .fill {  position: absolute;  inset: 0 auto 0 0;  transition: width 420ms cubic-bezier(0.2, 0.7, 0.2, 1);}.trail { background: #f5f5f0; }         /* the hit: pale, behind the fill */.trail.gain { background: #cde31c66; }  /* a heal: a faint lead */.fill { background: #cde31c; }@media (prefers-reduced-motion: reduce) {  .trail, .fill { transition-delay: 0s !important; }}

Timing the wait

Around half a second reads well: long enough to see the size of the hit, short enough that the bar is honest again before the next one. zeroUI keeps it as a token, --zero-dur-hold (480 ms), and moves both layers on its slow timing (420 ms). Under reduced motion the wait drops to zero, and so does the breathing of a low bar: the number is still right, nothing lingers.

Where to go next

The HUD block puts gauges beside a hotbar and objectives, and the Loadout uses them for durability and weight. For a reading that only goes one way, see Progress.