Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Controller glyphs

New

Every controller input a game names in its prompts, as one reusable glyph that dips, lights and teaches.

controller-glyphs/controller-glyphs.tsx
Live · interactive

Face buttons

Shoulders

Directions

Right stick

Left stick

Prompts

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

  • Telling a player which input does what, in a prompt over the game: “A Jump”, “ZL Aim”, “press the left stick to crouch”.
  • A tutorial that teaches a move: a stick glyph with its arrows (tilt, press, rotate) and animate looping the hint until it is done.
  • Showing that an input was taken: pressed dips the glyph and flashes it while the button is held, lit keeps it glowing.
  • A controls screen, a pause menu or a help sheet that lists every input of a pad in one consistent drawing.
  • Prompts drawn over anything: every shape carries its own dark edge and every label its own dark plate, so they read on sky, snow, a white menu or a black one.

When not to use

  • Keyboard shortcuts in an app or on a website. Use key caps (a kbd element) with the platform’s own keys.
  • Clickable actions in a web UI. Use a button with words; a glyph alone names an input, it is not a control.
  • Another pad’s own buttons (a cross, circle, square and triangle layout). These glyphs draw one family of pad; draw that pad’s set instead.
  • A whole action bar with cooldowns and charges. Use a quick selector or ability icons and put a small glyph on each slot.
  • Inside running text at body size. Under about 20 px the edge and the arrows close up; set the input in words, or use the glyph at sm or larger beside the line.

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

Into components/controller-glyphs of any React project, with no setup:

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

interact-prompt.tsx
import { Glyph } from './controller-glyphs';export function InteractPrompt({ held }: { held: boolean }) {  return (    <p>      <Glyph name="A" size="lg" pressed={held} label="Open" labelPosition="left" />      <Glyph name="LS" motion="horizontal" animate label="Steer" />      <Glyph name="dpad" direction="up" size="md" lit />    </p>  );}

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 badges a game uses to say which input to use: face buttons, plus and minus, shoulder tabs, the d-pad, the direction buttons and both sticks with arrows for tilt, press and rotate, alone or with words beside them. Each is one Glyph with sizes, a press that dips and springs back with a flash, a glow, and looping hints. Press them, play the sheet with the keyboard or a gamepad, or watch a short run of play.

  • Every glyph is an SVG on one grid, where a face button’s box is 40 units: a round badge of 32 (27 for plus and minus), a tab slanted on its outer side, a trigger whose top rises to the outer corner, a cross of 32.5 with arms of 10.5, four direction buttons of 10.5 in a diamond, and a stick drawn from the side, an oval cap with a rim and a thin groove on a short foot.
  • Letters and marks are not painted: an SVG mask cuts them out of the tan shape, over a dark edge drawn behind (the same shape filled and stroked near-black 1.2 units out all round), so every glyph carries its own crisp outline and reads on a light stage and a dark one alike. Arrows laid over a stick are cut around in the same way, so a press arrow sits in a notch of the cap.
  • The SVG is sized in em of --glyph-size, the box of a face button, so size sets one number: sm 24, md 32, lg 40 (the kit’s own), xl 56 or any px; a label beside, before or under it is 0.6 of it, semibold, on a dark pill of its own.
  • pressed dips the glyph to 0.86 in 70 ms and springs it back over 340 ms with an overshoot (a cubic-bezier past 1); a ring of light opens and fades behind it on each press. A pushed stick’s cap leans 2.6 units the way it goes, or sinks when pressed in, and its arrows nudge out.
  • lit adds a glow behind the sharp shapes (two drop-shadows), warm white on a dark stage and amber on a light one; the opaque edge keeps it out of the cut letters. animate loops the hint: tilt arrows nudge out, the press arrow bobs into a squashed cap, the rotate arrows swing, a marked direction blinks.
  • On the sheet, inputs are ids (A, dpad-up, LS-left…) and one input presses every glyph that names it: the plain stick, its tilt and its either-way glyphs, and a prompt. Pointer, Enter and Space press a glyph while held; with focus on the sheet the keyboard plays a pad (A B X Y, Q E, 1 3, + −, arrows, Shift + arrows); a gamepad is read each frame once it shows up.
  • A demo plays a short run of play while it is on screen and untouched (walk, jump, aim and throw, quick items, crouch, look around, menu), with the last input as a prompt at the foot; any input hands it to the reader. The stage is plain, white or near-black through light-dark() tokens; the glyphs keep their colours on both.
PropTypeDefaultDescription
size'sm' | 'md' | 'lg' | 'xl' | number—On both. Glyph: the box of a face button in px, sm 24, md 32, lg 40 (the kit’s), xl 56; without it, --glyph-size (40 px). ControllerGlyphs: the size of every glyph on the sheet, by default 48 px on a wide stage, 40 under 760 px, then 32 and 26.
gamepadbooleantrueControllerGlyphs: reads a connected gamepad (it shows up after its first press), each frame while the sheet is on screen.
onInput(input: string) => void—ControllerGlyphs: called with each input the reader presses ('A', 'dpad-up', 'LS-left'…).
autoplaybooleantrueControllerGlyphs: while on screen and untouched, a short run of play on the sheet.
colorScheme'light' | 'dark' | 'system'—ControllerGlyphs: the stage’s colours, light, dark or the reader’s system setting. Without it the component follows the page’s color-scheme.
classNamestring—Extra class for the root (on both components).
name'A' | 'B' | 'X' | 'Y' | 'plus' | 'minus' | 'L' | 'R' | 'ZL' | 'ZR' | 'dpad' | 'buttons' | 'LS' | 'RS'—Glyph: which input it names. Required.
direction'up' | 'down' | 'left' | 'right' | 'all'—Glyph: for the d-pad and the direction buttons, the one to mark (or all buttons).
motion'none' | 'left' | 'right' | 'horizontal' | 'press' | 'up' | 'down' | 'vertical' | 'rotate''none'Glyph: for a stick, the arrows that say how to move it.
pressedbooleanfalseGlyph: held down; it dips and lights, and springs back when released.
lean'up' | 'down' | 'left' | 'right'—Glyph: the way a pressed stick’s cap leans; by default its motion’s.
litbooleanfalseGlyph: glowing, for the input the game wants noticed.
animatebooleanfalseGlyph: loops its hint (arrows nudge, the press arrow bobs, a marked direction blinks).
labelReactNode—Glyph: words beside it, as in a prompt.
labelPosition'left' | 'right' | 'below''right'Glyph: where the words go.
titlestring—Glyph: its accessible name; by default “A button”, “Left stick, tilt left”…

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

