Loadout
A loadout screen for React: the hold on one side, the chosen item on the other, with its tier, wear and weight, a belt to equip it to, and a held scrap.
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/loadoutPreview
Loadout
8,640cr
Belt
legendary
Gate key
Opens any gate on the north wall, once.
Durability
Weight
Worth 2,400 cr each · 1 in the hold
import { type ComponentType, useState } from "react";import { Badge, type Rarity } from "@/components/zero/badge";import { Button } from "@/components/zero/button";import { Counter } from "@/components/zero/counter";import { Gauge, GaugeLabel, GaugeValue } from "@/components/zero/gauge";import { HoldButton } from "@/components/zero/hold-button";import { CellIcon } from "@/components/zero/icons/cell";import { ChipIcon } from "@/components/zero/icons/chip";import { CrateIcon } from "@/components/zero/icons/crate";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";/* * A loadout screen: the hold on one side, what is chosen on the other — its tier, what it is, * how worn and how heavy, and what to do with it. Equipping puts it in one of three slots on the * belt; scrapping it is held, and pays its worth into the credits, which count up. The items are * local state; feed your own. */type Thing = { id: string; name: string; icon: ComponentType; rarity: Rarity; count: number; line: string; durability: number; weight: number; worth: number;};const hold: Thing[] = [ { id: "key", name: "Gate key", icon: KeyIcon, rarity: "legendary", count: 1, line: "Opens any gate on the north wall, once.", durability: 100, weight: 1, worth: 2400, }, { id: "crate", name: "Sealed crate", icon: CrateIcon, rarity: "rare", count: 2, line: "Contents unknown. The seal must reach the gate unbroken.", durability: 72, weight: 4, worth: 900, }, { id: "chip", name: "Route chip", icon: ChipIcon, rarity: "epic", count: 1, line: "Shows the patrols' routes for one night.", durability: 55, weight: 1, worth: 1300, }, { id: "cell", name: "Power cell", icon: CellIcon, rarity: "uncommon", count: 6, line: "Refills the shield by a third.", durability: 90, weight: 2, worth: 120, }, { id: "kit", name: "Repair kit", icon: KitIcon, rarity: "uncommon", count: 3, line: "Patches the hull by thirty.", durability: 100, weight: 2, worth: 160, }, { id: "flare", name: "Signal flare", icon: FlareIcon, rarity: "common", count: 12, line: "Marks a spot on everyone's map for a minute.", durability: 100, weight: 1, worth: 15, },];const SLOTS = 20;const BELT = 3;export function Loadout() { const [things, setThings] = useState(hold); const [chosen, setChosen] = useState(hold[0].id); const [belt, setBelt] = useState<string[]>(["kit"]); const [credits, setCredits] = useState(8640); const thing = things.find((t) => t.id === chosen); const scrap = (id: string) => { const gone = things.find((t) => t.id === id); if (!gone) return; setCredits((c) => c + gone.worth); if (gone.count > 1) { setThings((list) => list.map((t) => (t.id === id ? { ...t, count: t.count - 1 } : t)), ); return; } const rest = things.filter((t) => t.id !== id); setThings(rest); setBelt((list) => list.filter((b) => b !== id)); setChosen(rest[0]?.id ?? ""); }; const worn = belt.includes(chosen); return ( <div className="@container w-full max-w-5xl"> <section aria-labelledby="loadout-title" className="grid border-(length:--zero-stroke) border-zero-ink bg-zero-surface bg-(image:--zero-hatch)" > <header className="flex flex-wrap items-end justify-between gap-4 bg-zero-void px-6 pt-6 pb-5"> <h2 id="loadout-title" className="origin-left rotate-(--zero-tilt) font-black font-zero text-zero-2xl italic" > Loadout </h2> <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> </header> <div className="grid gap-8 p-6 @3xl:grid-cols-[auto_1fr]"> <div className="grid content-start gap-5"> <Inventory columns={5} aria-label="Cargo hold"> {Array.from({ length: SLOTS }, (_, index) => { const t = things[index]; if (!t) // biome-ignore lint/suspicious/noArrayIndexKey: empty slots are only their place return <InventorySlot key={index} aria-label="Empty" />; return ( <InventorySlot key={t.id} rarity={t.rarity} count={t.count} selected={t.id === chosen} aria-label={`${t.name}, ${t.count}`} onClick={() => setChosen(t.id)} > <t.icon /> </InventorySlot> ); })} </Inventory> <div className="grid gap-2"> <span className="font-black font-zero text-zero-sm uppercase italic tracking-[0.06em]"> Belt </span> <Inventory columns={BELT} aria-label="Belt"> {Array.from({ length: BELT }, (_, index) => { const t = things.find((x) => x.id === belt[index]); return t ? ( <InventorySlot key={t.id} rarity={t.rarity} aria-label={`${t.name}, on the belt`} onClick={() => setChosen(t.id)} > <t.icon /> </InventorySlot> ) : ( // biome-ignore lint/suspicious/noArrayIndexKey: empty slots are only their place <InventorySlot key={index} aria-label="Empty belt slot" /> ); })} </Inventory> </div> </div> {thing ? ( <div className="grid content-start gap-5"> <div className="grid gap-2"> <Badge rarity={thing.rarity} className="w-fit"> {thing.rarity} </Badge> <h3 className="font-black font-zero text-zero-xl italic"> {thing.name} </h3> <p className="font-medium font-zero-text text-zero-md text-zero-text-2"> {thing.line} </p> </div> <Gauge value={thing.durability} tone="uncommon" low={0.3}> <GaugeLabel>Durability</GaugeLabel> <GaugeValue /> </Gauge> <Gauge value={thing.weight} max={5} segments={5} tone="legendary"> <GaugeLabel>Weight</GaugeLabel> <GaugeValue /> </Gauge> <p className="font-medium font-zero-text text-zero-sm text-zero-text-3"> Worth {thing.worth.toLocaleString("en-US")} cr each ·{" "} {thing.count} in the hold </p> <div className="flex flex-wrap gap-3 border-zero-line border-t-(length:--zero-stroke-thin) pt-5"> <Button disabled={!worn && belt.length >= BELT} onClick={() => setBelt((list) => worn ? list.filter((b) => b !== chosen) : [...list, chosen], ) } > {worn ? "Take off the belt" : "Put on the belt"} </Button> <HoldButton onConfirm={() => scrap(thing.id)}> Hold to scrap </HoldButton> </div> </div> ) : ( <p className="font-medium font-zero-text text-zero-text-3"> The hold is empty. </p> )} </div> </section> </div> );}Behaviour
Pick a slot to see the item: its tier as a badge, what it is, its durability and weight as gauges, what it is worth. Put on the belt moves it to one of three belt slots; Hold to scrap takes one away and pays its worth into the credits, which count up.
Both grids move with the arrow keys. Two columns once its box is wide enough, one on a phone. The items are local state: feed your own.
Built from Inventory, Gauge, Counter, Badge, Button, Hold button. It installs those with it.