GODRICH

ショートカットキー 表示 UI 9選 — キーキャップからヘルプシートまで

ショートカットキー 表示 UI とは、キーボードで画面を操作するためのキーキャップ・表記・案内を設計する仕事です。ここにある9点は、キーキャップの3スタイルからOS自動切替・録画・衝突テーブル・ヘルプシートまでをCSS と少しの JS で作ったもので、貼り付けたらそのまま動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9点の並び順は部品の種類ではなく、ショートカットが出会う順番です。まず目に見える3つ — キーキャップの形を選び(01)、使う人の OS に合わせ(02)、ボタンにヒントを付けます(03)。次に手が動く2つ — リストを J・K でなぞり(04)、G を押して I を押す2キー連続で画面を移動します(05)。それから定義する3つ — 組み合わせを録画して登録し(06)、重なる組み合わせをテーブルで捕まえ(07)、はてなマークで今の画面のショートカットを開きます(08)。最後の1つは人に見せる側 — 押したばかりのキーを画面の隅に流して、録画やデモに使います(09)。ステージ色はインク3・イエロー2・オレンジ2・紙2で回して、9マスの明るさが片側に寄らないように合わせました。検索窓を開くコマンドパレットとその中のショートカットバッジはコマンドパレット検索 UI 9選が、動画画面のショートカットオーバーレイは動画プレイヤーコントロール9選が扱います。指のジェスチャーで同じ場所を埋める側はモバイルタッチジェスチャー9選にあります。

01キーキャップ3スタイル

同じ文字をフラット・立体・アウトラインの3つのキャップで見せます。立体キャップの厚みは box-shadow で作り、押し込みは translateY だけにします — scale で縮めると文字まで一緒に潰れて見えるからです。実際に A・S・D を押すとそのキャップだけが沈み、keyup で戻ります — 動きは静かな方で、累積面積 1.495% ながら3つのキャップが交代で押されて移動フレームは 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プラットフォーム自動切替キーキャップ

Mac では ⌘、Windows では Ctrl と、同じショートカットが違って見えます。判定は userAgentData.platform を先に見て、その属性がないブラウザでは古い navigator.platform に降り、トグルで手動切り替えもできます。2つの表記は同じグリッド枠に重ねて 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 — 1行だけが tabindex 0 を持ち、Tab 1回でリスト全体を通り越せます。選択表示は行ごとに塗り直すのではなく1本のバーを translateY で運び、そのバーが4行をなぞって戻るループで、累積面積 29.468% は9点中2番目に大きく、移動フレームは 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 });
}

052キー連続入力

G を押して続けて I を押すと受信トレイへ移動します。最初のキーが入った瞬間から残り時間が scaleX のバーで減り、時間切れになると setTimeout が待機状態を自分で解除するので、中途半端な入力が残りません。キーが沈む動きとバーが減る区間が長く続くので、累積面積 3.647%・強度平均 137.8・移動フレーム 16/23 と、この中で最もせわしなく動きます。

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 でブラウザ既定の動作を止めて組み合わせだけを受け付け、IME 変換中に来るキーは 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衝突検出テーブル

同じ組み合わせに2つの動作が割り当たると、その2行に警告バッジと輪郭線が付くテーブルです。組み合わせごとに使う行を数えて2行以上を衝突とみなし、警告文は aria-live で一度だけ読み上げられ、どちらかを変えるとバッジが「衝突」から「問題なし」へ一緒に変わります。バッジ2枚を同じ枠に重ねて 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点中最大で、移動フレームは 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 グラデーションを一度被せて、位置がそのまま古さになるようにしました。5個を超えると最も古いものから外れ、プレビューは同じ5コマをもう1組置いて 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 のプレビューは同じ5コマを2組並べたトラックを translateX(-50%) で回すものですが、トラックに width: max-content を与えた瞬間、その本来の幅が親のレイアウト計算にそのまま染み込みました。パネルは min(100%, 300px) で制限してあったのに、320px の画面で document.body.scrollWidth が 369px と出て狭い画面の検査に引っかかりました — パネルの overflow: hidden は描かれる物を切り取るだけで、レイアウトが計算した幅は戻してくれません。パネルの幅を width: 300px + max-width: 100% で固定して寄与を遮ると、同じ計測で 320px に収まりました。同じトラックにはもう1つ規則があります。2組の間隔は flex の gap ではなくコマごとの margin-right で — 10コマの間にできる gap は9個なので、-50% が毎周半分ずつ足りなくなり飛びます。無限マーキーがマージンを使うのと同じ理由です。

2つ目は、静止画が常に最も変化の大きいフレームから選ばれることです。08 のシートが開く様子を、ユーザーが目にするそのままの clip-path ワイプで見せようとしたら、24フレームのうち1枚が半開きの瞬間を捕まえ、そのフレームが最も大きく変わっていたので毎回ポスターになってしまいました。プレビューループでは開く瞬間だけ steps(1, end) のカットに変え、実際のクリックや ? 入力はワイプのままにしたところ、開いたシートがポスターに収まりました。閉じる場面はループに入れていません — 閉じると空の文書が最大の変化になって、その空白がポスターを占めてしまうからです。03 のツールチップも同じ理由で、3つが繋がって常に1つは見えているように並べました。

