Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Ability select

New

A pause-tablet page for picking an ability: glowing glyph tiles, a highlight that glides, and a crossfading description.

ability-select/ability-select.tsx
Live · interactive

Call Motorbike

Load materials into its tank to make fuel. It comes whenever you call, except in a few places where it cannot ride.

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 page of a pause menu where the player picks which ability one button fires, from a short row they walk with left and right.
  • Tools the player recognises by colour and shape first, so each one is a glyph on a square tile, with its name only for the picked one.
  • A pick that needs a line of explanation: what the button does, with the one thing to remember picked out in red.
  • A set that grows over the game: empty slots promise what is still to earn, and a cream “+” marks the upgraded ones.
  • A choice made with a controller or arrow keys: one highlight that glides, Enter or A to confirm, the ends nudging back.

When not to use

  • Picking an ability in the middle of play. Use a quick selector or a radial wheel that opens over the game while a button is held.
  • A loadout of many items to compare by stats or counts. Use an item grid with a detail panel.
  • A toolbar or segmented control in an app. Use a toggle group with labels; glyphs alone ask the reader to learn them.
  • Showing which abilities are ready or cooling down while playing. Use HUD ability icons with a cooldown sweep.
  • Moving between the pages of a pause menu. Use menu tabs; the tab in the top band here only names the page.

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

Into components/ability-select of any React project, with no setup:

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

tablet.tsx
import { AbilitySelect } from './ability-select';export function Tablet() {  return (    <AbilitySelect      abilities={['round-bomb', 'square-bomb', 'magnet', 'lock', 'camera']}      slots={8}      defaultSelected="magnet"      upgraded={['round-bomb', 'lock']}      onConfirm={(ability) => console.log('equipped', ability)}      autoplay={false}    />  );}

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 page of a game’s pause tablet where the player picks what the shoulder button fires. A row of square tiles, each glyph in its own colour, the picked one lit blue with a cyan glow; its name above in large cyan, and below it what the button does, with the one thing to remember in red. Walk the row with the arrow keys: the highlight glides, the words crossfade, the ends nudge back. Upgrades wear a cream “+”; slots still to earn stay empty.

  • The tablet is drawn whole: a dark bezel whose top and bottom bands carry a carved sandstone rail, four stepped corner keys with lit cores, and a navy screen lit blue along its top and bottom edges, over a faint dot grid. Everything is SVG and CSS, sized in a unit that is 1 px on a 960 × 540 tablet.
  • Each ability is a flat SVG glyph in its own colour, its details cut out with a mask. An upgrade adds a cream “+” with a moat cut around it, so it reads on any colour.
  • The highlight is one element under the tiles. It glides to the picked tile by a transition on translate (its index times the tile pitch) with a little overshoot and a settle, while the tile’s own dark plate fades so the glyph seems to sit in the light.
  • The name above and the description below crossfade: the old copy stays a moment, stacked in the same grid cell, fading out as the new one fades in; the name also slides in from the side the highlight came from.
  • Left and right walk the row and skip the empty slots. At either end the highlight nudges and springs back (or goes round, with wrap). Enter, Space or a click on the picked tile confirms it with a press pulse. A “+” that lands later pops in, and its tile flashes. Nudges, pulses and pops are Web Animations, skipped under reduced motion, where the copy also swaps without fading.
  • While on screen and untouched, the page plays a short session: walking the row, an upgrade landing on the ice block, a quick scrub back, a confirm, the end of the row. Any input of the reader’s ends it.
  • Like a game’s menu, the tablet brings its own dark surface, so it reads the same on a light or a dark stage; only its halo and shadow on the page follow the colour scheme. Under 700 px it switches to a compact layout that grows to its content, and words never go under 11 px.
PropTypeDefaultDescription
abilitiesAbilityId[]all sevenThe abilities owned, in the row's order: 'round-bomb', 'square-bomb', 'magnet', 'lock', 'ice', 'camera', 'bike'.
slotsnumber8How many tiles the row has; those past the abilities are empty slots.
defaultSelectedAbilityIdthe lastThe ability picked at first.
selectedAbilityId—The picked ability, controlled; pair it with onSelectedChange. Turns the demo off.
onSelectedChange(ability: AbilityId) => void—Called when another ability is picked.
upgradedAbilityId[]['round-bomb', 'square-bomb', 'lock']The abilities that wear the “+” of an upgrade, and show their upgraded text.
onConfirm(ability: AbilityId) => void—Called when the picked ability is confirmed: Enter, Space, or a click on it.
textsPartial<Record<AbilityId, Partial<AbilityText>>>—Your own name, heading and lines per ability. A line is a list of pieces; a piece written { mark: "…" } is picked out in red. `upgraded` holds the text shown once upgraded.
wrapbooleanfalseWhether walking past either end goes round to the other; otherwise the highlight nudges back.
autoplaybooleantrueWhile on screen and untouched, the page plays a short session. Any input hands it to the reader.
colorScheme'light' | 'dark' | 'system'—The stage's scheme, for the tablet's halo and shadow; without it, the page's color-scheme.
classNamestring—A 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.

