Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Quick selector

New

A game’s d-pad gear cluster and the row it opens: hold a direction, flick to an item, let go to equip.

quick-selector/quick-selector.tsx
Live · interactive

Hold ↑←→ to choose↓ draw bow

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

  • Swapping gear in the middle of play without pausing: a weapon, a shield, an ability, an arrow type, a few items per slot.
  • A game where each d-pad direction (or arrow key) owns one slot, and the player holds it, flicks to an item and lets go.
  • A HUD that has to say at a glance what is in hand, and only shows the full list while a direction is held.
  • A slot that changes meaning with a stance: the sides read shield and weapon in melee, arrows (with their count) and bow with the bow drawn.
  • Gear that wears out or runs down, where the row shows durability or a count next to each item.

When not to use

  • A full inventory with dozens of items, sorting and descriptions. Use a paused inventory grid.
  • Picking one of many options in an ordinary interface. Use a select, a menu or a segmented control.
  • Spells or tools picked by direction from eight or more at once. Use a radial wheel.
  • Abilities fired straight from a key, with no choice to make. Use an ability bar with cooldowns.
  • A single on/off choice, such as drawing the bow on its own. Use a toggle or one button.

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

Into components/quick-selector of any React project, with no setup:

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

gear-hud.tsx
import { QuickSelector, type QuickCategory } from './quick-selector';export function GearHud({  gear,  onEquip,}: {  gear: Record<QuickCategory, string>;  onEquip: (category: QuickCategory, id: string) => void;}) {  return <QuickSelector bare equipped={gear} onEquip={onEquip} />;}

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.

How a game lets the player swap gear without stopping: a d-pad cluster on the HUD shows the ability, shield and weapon in hand (arrows and bow once the bow is drawn), and holding a direction opens that slot’s row above it. A cream frame steps from tile to tile with the item’s name above it, the row scrolls and fades at its ends, and letting go floods the tile blue and pops the new item into its slot. Hold the arrow keys, or press and hold a slot.

  • The HUD is a zero-size box at the d-pad’s centre: the four dots sit 13 units around it, the ability 45 units above, the shield or arrows 36 to the left and the weapon or bow 31 to the right, each a button. Its glyphs are the equipped items drawn in white (the ability keeps its colour), with a soft drop shadow, as on a game’s HUD.
  • Holding a slot (an arrow key, or a press on it) opens its row with the frame on the equipped item. Left and right (or A/D, Q/E) step the frame; letting go equips what is under it. A press shorter than 260 ms is a tap instead: the row stays open for Enter, a click or a tap on a tile, and Escape cancels. Down draws or puts away the bow, which turns the sides into arrows and bow; F fires an arrow and spends one.
  • The row shows five tiles. The strip keeps the frame one tile off its ends while there is more beyond, moving by translate (180 ms); the end tile on a side with more items fades out through a mask whose stops are two registered custom properties, so the fade eases in and out.
  • The frame, the name, its dot and the stick glyph travel together in one layer that glides 140 ms per step; the new item bumps to 1.1, the name rises in, and at the end of the list the frame nudges 6 units out and back (Web Animations). Opening raises the row 10 units as its tiles stagger in 22 ms apart.
  • Equipping floods the chosen tile blue from 0.55, fades the row out in 150 ms, and remounts the slot’s glyph, which pops from 0.6 through 1.16 with a white flash; drawing the bow flips the side slots in, and each shot pulses the arrow count, red at zero.
  • Sizes are in units of the kit’s art (a tile is 112), turned into pixels from the stage’s width by a container query: 1 px at full size, 0.4 px on a narrow phone. The HUD is drawn 1.3 times larger with a 0.62 px floor, so the d-pad stays readable and pressable.
  • It is drawn alone on the page, with no game scene, and brings its own contrast as a HUD over a bright sky does: the cluster sits on a dark disc, the item’s name on a dark pill, the tiles carry a hairline edge on a dark page and the stick glyph a dark outline. All of it is CSS and SVG at the screen’s resolution, with no blur on any shape; the glows are shadows behind sharp shapes. Room for the row is kept while it is closed, so the cluster never moves.
  • Uncontrolled, it keeps its own gear and stance, and a demo plays a short run while it is on screen and untouched: a new weapon, the magnet, a bigger shield, the bow drawn, shots, fire arrows. Any real input hands it to the reader. equipped and stance control it; onEquip and onStanceChange report.
