GODRICH

키보드 단축키 UI 9종 — 키캡부터 도움말 시트까지

단축키 UI는 키보드로 화면을 다루게 하는 키캡·표시·안내를 짜는 일입니다. 여기 아홉 가지는 키캡 세 스타일부터 플랫폼 자동 전환·녹화·충돌 표·도움말 시트까지 CSS 와 약간의 JS 로 만들어 둔 것이라, 붙여 넣으면 그대로 돕니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개를 늘어놓은 기준은 기능의 종류가 아니라 단축키가 사람을 만나는 순서입니다. 먼저 보이는 쪽 셋 — 키캡 생김새를 고르고(01), 쓰는 사람의 OS 에 맞추고(02), 버튼에 힌트를 붙입니다(03). 다음은 손이 움직이는 쪽 둘 — 목록을 J·K 로 훑고(04), G 누르고 I 누르는 두 키 연속으로 화면을 옮깁니다(05). 그다음은 정의하는 쪽 셋 — 조합을 녹화해 등록하고(06), 겹치는 조합을 표로 잡아내고(07), 물음표로 지금 화면의 단축키를 펼쳐 봅니다(08). 마지막 하나는 남에게 보여 주는 쪽 — 방금 누른 키를 화면 구석에 흘려 녹화와 시연에 씁니다(09). 무대 색은 잉크 셋·옐로 둘·오렌지 둘·종이 둘로 돌려 아홉 칸의 밝기가 한쪽으로 쏠리지 않게 맞췄습니다. 검색창을 여는 커맨드 팔레트와 그 안의 단축키 배지는 커맨드 팔레트 검색 UI 9종이, 영상 화면의 단축키 오버레이는 비디오 플레이어 컨트롤 9종이 다룹니다. 손가락 제스처로 같은 자리를 채우는 쪽은 모바일 터치 제스처 9종에 있습니다.

01키캡 스타일 세 가지

같은 글자를 평면·입체·테두리 세 키캡으로 보여줍니다. 입체 키캡의 두께는 box-shadow 로 만들고 누름은 translateY 로만 내려 — scale 로 줄이면 글자까지 눌려 보입니다. 실제로 A·S·D 를 누르면 해당 키캡만 내려앉았다가 keyup 에 돌아오며, 누적면적 1.495%·움직인 프레임 13/23 으로 세 키캡이 돌아가며 눌립니다.

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; }

02플랫폼 자동 전환 키캡

맥에서는 ⌘, 윈도우에서는 Ctrl 로 같은 단축키가 다르게 보입니다. 감지는 userAgentData.platform 을 먼저 보고 없으면 예전 navigator.platform 으로 내려가며, 두 표기는 같은 격자 칸에 겹쳐 visibility 로만 골라 글자가 바뀌어도 줄이 밀리지 않습니다. 표기가 바뀌는 컷마다 키캡이 튀어오르는 보조 움직임을 붙여 누적면적 6.392%·움직인 프레임 11/23 을 기록했습니다.

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';
}

03단축키 힌트 툴팁

버튼에 마우스를 올리면 그 버튼의 단축키가 작은 키캡으로 떠오릅니다. 같은 값을 aria-keyshortcuts 에도 적어 스크린리더가 버튼 이름과 함께 읽어 주고, 실제로 Ctrl+F 를 누르면 찾기 버튼이 한 번 눌립니다. 나타남은 visibility 컷과 translateY 이동으로만 — opacity 페이드는 정지 이미지에 반투명 유령을 남겨 쓰지 않았고, 툴팁이 이어 떠오르는 루프 기준 움직인 프레임은 8/23·누적면적 5.105% 입니다.

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·K 목록 이동

메일함처럼 J 로 내려가고 K 로 올라가며 Enter 로 여는 목록입니다. 초점은 로빙 tabindex — 한 줄만 tabindex 0 을 가져 Tab 한 번이면 목록 전체를 지나가고, 선택 표시는 줄마다 다시 칠하지 않고 막대 하나를 translateY 로 옮깁니다. 막대가 네 줄을 훑고 돌아오는 루프라 누적면적 29.468% 로 아홉 개 중 두 번째로 크게 움직이고 움직인 프레임은 9/23 입니다.

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 });
}

05두 키 연속 입력

G 를 누르고 이어서 I 를 누르면 받은 편지함으로 갑니다. 첫 키가 들어온 순간 남은 시간이 scaleX 막대로 줄고, 시간이 다 되면 setTimeout 이 대기 상태를 스스로 풀어 쌓인 첫 키가 영영 남지 않게 합니다. 키가 눌리는 내려앉음과 막대가 줄어드는 구간이 길게 이어져 누적면적 3.647%·움직인 프레임 16/23 이고, 강도 평균 137.8 은 아홉 개 중 가장 높습니다.

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);
}

06단축키 녹화 입력칸

칸에 초점을 두고 조합을 그대로 누르면 Ctrl+Shift+K 처럼 적힙니다. 칸이 초점을 가진 동안에는 preventDefault 로 브라우저 기본 동작을 막고, 한글·일본어 입력기가 조합 중일 때 오는 키는 isComposing 을 보고 건너뛰며 이미 쓰는 조합이면 테두리와 경고 문장이 붙습니다. 키캡이 차례로 찍히고 경고까지 이어지는 루프라 누적면적 2.951%·움직인 프레임 11/23 입니다.

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);
});

