GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Keyboard Shortcuts UI Design: 9 Working Parts

Keyboard shortcuts UI design is the craft of making a page answer the keyboard: keycaps, hints, recorders, help sheets.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine parts are ordered the way a person actually meets a keyboard shortcut ui design, not by component type. First the part you see — pick a keycap style (01), match the reader's OS (02), hang a hint on a button (03). Then the part your hand does — sweep a list with J and K (04), jump screens with G then I (05). Then the part you define — record a combination (06), catch two actions on one combination (07), unfold the shortcuts for the current screen from a question mark (08). The last one is for other people: stream the keys you just pressed into a corner for recordings and live demos (09). Stage colors rotate through three ink, two yellow, two orange and two paper so the nine tiles never tilt to one brightness. The command palette and the shortcut badges inside it live in Command Palette Search UI: 9 Patterns, and the player-wide shortcut overlay is covered by Video Player Controls: 9 Patterns. The touch-side counterparts are in Mobile Touch Gestures: 9 Patterns. Finishing the same job with one button instead of a key is the axis social share buttons take, while a scene tilting under the cursor belongs to pointer-following 3D scenes.

01Three keycap styles

What you see

The same letter as three keycaps: flat, raised, and outline. The raised cap gets its thickness from a box-shadow and its press from translateY alone — pressing with scale squashes the letter with it.

Three keycap styles
Open in a new tab

01 · Three keycap styles — Measured area 1.499% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

box-shadowtranslateYkeyup

Where to look in the code

Press A, S or D for real and just that cap sinks until keyup; the loop cycles through all three at a modest 1.495% area with 13/23 moving frames.