PropTypeDefaultDescription
itemsPartial<Record<QuickCategory, QuickItem[]>>DEFAULT_ITEMSEach slot’s items in row order: { id, name, icon, tint?, count?, durability? }. icon names one of the component’s glyphs; count shows as ×N (arrows), durability in a dark box (gear). Categories: ability, weapon, shield, bow, arrow.
equippedPartial<Record<QuickCategory, string>>—The equipped item’s id in each slot, controlled. Pair it with onEquip. Turns the demo off.
defaultEquippedPartial<Record<QuickCategory, string>>time lock, iron sword, iron shield, short bow, arrowUncontrolled, the items equipped at the start.
onEquip(category: QuickCategory, id: string) => void—Called when the player lets go on an item (or picks one in an open row).
stance'melee' | 'ranged'—Controlled: melee shows shield and weapon on the sides, ranged shows arrows (with their count) and bow. Turns the demo off.
onStanceChange(stance: 'melee' | 'ranged') => void—Called when the player draws or puts away the bow (the d-pad’s down).
barebooleanfalseNo key legend under it: the HUD and its row alone, to lay over a game of your own.
autoplaybooleantrueWhile on screen and untouched, a short run of play; real input stops it, and it never plays under reduced motion.
defaultOpen'ability' | 'weapon' | 'shield' | 'bow' | 'arrow'—Starts with this slot’s row open, as if tapped (it stays open until an item is picked or Escape), for a still. Turns the demo off.
defaultCursorstring—With defaultOpen, the id of the item the frame starts on; the equipped one when left out.
colorScheme'light' | 'dark' | 'system'—Forces light or dark for the key chips and the tiles’ edges; without it, they follow the page. The disc, the pill and the tiles keep their own dark colours.
classNamestring—Classes for the root, the stage.

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

quick-selector.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useMemo,  useReducer,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import { ItemIcon, PadDot, StickGlyph, type IconKind } from './icons';import { useAutoplay } from './use-autoplay';import styles from './quick-selector.module.css';/* * Quick selector: how a game lets the player swap gear in the middle of play, without a menu. * * - The HUD cluster (always on, on its own dark disc) says what is in each slot at a glance: the
icons.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The quick selector's item art: flat, bold glyphs on a 64-unit square, drawn for it. Each is * painted with three custom properties: --ia (the main shape), --ib (the second material: grips, * shafts, rims) and --ic (details and cut lines). A tile gives each kind its own colours (a tint * replaces the main one); the HUD draws them mono: white, with the cut lines in the dark of its * drop shadow, so a shield keeps its rim and a blade its fuller. */import type { CSSProperties } from 'react';export type IconKind =  | 'magnet'  | 'orb-bomb'  | 'box-bomb'  | 'time-lock'  | 'frost'  | 'sword'  | 'axe'  | 'club'  | 'spear'  | 'heater'  | 'round'
quick-selector.module.css
/* Quick selector (quick-selector.tsx): the HUD's d-pad cluster and, while a slot is held, that   slot's row of items above it, alone on the page's stage.   Sizes are in --u, one unit of the kit's art (a tile is 112 units, the HUD's magnet 36), which   the stage sets from its width (a container query): 1 px at full size, down to 0.4 px on a narrow   phone. The HUD is drawn 1.3 times larger, with a floor, so its d-pad stays readable and   pressable.   With no game scene behind it, the element brings its own contrast, as a HUD over a bright sky   does: the cluster sits on a dark disc, the item's name on a dark pill, the tiles carry a hairline   edge on a dark stage, and the cream glyphs are outlined in dark. Those plates are the same on   both schemes; only the stage around them and the key chips follow the scheme.   Everything is CSS and SVG, drawn at the screen's resolution: no blur on any shape. The glows are   box shadows and drop shadows, layers behind sharp shapes.   Motion: the row rises in and its tiles stagger in (keyframes on mount); the strip and the frame   glide by transitions of translate; the bump, the flood, the pop, the flip and the shot are   keyframes on the parts they move. Under reduced motion, all of it is instant. */@property --qs-fade-l {  syntax: '<number>';  inherits: true;  initial-value: 0;
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. */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);    const observer = new IntersectionObserver(
  • The slots are buttons named by their slot and item (“Weapons: Iron sword”, “Arrows: Arrow, 58 left”), with aria-haspopup="listbox", aria-expanded and aria-keyshortcuts; Enter or Space opens a slot’s row and leaves it open.
  • The open row is a listbox that takes focus, with each tile an option (its name, whether it is equipped, its count or durability) and aria-activedescendant on the frame’s tile; arrows or A/D move, Home and End jump, Enter equips, Escape cancels, and focus returns to the slot.
  • Equips and stance changes are announced in a polite live region; the demo’s are not. The frame, the label above it, the stick glyph and the d-pad dots are aria-hidden: the listbox says the same.
  • Every slot and tile works with a pointer alone (press and hold, or tap), and the d-pad keeps a minimum size on a phone. Under reduced motion nothing glides, bumps or pops, and the demo does not play.