ability-select.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,} from 'react';import { textOf, plain, type AbilityText, type Piece } from './abilities';import { Corner, Rail } from './bezel';import { ABILITY_IDS, AbilityGlyph, type AbilityId } from './glyphs';import { useAutoplay } from './use-autoplay';import styles from './ability-select.module.css';export { AbilityGlyph, ABILITY_IDS, type AbilityId } from './glyphs';export { TEXTS, type AbilityText, type Piece } from './abilities';/* * Ability select: the page of a game's pause tablet where the player picks the ability the
abilities.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { AbilityId } from './glyphs';/* * What the page says about each ability: its name, a heading that says what the button does, and * two or three short lines. A line is a list of pieces; a piece in `{ mark }` is the one thing to * remember, picked out in red. An upgraded ability has its own heading and lines. */export type Piece = string | { mark: string };export type AbilityText = {  /** The name above the row, without the "+" of an upgrade (added on its own). */  name: string;  heading: string;  text: Piece[];  /** What the page says once the ability is upgraded; without it, the same as above. */  upgraded?: { heading?: string; text: Piece[] };};export const TEXTS: Record<AbilityId, AbilityText> = {  'round-bomb': {    name: 'Round Bomb',
glyphs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId, type ReactNode } from 'react';/* * The ability glyphs: flat, bold silhouettes, one colour each, drawn on a 54-unit square (the * motorbike is wider and spills 5 units out each side, so every glyph sits in the same 64 × 60 * box). Details are cut out of the shape, never painted over it, so whatever is behind shows * through them: each glyph is a filled group under a mask whose black shapes are the cuts. * * An upgraded glyph gets a cream "+" in its lower right corner, with a 2.5-unit moat cut around it * so the badge reads on top of any colour. */export type AbilityId =  'round-bomb' | 'square-bomb' | 'magnet' | 'lock' | 'ice' | 'camera' | 'bike';export const ABILITY_IDS: AbilityId[] = [  'round-bomb',  'square-bomb',  'magnet',  'lock',
bezel.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId } from 'react';/* * The tablet's bezel ornaments: a carved sandstone inlay, drawn as SVG strokes so it stays sharp * at any size. Each stroke is laid three times: a dark bed a little wider than the stone, the * stone, then a thin darker groove down its middle, which reads as a channel cut into it. A * blurred cyan copy sits behind the corners, the light leaking from the screen under the stone. * * A corner is a stepped key: an outer bracket that follows the tablet's edge and an inner line * that steps in towards the screen, ending in two rings with a lit core, plus a diamond stud. * The rails along the top and bottom bands repeat a long rounded bar and a small stud. */const STONE = '#b39a6a';const BED = '#1c160d';const GROOVE = '#7a6544';const CORE = '#62ddff';const KEY = 'M7 132V34Q7 7 34 7H124M26 112V64L44 46H64V26H104';
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(
ability-select.module.css
/* Ability select (ability-select.tsx): a page of a game's pause tablet, centred on a plain stage.   Every size is a multiple of --u, 1 px at full size (a 960 × 540 tablet), less on a narrower   stage. Under 700 px the tablet switches to a compact layout 520 units wide, with its own   sizes, and grows as tall as its content instead of keeping 16:9; words never go under 11 px.   The tablet's bezel and screen are dark on any page, as a game draws them; only its edge and its   shadow on the page follow the colour scheme, through light-dark() tokens.   No filter touches a glyph or a word: glows are box- and text-shadows behind the sharp shapes.   Motion: the highlight glides by a transition on translate; the name and the description   crossfade by keyframes; nudges, pulses and pops are Web Animations (in the component). */.root {  /* Words on the screen. */  --cyan: #3cd3fc;  --cyan-glow: rgb(24 160 255 / 0.85);  --mark: #f0484a;  /* The screen: a navy field, lit blue along its top and bottom edges. */  --navy: #191b31;  --navy-dot: rgb(140 156 236 / 0.075);  --edge-light: #05428c;  /* The bezel. */  --bezel: #1a1a1b;  --bezel-deep: #101011;
  • The row is a radiogroup of real buttons with role radio: one tab stop, arrow keys, Home and End move the pick, and focus follows it. Empty slots are hidden from assistive technology and skipped.
  • Each ability is named, with “upgraded” when it wears a “+”; the picked one is described by its heading and lines, so moving to it reads what it does.
  • Confirming and upgrades are announced in a polite live region.
  • The bezel, the highlight, the flourishes and the glyphs are decorative and hidden; the red words are a mark element, and colour is never the only cue.
  • Focus shows a visible ring outside the tile. Under reduced motion the glide, the pops and the demo are off, and the copy swaps without fading.