.kc__cap {
  display: grid; place-items: center; box-sizing: border-box;
  width: 48px; height: 48px; border-radius: $r-control;
  font-family: inherit; font-weight: 800; font-size: 20px; line-height: 1;
  transition: transform $duration $easing, box-shadow $duration $easing;
}
// the 6px thickness is made of shadow alone — on press it shrinks to 2px and the key sinks
.kc__cap--raised { background: $color; color: $subject-ink; box-shadow: 0 6px 0 #b8890a; }
.kc__cap--raised.is-down { transform: translateY(4px); box-shadow: 0 2px 0 #b8890a; }

02Platform-aware keycap

What happens on screen

One shortcut, two faces: ⌘ on a Mac and Ctrl on Windows. Detection reads userAgentData.platform first and falls back to the older navigator.platform where the property is missing, and both labels sit in one grid cell picked with visibility alone so the row never shifts.

Platform-aware keycap
Open in a new tab

02 · Platform-aware keycap — Measured area 6.392% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

userAgentDatasteps(1, end)aria-pressed

How to read the code

A small pop on every swap lifts it to 6.392% area and 11/23 moving frames.

// read userAgentData.platform first when it exists, and fall back to the older property on browsers that lack it
function detectPlatform() {
  var data = navigator.userAgentData;
  var name = (data && data.platform) || navigator.platform || '';
  return /mac|iphone|ipad|ipod/i.test(name) ? 'mac' : 'win';
}

03Shortcut hint tooltip

Where keyboard controls ui hints belong

Hover a button and its shortcut floats up as a small keycap. The same value is written into aria-keyshortcuts so a screen reader announces it next to the button name, and pressing Ctrl+F for real flashes the Find button once.

Shortcut hint tooltip
Open in a new tab

03 · Shortcut hint tooltip — Measured area 5.105% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-keyshortcutstranslateYtransition

What to change

Appearance is a visibility cut plus a translateY slide — an opacity fade would leave a translucent ghost in the still frame — and the chained tooltips run at 8/23 moving frames and 5.105% area.

.ht__tip {
  position: absolute; top: calc(100% + 8px); left: 50%; margin-left: -36px;
  display: flex; align-items: center; justify-content: center; gap: $sp-1;
  width: 72px; box-sizing: border-box;
  background: $color; border-radius: $r-sm; padding: $sp-1 $sp-2;
  visibility: hidden; transform: translateY(4px);
  transition: transform $duration $easing;
}
.ht__btn:hover .ht__tip,
.ht__btn:focus-visible .ht__tip { visibility: visible; transform: translateY(0); }

04J and K list navigation

What you see

A mailbox-style list: J moves down, K moves up, Enter opens. Focus roves — exactly one row holds tabindex 0, so a single Tab skips the whole list — and the selection is not repainted per row but carried by one bar moved with translateY.

J and K list navigation
Open in a new tab

04 · J and K list navigation — Measured area 29.468% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

tabindexpreventScrollaria-selected

Where to look in the code

The bar sweeping four rows and back makes this the second-largest mover at 29.468% area with 9/23 moving frames.

// Roving tabindex — one row, not the whole list, holds tabindex 0. One Tab steps past the list
function applySelection(next) {
  index = Math.max(0, Math.min(rows.length - 1, next));
  list.style.setProperty('--i', String(index));
  rows.forEach(function (row, i) {
    row.setAttribute('tabindex', i === index ? '0' : '-1');
    row.setAttribute('aria-selected', i === index ? 'true' : 'false');
  });
  // called inside an iframe without preventScroll, the parent page gets dragged down to this row
  rows[index].focus({ preventScroll: true });
}

05Two-key sequence

What happens on screen

Press G, then I, and you land in the inbox. From the moment the first key arrives, the remaining time drains as a scaleX bar, and when it empties a setTimeout releases the pending state so a half-finished chord never lingers.

Two-key sequence
Open in a new tab

05 · Two-key sequence — Measured area 3.294% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

setTimeoutscaleXtransform-origin

How to read the code

The long drain plus two key presses make it the most intense loop of the set: 3.647% area, intensity 137.8, 16/23 moving frames.

var WINDOW_MS = 1200;
function startChord() {
  pending = true;
  cs.classList.add('is-pending');
  cs.classList.remove('is-done');
  clearTimeout(timer);
  // if the second key does not arrive in time the wait releases itself — so the stored first key never lingers forever
  timer = setTimeout(cancelChord, WINDOW_MS);
}

06Shortcut recorder field

What moves

Focus the field, press the combination, and it writes itself down as Ctrl+Shift+K. While the field holds focus, preventDefault holds off the browser's own action so the combination is captured rather than triggered; keys arriving mid-IME composition are skipped by checking isComposing; a combination already in use turns the border red with a warning.

Shortcut recorder field
Open in a new tab

06 · Shortcut recorder field — Measured area 2.931% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

preventDefaultisComposingkeydown

What to change

Caps stacking up into the warning give 2.951% area and 11/23 moving frames.

field.addEventListener('keydown', function (e) {
  // keys arriving while a Korean or Japanese IME is composing are building a character, not a shortcut
  if (e.isComposing) { return; }
  if (e.key === 'Tab') { return; }
  // while the field holds focus, block browser defaults like Ctrl+S and just write the combination down
  e.preventDefault();
  var parts = [];
  if (e.ctrlKey) { parts.push('Ctrl'); }
  if (e.altKey) { parts.push('Alt'); }
  if (e.shiftKey) { parts.push('Shift'); }
  if (e.metaKey) { parts.push('Meta'); }
  if (MODIFIER_KEYS.indexOf(e.key) < 0) {
    parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
  }
  renderCombo(parts);
});

07Conflict detection table

What you see

A table that badges both rows when one combination is bound to two actions. It counts how many rows use each combination, reads the warning once through aria-live, and when you rebind one of the rows the badges flip from "Clash" to "Clear" together.

Conflict detection table
Open in a new tab

07 · Conflict detection table — Measured area 7.233% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-livegrid-template-columnsoutline

Where to look in the code

The two badges stack in one cell and are picked with visibility, so the column never wobbles: 7.233% area, 8/23 moving frames.

// Count how many rows use each combination; two or more rows mean a clash
function applyConflicts() {
  var counts = new Map();
  rows.forEach(function (row) {
    var key = bindingOf(row);
    counts.set(key, (counts.get(key) || 0) + 1);
  });
  rows.forEach(function (row) {
    var dup = counts.get(bindingOf(row)) > 1;
    row.classList.toggle('is-conflict', dup);
    row.classList.toggle('is-ok', !dup);
  });
}

08Question-mark help sheet

What happens on screen

Press the question mark and only the shortcuts that work on this screen unfold, grouped. Typing in the search box filters the list in place, Esc closes it, and the sheet is a role="dialog" with aria-modal so screen readers treat the page behind it as inert.

Question-mark help sheet
Open in a new tab

08 · Question-mark help sheet — Measured area 33.781% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

clip-pathsteps(1, end)aria-modal

How to read the code

Because the sheet covers the whole card it is the largest mover of the nine: 33.781% area with 9/23 moving frames.

window.addEventListener('keydown', function (e) {
  if (e.isComposing) { return; }
  if (e.key === '?') { e.preventDefault(); applySheet(true); return; }
  if (e.key === 'Escape') { applySheet(false); }
});
search.addEventListener('input', function () {
  var want = search.value.trim().toLowerCase();
  items.forEach(function (item) {
    var text = item.textContent.toLowerCase();
    item.classList.toggle('is-hidden', want !== '' && text.indexOf(want) < 0);
  });
});

09Keycast stream

What moves

Keys you just pressed stack up in a corner, then fade and slide away oldest-first. Instead of setting opacity per chip, one mask-image gradient over the whole panel makes position equal age, and past five chips the oldest drops off.

Keycast stream
Open in a new tab

09 · Keycast stream — Measured area 5.389% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXmask-imagemargin-right

What to change

The preview doubles the same five chips and streams them with translateX(-50%) so the seam never shows — every frame moves, giving a perfect 23/23 at 5.389% area.

.kt__panel {
  box-sizing: border-box; width: 300px; max-width: 100%; overflow: hidden;
  background: rgba(255, 247, 230, .08); border-radius: $r-card; padding: $sp-3;
  mask-image: linear-gradient(to right, transparent 0, #000 42%, #000 88%, transparent 100%);
}
.kt__chip {
  display: grid; place-items: center; box-sizing: border-box; flex: 0 0 auto;
  height: 30px; min-width: 34px; padding: 0 $sp-2; margin-right: $sp-2;
  border-radius: $r-sm; border: 1px solid rgba(255, 247, 230, .28);
}

Where it breaks — the trap

The thing that ate the most time here was an infinite flow track widening its parent. The keycast preview is a track of ten chips (two copies of five) scrolling with translateX(-50%), and the moment the track got width: max-content, that intrinsic width leaked into the parent's layout math. The panel was capped at min(100%, 300px) and still document.body.scrollWidth read 369px in a 320px viewport, failing the small-screen check — overflow: hidden on the panel clips what is drawn, not the width the layout computes. Pinning the panel to width: 300px plus max-width: 100% shut the leak and the same probe read 320px. The same track holds a second rule: spacing between the two copies must be per-chip margin-right, never flex gap — ten chips make nine gaps, and -50% comes up short by half a gap every lap, which is why the infinite marquee uses margins too.

The second trap was that the still image is always taken from the frame that changed most. Showing 08's sheet opening exactly as a user sees it — a clip-path wipe — meant one of the 24 frames caught the sheet half open, and since that frame changed the most pixels it became the poster every single render. The preview loop now opens the sheet with a steps(1, end) cut while real clicks and the ? key keep the smooth wipe; the closing beat is left out of the loop entirely, because a closing sheet turns an empty document into the biggest change on screen. The 03 tooltips chain the same way so one of them is always visible.

The third was cream text on an orange stage. The caption on 03 and the title plus warning line on 07 sat at 3.11:1 against #ff4d1f, short of the 4.5:1 body-text bar, and going white barely helped. All three sentences moved onto ink (#17141a) pill backgrounds for 17.11:1, and the 06 placeholder hint went from .56 to .72 ink for 7.26:1. The pills then pushed 07 fifteen pixels past the height budget of the 320×200 phone check, so on narrow screens the fix was to shrink only — row height and pill padding down, never up. The measured contrast table for all thirty-five text elements lives in run/288/_probe/_대비실측.json, the pre-fix numbers in run/288/_수리전실측.json. Turning pressed input into a picture, as the keycast in 09 does, is the same move the audio visualizer makes on the sound side. All nine originals with these fixes baked in are in the zip below, and the password to open it is <span class="pw-inline" id="pw">pe8qtpqk</span>.

Three variations

Name Changed value Feel
Thicker keycap 01 box-shadow: 0 6px 0 to 0 8px 0 The cap reads more mechanical, like an old clicky board
Roomier window 05 WINDOW_MS = 1200 to 1800 Slower hands keep up with the two-key sequence
Three-chip stream 09 MAX = 5 to 3 The corner overlay covers less of the slide you are presenting

AI prompt

When vibe-coding a shortcut part from scratch, pinning what shows, how it moves, and the limits in one pass cuts the rewrite count. This is the fragment that produced the help sheet.

Build a help sheet.
Shows: a 330px card holding one search box and two shortcut groups (editing, navigation); each row is an action name plus kbd keycaps.
Motion: opens on the ? key or the question button with a 300ms clip-path inset wipe; closes on Escape.
Limits: keep the sheet inset to the card; filter the list in place from the search box without re-rendering it.
Never: opening with an opacity fade, or a loop that leaves a half-open frame as the still image.

If the result opens with a fade instead of the wipe, swap opacity fade for clip-path inset wipe in your prompt fix — one phrase, opposite outcome.

Accessibility

Under prefers-reduced-motion: reduce every part stops moving but leaves a still frame you can read a state from. 01 lines the three keycaps up unpressed, 02 freezes on the detected platform's labels, 03 keeps its tooltips folded with buttons intact. 04 stands with the first row selected, 05 holds the half-drained waiting bar, 06 shows the empty field with its hint. 07 keeps the badged table as is, 08 shows the sheet open, and 09 lays five chips faintly across the panel. State is also spoken: 02's toggle carries aria-pressed, 04's list roves tabindex with aria-selected, 06 and 07 announce through aria-live, 08's sheet is aria-modal, and 03's buttons carry aria-keyshortcuts so the combination is announced with the button name. Alt-based combinations are avoided on purpose — they collide with Windows menu accelerators and browser window commands that a page cannot intercept — and both the recorder and the sheet ignore keys while isComposing is true, for IME users.

FAQ

Which browsers have userAgentData

Per caniuse it is at 77.83% global — Chrome 90+ plus the Chromium family (Edge, Opera), while Safari and Firefox do not ship it yet. That is exactly why 02 reads userAgentData.platform first and falls back to navigator.platform when the property is missing.

What if my shortcut collides with the browser's own

Keep the interception as narrow as 06 does: preventDefault only while the recorder field holds focus, so the rest of the page keeps its browser behavior. Combinations the browser never hands to the page — Ctrl+W, Ctrl+T — cannot be intercepted at all, so pick different keys at design time, and skip Alt combinations because the OS menu gets them first.

What about keyboard shortcuts ui on devices without a physical keyboard

01, 04, 05 and 09 only mean something with keys, so gate their hint text with (hover: hover) and (pointer: fine) and keep a touch path beside every action — 04's clickable rows, 08's question button. The 03 tooltip can likewise show on :focus-visible alone, which touch never triggers.

Enter the archive password

The password is inside this article. You will find it as you read.