HUD
An in-game HUD for React: hull and shield gauges, credits and objectives, and a hotbar whose items do things and go on cooldown.
Install
# once per project: where @zeroui items come frompnpm dlx shadcn@latest registry add @zeroui=https://zeroui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zeroui/hudPreview
Hull
Shield
12,480cr
Night shift
Current: Mark the drop zone
Current: Open the north gate
import { type ComponentType, type KeyboardEvent, useEffect, useState,} from "react";import { Counter } from "@/components/zero/counter";import { CutIn, CutInContent, CutInDescription, CutInEyebrow, CutInTitle,} from "@/components/zero/cut-in";import { Gauge, GaugeLabel, GaugeValue } from "@/components/zero/gauge";import { CellIcon } from "@/components/zero/icons/cell";import { FlareIcon } from "@/components/zero/icons/flare";import { KeyIcon } from "@/components/zero/icons/key";import { KitIcon } from "@/components/zero/icons/kit";import { Inventory, InventorySlot } from "@/components/zero/inventory";import { Objective, Objectives, ObjectivesHeader, ObjectiveTitle,} from "@/components/zero/objectives";/* * An in-game HUD over a scene: the hull and shield at the top left, credits and objectives at the * top right, a hotbar along the bottom. Using a slot (a click, or its number while the HUD has * focus) does what the item does and puts it on cooldown: the kit patches the hull, the cell * charges the shield, the flare and the key each finish an objective. Finishing them all pays out, * announced by a cut-in. The scene is a drawn backdrop; put your game behind it. */type Use = "kit" | "cell" | "flare" | "key";const bar: { id: Use; name: string; icon: ComponentType; rarity: "common" | "uncommon" | "legendary"; cooldown: number;}[] = [ { id: "kit", name: "Repair kit", icon: KitIcon, rarity: "uncommon", cooldown: 4000, }, { id: "cell", name: "Power cell", icon: CellIcon, rarity: "uncommon", cooldown: 3000, }, { id: "flare", name: "Signal flare", icon: FlareIcon, rarity: "common", cooldown: 2000, }, { id: "key", name: "Gate key", icon: KeyIcon, rarity: "legendary", cooldown: 0, },];/** A slot's key number in its corner, drawn from data-key so it is not part of its name. */const key = "before:absolute before:start-1.5 before:top-1 before:font-bold before:font-zero-text before:text-zero-text-3 before:text-zero-xs before:content-[attr(data-key)]";const goals = [ { id: "flare", title: "Mark the drop zone" }, { id: "key", title: "Open the north gate" },] as const;export function Hud() { const [hull, setHull] = useState(46); const [shield, setShield] = useState(1); const [stock, setStock] = useState({ kit: 3, cell: 6, flare: 12, key: 1 }); const [done, setDone] = useState<string[]>([]); const [credits, setCredits] = useState(12480); const [paid, setPaid] = useState(false); const [announce, setAnnounce] = useState(false); // when each slot's cooldown ends, and the clock that redraws the sweeps const [ready, setReady] = useState<Partial<Record<Use, number>>>({}); const [now, setNow] = useState(0); useEffect(() => { const waiting = Object.values(ready).some((at) => at && at > now); if (!waiting) return; const frame = requestAnimationFrame((t) => setNow(t)); return () => cancelAnimationFrame(frame); }, [ready, now]); const use = (slot: (typeof bar)[number]) => { if (stock[slot.id] === 0 || (ready[slot.id] ?? 0) > now) return; if (slot.id === "kit") setHull((h) => Math.min(120, h + 30)); if (slot.id === "cell") setShield((s) => Math.min(4, s + 1)); if (slot.id === "flare" || slot.id === "key") { const next = done.includes(slot.id) ? done : [...done, slot.id]; setDone(next); if (next.length === goals.length && !paid) { setPaid(true); setAnnounce(true); setCredits((c) => c + 3100); } } setStock((s) => ({ ...s, [slot.id]: s[slot.id] - 1 })); if (slot.cooldown) { const t = performance.now(); setNow(t); setReady((r) => ({ ...r, [slot.id]: t + slot.cooldown })); } }; const keys = (event: KeyboardEvent<HTMLElement>) => { const slot = bar[Number(event.key) - 1]; if (slot) use(slot); }; return ( <section aria-label="Heads-up display" onKeyDown={keys} className="grid min-h-[28rem] w-full max-w-6xl grid-rows-[auto_1fr_auto] gap-6 overflow-hidden border-(length:--zero-stroke) border-zero-ink p-4 sm:p-6" style={{ // the backdrop: a glow low in the accent, a floor grid, and a dark sky background: "radial-gradient(ellipse at 50% 115%, color-mix(in srgb, var(--color-zero-accent) 22%, transparent), transparent 60%), repeating-linear-gradient(90deg, color-mix(in srgb, var(--color-zero-text) 5%, transparent) 0 1px, transparent 1px 48px), linear-gradient(var(--color-zero-void), var(--color-zero-ground))", }} > <div className="flex flex-wrap items-start justify-between gap-6"> <div className="grid w-full max-w-64 gap-3 bg-[linear-gradient(to_right,rgb(0_0_0/0.72)_55%,transparent)] py-3 ps-4 pe-10"> <Gauge value={hull} max={120} low={0.25}> <GaugeLabel>Hull</GaugeLabel> <GaugeValue /> </Gauge> <Gauge value={shield} max={4} segments={4} tone="rare"> <GaugeLabel>Shield</GaugeLabel> <GaugeValue /> </Gauge> </div> <div className="grid justify-items-end gap-3"> <p className="flex items-baseline gap-1.5"> <Counter value={credits} delta className="text-zero-xl" /> <span className="font-black font-zero text-zero-accent italic"> cr </span> </p> <Objectives aria-label="Objectives" className="w-72"> <ObjectivesHeader>Night shift</ObjectivesHeader> {goals.map((goal) => ( <Objective key={goal.id} state={done.includes(goal.id) ? "done" : "active"} > <ObjectiveTitle>{goal.title}</ObjectiveTitle> </Objective> ))} </Objectives> </div> </div> <div /> <Inventory columns={6} aria-label="Hotbar" className="justify-self-center bg-[rgb(0_0_0/0.6)] p-2" > {bar.map((slot, index) => { const left = Math.max(0, (ready[slot.id] ?? 0) - now); return ( <InventorySlot key={slot.id} rarity={slot.rarity} count={stock[slot.id]} cooldown={ left && slot.cooldown ? left / slot.cooldown : undefined } disabled={stock[slot.id] === 0} aria-label={`${slot.name}, ${stock[slot.id]}`} aria-keyshortcuts={String(index + 1)} data-key={index + 1} onClick={() => use(slot)} className={key} > <slot.icon /> </InventorySlot> ); })} <InventorySlot aria-label="Empty" data-key={5} className={key} /> <InventorySlot aria-label="Empty" data-key={6} className={key} /> </Inventory> <CutIn open={announce} onOpenChange={setAnnounce}> <CutInContent> <CutInEyebrow>Run complete</CutInEyebrow> <CutInTitle>Night shift</CutInTitle> <CutInDescription>+3,100 cr</CutInDescription> </CutInContent> </CutIn> </section> );}Behaviour
Use a hotbar slot with a click, or with its number while the HUD has focus: the kit patches the hull, the cell charges the shield, the flare and the key each finish an objective, and the slot sweeps through its cooldown. Finishing both objectives pays out, announced by a cut-in.
The backdrop is drawn (a glow, a floor grid): put your game behind it. The panels stack on a phone.
Built from Gauge, Counter, Objectives, Inventory, Cut-in. It installs those with it.