Ability select
NewA pause-tablet page for picking an ability: glowing glyph tiles, a highlight that glides, and a crossfading description.
ability-select/ability-select.tsxCall Motorbike
Load materials into its tank to make fuel. It comes whenever you call, except in a few places where it cannot ride.
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:
npx shadcn@latest add https://brunodelorence.com/r/ability-select.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
abilities | AbilityId[] | all seven | The abilities owned, in the row's order: 'round-bomb', 'square-bomb', 'magnet', 'lock', 'ice', 'camera', 'bike'. |
slots | number | 8 | How many tiles the row has; those past the abilities are empty slots. |
defaultSelected | AbilityId | the last | The ability picked at first. |
selected | AbilityId | — | The picked ability, controlled; pair it with onSelectedChange. Turns the demo off. |
onSelectedChange | (ability: AbilityId) => void | — | Called when another ability is picked. |
upgraded | AbilityId[] | ['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. |
texts | Partial<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. |
wrap | boolean | false | Whether walking past either end goes round to the other; otherwise the highlight nudges back. |
autoplay | boolean | true | While 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. |
className | string | — | 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.
// 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// 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',// 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',// 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';// 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 (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.