07충돌 감지 표

같은 조합에 두 가지 동작이 걸리면 그 두 줄에 경고 배지와 윤곽선이 붙는 표입니다. 조합마다 몇 줄이 쓰는지 세어 두 줄 이상이면 충돌로 보고, 경고 문장은 aria-live 로 한 번 읽히며 한쪽을 바꾸면 배지가 「충돌」에서 「확인」으로 바뀝니다. 배지 두 장을 같은 칸에 겹쳐 visibility 로만 고르므로 칸 폭이 흔들리지 않고, 누적면적 7.233%·움직인 프레임 8/23 입니다.

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);
  });
}

08물음표 도움말 시트

물음표를 누르면 지금 화면에서 쓸 수 있는 단축키만 묶음별로 펼쳐집니다. 위쪽 검색칸에 글자를 넣으면 목록이 그 자리에서 걸러지고 Esc 로 닫히며, 시트는 role="dialog"aria-modal 을 단 판이라 초점이 밖으로 새지 않습니다. 시트가 카드를 통째로 덮는 만큼 누적면적 33.781% 로 아홉 개 중 가장 크고 움직인 프레임은 9/23 입니다.

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);
  });
});

09눌린 키 흘림 표시

방금 누른 키가 화면 구석에 쌓였다가 오래된 것부터 옅어지며 왼쪽으로 흘러 나갑니다. 옅어짐은 칸마다 불투명도를 매기는 대신 판 전체에 mask-image 그라디언트를 한 번 씌워 자리가 곧 나이가 되게 했고, 다섯 개를 넘으면 가장 오래된 것부터 빠집니다. 미리보기는 같은 다섯 칸을 한 벌 더 두고 translateX(-50%) 로 흘려 이음새를 없앴습니다 — 매 프레임이 움직여 움직인 프레임 23/23, 누적면적 5.389% 입니다.

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);
}

어디서 깨지는가 — 함정

이 아홉 개에서 가장 시간을 잡아먹은 것은 무한 흐름 트랙이 부모 폭을 넓히는 일이었습니다. 09 흘림 표시의 미리보기는 같은 칸 다섯 개를 두 벌 늘어놓고 translateX(-50%) 로 도는 트랙인데, 이 트랙에 width: max-content 를 준 순간 트랙의 본래 폭이 부모 레이아웃 계산에 그대로 새어 들어갔습니다. 판은 min(100%, 300px) 로 제한해 두었는데도 320px 폭에서 document.body.scrollWidth 가 369px 로 잡혀 좁은 화면 검사(BD9)에 걸렸습니다 — 숨긴 판의 overflow: hidden 은 보이는 칸만 자를 뿐 레이아웃 폭을 되돌려 주지 않습니다. 판 폭을 width: 300px + max-width: 100% 로 못 박아 기여를 차단하니 같은 측정에서 320px 로 들어왔습니다. 같은 트랙의 또 하나 — 두 벌 사이 간격을 gap 으로 주면 열 칸 사이에 아홉 개만 생겨 -50% 가 매 바퀴 반 칸씩 모자라 튑니다. 칸마다 margin-right 를 주는 무한 마퀴의 규칙을 그대로 따랐습니다.

두 번째는 정지 이미지가 항상 최대 변화 프레임에서 뽑힌다는 점입니다. 08 도움말 시트의 열림을 실사용 그대로 clip-path 위프로 보여 주려다 24프레임 중 한 장이 반쯤 열린 장면으로 잡혀, 그 장이 가장 크게 변한 프레임이라 대표 이미지가 늘 「반쯤 가린 문서」로 나왔습니다. 미리보기 루프에서만 열림을 steps(1, end) 컷으로 바꾸고 실제 클릭·? 입력은 위프 그대로 두니 열린 시트가 대표 이미지로 잡혔습니다. 닫히는 장면은 아예 루프에 넣지 않았습니다 — 닫히면 빈 문서가 최대 변화가 되어 그 빈 화면이 대표 이미지를 차지하기 때문입니다. 03 툴팁도 같은 이유로 세 툴팁이 이어 달리게 잡아 언제나 한 개는 떠 있게 했습니다.

