ショートカットキー 表示 UI 9選 — キーキャップからヘルプシートまで
ショートカットキー 表示 UI とは、キーボードで画面を操作するためのキーキャップ・表記・案内を設計する仕事です。ここにある9点は、キーキャップの3スタイルからOS自動切替・録画・衝突テーブル・ヘルプシートまでをCSS と少しの JS で作ったもので、貼り付けたらそのまま動きます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 キーキャップ3スタイル
- 02 プラットフォーム自動切替キーキャップ
- 03 ショートカットヒントのツールチップ
- 04 J・Kのリスト移動
- 05 2キー連続入力
- 06 ショートカット録画フィールド
- 07 衝突検出テーブル
- 08 はてなマークのヘルプシート
- 09 押したキーの流し表示
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 です。
.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 を記録しました。
// 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% です。
.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 です。
// 로빙 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 と、この中で最もせわしなく動きます。
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 です。
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 です。
// 조합마다 몇 줄이 쓰는지 세어, 두 줄 이상이면 그 줄을 충돌로 본다
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 です。
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% です。
.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 0 を 0 8px 0 に |
キャップがより盛り上がり、古いメカニカルキーボードの風合いが出ます |
| 余裕のある入力窓 | 05 WINDOW_MS = 1200 を 1800 に |
2キーの間の猶予が増えて、初心者も順番を逃しません |
| 3コマの流し表示 | 09 MAX = 5 を 3 に |
画面の隅をあまり占有せず、デモ中の本文を隠しません |
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 のリストはロービング tabindex と aria-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 のときだけ出るようにすれば、タッチでは現れません。