GODRICH

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 shortcuts, 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.

01Three keycap styles

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. 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.

box-shadowtranslateYkeyup
.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;
}
// 두께 6px 은 그림자로만 만든다 — 누를 때 이 값이 2px 로 줄면서 키가 내려앉는다
.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

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. A small pop on every swap lifts it to 6.392% area and 11/23 moving frames.

userAgentDatasteps(1, end)aria-pressed
// userAgentData.platform 이 있으면 그걸 먼저 보고, 없는 브라우저에서는 예전 속성으로 내려간다
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

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. 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.

aria-keyshortcutstranslateYtransition
.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

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. The bar sweeping four rows and back makes this the second-largest mover at 29.468% area with 9/23 moving frames.

tabindexpreventScrollaria-selected
// 로빙 tabindex — 목록 전체가 아니라 한 줄만 tabindex 0 을 갖는다
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');
  });
  // iframe 안에서 preventScroll 없이 부르면 부모 페이지가 이 줄로 끌려 내려간다
  rows[index].focus({ preventScroll: true });
}

05Two-key sequence

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. 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.

setTimeoutscaleXtransform-origin
var WINDOW_MS = 1200;
function startChord() {
  pending = true;
  cs.classList.add('is-pending');
  cs.classList.remove('is-done');
  clearTimeout(timer);
  // 시간 안에 두 번째 키가 안 오면 대기를 스스로 푼다 — 쌓인 첫 키가 영영 남지 않게
  timer = setTimeout(cancelChord, WINDOW_MS);
}

06Shortcut recorder field

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. Caps stacking up into the warning give 2.951% area and 11/23 moving frames.

preventDefaultisComposingkeydown
field.addEventListener('keydown', function (e) {
  // 한글·일본어 입력기가 조합 중일 때 오는 키는 글자를 만드는 중이라 단축키가 아니다
  if (e.isComposing) { return; }
  if (e.key === 'Tab') { return; }
  // 칸이 초점을 가진 동안에는 Ctrl+S 같은 브라우저 기본 동작을 막고 조합만 받아 적는다
  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

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. The two badges stack in one cell and are picked with visibility, so the column never wobbles: 7.233% area, 8/23 moving frames.

aria-livegrid-template-columnsoutline
// 조합마다 몇 줄이 쓰는지 세어, 두 줄 이상이면 그 줄을 충돌로 본다
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

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. Because the sheet covers the whole card it is the largest mover of the nine: 33.781% area with 9/23 moving frames.

clip-pathsteps(1, end)aria-modal
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

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. 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.

translateXmask-imagemargin-right
.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. 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 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.