controller-glyphs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useState,  type KeyboardEvent,  type PointerEvent,} from 'react';import {  Glyph,  describeGlyph,  type GlyphDirection,  type GlyphName,  type GlyphSize,  type StickMotion,} from './glyph';import { useAutoplay } from './use-autoplay';import styles from './controller-glyphs.module.css';/*
glyph.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId, type CSSProperties, type ReactNode } from 'react';import styles from './glyph.module.css';/* * Glyph: one controller input as a game draws it in its prompts, flat tan inside a dark edge. * * - Each glyph is an SVG on a grid where a face button's box is 40 units: a round badge of 32, a *   slanted shoulder tab, a cross, four direction buttons, or a stick seen from the side (an oval *   cap with a rim, on a short foot), with arrows that say how to move it. * - The letter is not painted: it is cut out of the tan shape by a mask, over a dark edge drawn *   behind (the same shape, filled and stroked 2.4 units wider), so the glyph reads on a light *   stage and a dark one alike, as a game's prompts read on any scene. Arrows that overlap a *   stick are cut around in the same way, and get their own edge. * - Its size is the box's, in px: the SVG is drawn in em of it, so a 40 px glyph is the kit's *   own, and a label beside it is 0.6 of it (24 px). * - pressed dips it in 70 ms and springs it back with an overshoot on release, with a flash of *   light; a stick leans the way it is pushed. lit makes it glow. animate loops its hint: arrows *   nudge out, the press arrow bobs, the rotate arrows swing, a lit direction pulses. */
controller-glyphs.module.css
/* Controller glyphs (controller-glyphs.tsx): a plain stage with the sheet of glyphs on it, in   groups, centred, and the last input at its foot as a prompt. The glyphs are 48 px on a wide   stage, 40 (the kit's) under 760 px, 32 under 560, 26 under 380 (a container query), or the size   prop.   Colours are the tokens below, light-dark(light, dark) where the two differ: only the stage and   its captions change, white by day and near-black at night; the glyphs keep their tan inside a   dark edge, and their words sit on dark plates, so they read on both, as a game's prompts do.   Motion lives in glyph.module.css; here, only the keys' hover and focus. */.root {  /* The stage, its edge, and the captions over each group. */  --surface: #fff;  --line: rgb(0 0 0 / 0.06);  --caption: rgb(23 25 29 / 0.55);  /* The keys: hover wash and focus ring. */  --key-hover: rgb(0 0 0 / 0.05);  --focus: #387bff;  position: relative;  container-type: inline-size;  isolation: isolate;  display: flex;  flex-direction: column;
glyph.module.css
/* Glyph (glyph.tsx): one controller input. Its size is --glyph-size, the box of a face button   (40 px, as the kit draws it); the SVG is sized in em of it, and a label is 0.6 of it.   Colours: the tan of the shapes, the dark edge behind them that the letters are cut into, the   label's warm white on its dark plate, and the glow. A game keeps its prompts' colours on any   scene, so the shapes, the edge and the plate are the same on a light stage and a dark one; only   the glow changes, a warm white on dark and an amber that shows on light.   Crispness: the shapes are plain SVG fills and strokes, never blurred; a glow is a drop-shadow   behind them, so the shapes themselves stay sharp at any pixel ratio.   Motion: a press dips the art in 70 ms and springs it back with an overshoot (transitions of   scale and translate), with a flash of light (a keyframe on a ring behind it); a pushed stick's   cap leans the way it goes; animate loops the hints (keyframes on the arrows and marks). */.glyph {  --glyph-ink: #e2ded3;  /* The dark edge round every shape, and the plate behind the words. */  --glyph-edge: #1d1c19;  --glyph-plate: rgb(29 28 25 / 0.86);  --glyph-text: #efe8da;  --glyph-glow: rgb(255 249 228 / 0.85);  --glyph-glow-soft: rgb(255 240 200 / 0.4);  --glyph-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
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. `visible` alone says whether it is on screen * in a visible tab, for clocks that should stop when nobody can see them. */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);
  • Each glyph is an image with a spoken name (“A button”, “D-pad up”, “Right stick, rotate”); its SVG is hidden, and a label beside it is real text.
  • On the sheet, every glyph is a button in a named group per section, reached with Tab, pressed with Enter or Space while held, with a visible focus ring; a focused or hovered glyph loops its hint.
  • The keyboard plays the pad only while focus is on the sheet, so the page’s own keys are never taken; the keys are listed in How it works.
  • A polite live region says each input the reader presses and its action (“A button, Jump”), never the demo’s.
  • Under reduced motion presses and leans change at once, nothing loops, flashes or blinks, and the demo never plays.