3つ目はオレンジのステージ上のクリーム色の文字でした。03 の案内文と 07 のタイトル・警告文をクリーム(#fff7e6)で書いたところ、オレンジ(#ff4d1f)の上で 3.11:1 となり、本文の基準(4.5:1)に届きませんでした。白に変えてもほとんど変わらないので、3つの文はすべてインク(#17141a)のピル背景の上に移して 17.11:1 にし、06 の案内文はインクの濃度を .56 から .72 に上げて 7.26:1 にしました。このピルのせいで 07 が 320px の高さを15px 超えるようになったので、狭い画面では値を増やす方向ではなく減らす方向だけ — 行の高さとピルの余白を縮めて — 合わせました。画面の文字35箇所の実測対比表は run/288/_probe/_대비실측.json に、修復前の値は run/288/_수리전실측.json に残してあります。こうして直した箇所まで全部入った9点のオリジナルは下の zip にあり、開くパスワードは <span class="pw-inline" id="pw">pe8qtpqk</span> です。

3つのバリエーション

名前 変える値 感じ
厚いキーキャップ 01 box-shadow: 0 6px 00 8px 0 キャップがより盛り上がり、古いメカニカルキーボードの風合いが出ます
余裕のある入力窓 05 WINDOW_MS = 12001800 2キーの間の猶予が増えて、初心者も順番を逃しません
3コマの流し表示 09 MAX = 53 画面の隅をあまり占有せず、デモ中の本文を隠しません

AIプロンプト

バイブコーディングでショートカット部品を1つ新しく作るときは、見えるもの・動き・制約の3つを一度に固定すると書き直しが減ります。下は 08 のヘルプシートを作ったときの断片です。

ヘルプシートを作る。
見えるもの: 330px カードの中に検索欄1つとショートカットグループ2つ(編集・移動)。各行は動作名と kbd キーキャップ。
動き: ? キーかはてなボタンで開く。開きは clip-path inset のワイプ 300ms。Esc で閉じる。
制約: シートはカードの中に inset 0 で閉じ込める。検索はリストを描き直さずその場で絞り込む。
禁止: opacity フェードで開くこと。半開きのフレームが静止画として残るループ。

clip-path inset のワイプopacity フェード に置き換えると重なった文字が半透明の幽霊として写るので、結果が逆に出たときはまずこの1語から見ます。

アクセシビリティ

prefers-reduced-motion: reduce では9点すべてのアニメーションを止めつつ、状態の読み取れる1枚を残します。01 は3つのキャップを押されていない姿で並べ、02 は判定したプラットフォームの表記で止まります。03 はツールチップを畳んだままボタンを置き、04 は最初の行が選ばれた状態で立ちます。05 は半分減ったバーの待機姿、06 は案内文が見える空欄で止まります。07 はバッジ付きのテーブルをそのまま残し、08 は開いたシートを見せ、09 は5コマが薄く敷かれた盤で止まります。状態を声でも伝えるため、02 のトグルは aria-pressed、04 のリストはロービング tabindexaria-selected、06・07 は aria-live、08 のシートは aria-modal を持ち、03 のボタンには aria-keyshortcuts を付けてスクリーンリーダーがボタン名と一緒に組み合わせを読むようにしました。ショートカットを捕まえる際に Alt の組み合わせは避けました — Windows のメニューアクセスキーやブラウザのウィンドウ命令と重なり、ページでは止められない動作が先に起こるためです。韓国語・日本語の IME 利用者のために、録画とシートの開閉は isComposing が真の間は無視します。

FAQ

userAgentData はどこまで使えますか

caniuse の実測では全世界 77.83% — Chrome 90 以降と Edge・Opera などの Chromium 系は対応していますが、Safari と Firefox はまだ未対応です。だから 02 は userAgentData.platform を先に見て、なければ navigator.platform に降りる2段構えにしています。

ショートカットがブラウザの既定動作と重なるときはどうすればいいですか

06 の録画欄のように、その画面がフォーカスを持っている間だけ preventDefault で止める範囲を狭めます。Ctrl+W・Ctrl+T のようにブラウザがページに渡してくれない組み合わせは止められないので、設計段階で別のキーに逃がし、Alt の組み合わせは OS のメニューと重なるためこれも避けるのが定石です。

物理キーボードがない端末ではどうなりますか

01・04・05・09 はキーがあって意味を持つので、ヒント文は (hover: hover) and (pointer: fine) メディアクエリで表示だけ切り替えるのが定石で、動作そのものは 04 の行クリック・08 のはてなボタンのようにタッチの経路を並べて置きます。03 のツールチップも :focus-visible のときだけ出るようにすれば、タッチでは現れません。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。