세 번째는 주황 무대 위의 크림 글자였습니다. 03 안내 문구와 07 표 제목·경고 문장을 크림(#fff7e6)으로 쓰었더니 주황 무대(#ff4d1f) 위에서 3.11:1 로 본문 기준(4.5:1)에 못 미쳤습니다. 흰색으로 올려도 사정이 나아지지 않아 세 문장 모두 잉크(#17141a) 알약 배경 위로 옮겨 17.11:1 을 만들었고, 06 의 안내 문구는 잉크 농도 .56 에서 .72 로 올려 3.74:1 을 7.26:1 로 끌어올렸습니다. 여기서 07 이 320px 에서 15px 세로로 넘치게 되었는데, 알약 두 개가 세로 여백을 더 쓴 탓이라 좁은 화면에서는 값을 올리지 않고 반대로만 — 줄 높이와 알약 세로 여백을 줄여 — 맞췄습니다. 화면 글자 서른다섯 곳의 실측 대비표는 run/288/_probe/_대비실측.json 에, 수리 전 값은 run/288/_수리전실측.json 에 남겨 두었습니다. 이렇게 고친 자리까지 그대로 담긴 아홉 벌의 원본은 zip 안에 있고, 압축을 여는 비밀번호는 <span class="pw-inline" id="pw">pe8qtpqk</span> 입니다.

변형 3개

이름 바꾼 값 느낌
두꺼운 키캡 01 box-shadow: 0 6px 00 8px 0 키가 더 볼록해져 낡은 기계식 키보드 느낌이 납니다
넉넉한 대기창 05 WINDOW_MS = 12001800 두 키 사이 여유가 늘어 초보자도 순서를 놓치지 않습니다
세 칸 흘림 09 MAX = 53 화면 구석을 덜 차지해 시연 중 본문을 가리지 않습니다

AI 프롬프트

바이브코딩으로 단축키 부품을 하나 새로 만들 때는 보일 것·움직임·제약 셋을 한 번에 못 박는 편이 고쳐 쓰는 횟수를 줄입니다. 아래는 08 도움말 시트를 만들 때 쓴 조각입니다.

도움말 시트를 만든다.
보일 것: 330px 카드 안에 검색칸 하나와 단축키 묶음 두 개(편집·이동), 각 줄은 동작 이름과 kbd 키캡.
움직임: ? 키나 물음표 버튼으로 열 때 clip-path inset 위프 300ms, Esc 로 닫기.
제약: 시트는 카드 안 inset 0 으로 가두고, 검색은 목록을 다시 그리지 않고 그 자리에서 걸러라.
금지: opacity 페이드로 열기, 반쯤 열린 장면이 정지 이미지로 남겨지는 루프.

clip-path inset 위프 자리를 opacity 페이드 로 바꾸면 겹치는 글자가 반투명 유령으로 찍히니, 결과가 반대로 나올 때는 이 한 낱말부터 봅니다.

접근성

화면 움직임을 줄이는 prefers-reduced-motion: reduce 설정에서는 아홉 개의 애니메이션을 전부 끄고, 대신 어떤 상태인지 읽히는 한 장을 남깁니다. 01 은 세 키캡을 나란히 세워 두고 누른 키 칸의 미리보기 글자를 지우며, 02 는 감지한 플랫폼 표기로 멈춥니다. 03 은 툴팁을 접은 채 버튼만 두고, 04 는 첫 줄이 선택된 상태로 섭니다. 05 는 막대를 절반쯤 채운 대기 모습으로, 06 은 안내 문구가 보이는 빈 칸으로 멈춥니다. 07 은 충돌 배지가 붙은 표를 그대로 두고, 08 은 열린 시트를 보여 주며, 09 는 다섯 칸이 옅게 깔린 판으로 멈춥니다. 상태를 말로도 전하려고 02 토글은 aria-pressed, 04 목록은 로빙 tabindexaria-selected, 06·07 은 aria-live, 08 시트는 aria-modal 을 달았고 03 버튼에는 aria-keyshortcuts 를 적어 스크린리더가 버튼 이름과 함께 조합을 읽게 했습니다. 단축키를 잡을 때 Alt 조합은 피했습니다 — 윈도우 메뉴 접근키·브라우저 창 명령과 겹쳐 우리가 막을 수 없는 동작이 먼저 일어나기 때문입니다. 한글·일본어 입력기 사용자를 위해 녹화기와 시트 열림은 isComposing 참을 때 무시합니다.

FAQ

userAgentData 는 어느 브라우저까지 되나요

caniuse 실측 기준 전역 77.83% — Chrome 90 이상과 엣지·오페라 등 크로미움 계열은 쓰지만 Safari 와 Firefox 는 아직 지원하지 않습니다. 그래서 02 는 userAgentData.platform 을 먼저 보고 없으면 navigator.platform 으로 내려가는 두 단계로 짰습니다.

단축키가 브라우저 기본 동작과 겹칠 때는 어떻게 하나요

06 녹화 칸처럼 그 화면이 초점을 가진 동안만 preventDefault 로 막는 범위를 좁힙니다. Ctrl+W·Ctrl+T 처럼 브라우저가 아예 페이지에 넘겨 주지 않는 조합은 막을 수 없으니 설계 단계에서 다른 조합으로 피하고, Alt 조합은 OS 메뉴와 겹쳐 역시 피하는 것이 정석입니다.

물리 키보드가 없는 기기에서는 어떻게 하나요

01·04·05·09 는 키가 있어야 의미가 있으므로 (hover: hover) and (pointer: fine) 미디어쿼리로 힌트 문구만 노출 조절하고, 동작 자체는 04 의 줄 클릭·08 의 물음표 버튼처럼 터치 경로를 나란히 둡니다. 03 툴팁도 터치에서는 :focus-visible 로 초점이 갔을 때만 보이게 둘 수 있습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.