Webアプリのキーボードショートカット実装:Cmd/Ctrl判定と入力中の無効化
JavaScriptでキーボードショートカットを実装する方法。keydown、修飾キー判定、入力中の無効化、?キーでヘルプ、Reactフックまでコピペで動く形で解説。
Cmd+Sで保存できるようにしたら、検索ボックスに「s」と打った瞬間に保存ダイアログが開くようになった。
これ、僕が最初に作ったショートカットの話です。動作確認では「おお、効く効く」と喜んでいたのに、テスターから「文字が打てません」とバグ報告が来て、ようやく気づきました。keydownを窓口にしただけだと、入力欄でタイプしている最中のキーまで全部ショートカットに吸い込まれるんですね。
Webアプリのショートカットは、見た目は地味なのに落とし穴が深い機能です。修飾キー(Cmd/Ctrl)の判定、Macとwindowsの違い、入力中の無効化、ブラウザ標準キーとの衝突、IME(日本語変換)中の誤爆。どれか一つ抜けると、便利機能のはずが「文字が打てないアプリ」に化けます。
この記事では、素のJavaScriptで動く土台を作り、そこからReactフックに育て、?キーで一覧を出すヘルプまで、僕が実際に踏んだ地雷ごと書きます。
この記事の要点
- ショートカットは
keydownで拾い、e.key(文字)と修飾キー(metaKey/ctrlKey)の組み合わせで判定する。keyCodeはもう使わない。 - MacとWindowsで「主修飾キー」が違う。
e.metaKey || e.ctrlKeyで吸収すると1行で両対応できる。 - 入力中(
input/textarea/contenteditable)とIME変換中(e.isComposing)は、ショートカットを必ず止める。ここが事故の9割。 - ブラウザ標準(
Cmd+S等)を奪うならe.preventDefault()が要る。奪わない設計のほうが安全。 ?キーで一覧を出すヘルプと、操作を1か所に集める「登録テーブル」を持つと、競合も保守も楽になる。
まず素のJavaScriptで土台を作る
フレームワークの前に、ブラウザがキー入力をどう渡してくるかを押さえます。ここを飛ばすと、Reactに包んだ瞬間に「なぜか効かない」で詰まります。
キー入力にはkeydown・keypress・keyupの3つがありますが、ショートカットはkeydown一択です。keypressは廃止予定で、文字を伴わないキー(矢印やEscape)を拾えません。そして読むプロパティはe.keyです。e.keyは「押された結果の文字」を返すので、"a"、"Enter"、"Escape"、"?"がそのまま入ります。昔よく見たe.keyCode === 83のような数値判定は、非推奨なうえに国際キーボードで壊れます。今から書くなら使いません。
修飾キーは4つのboolプロパティで分かります。e.metaKey(MacのCmd、WindowsのWinキー)、e.ctrlKey、e.shiftKey、e.altKey。詳しい一覧はMDNのKeyboardEventが正確なので、迷ったらそこを見てください。
最小の例を書きます。Ctrl+K(MacはCmd+K)で検索を開く、というよくあるやつです。
document.addEventListener("keydown", (e) => {
// Mac の Cmd と Windows の Ctrl を1行で吸収する
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === "k") {
e.preventDefault(); // ブラウザの標準動作(一部ブラウザの検索)を止める
openSearch();
}
});
ポイントはe.metaKey || e.ctrlKeyの1行です。MacユーザーはCmd、WindowsユーザーはCtrlを「主役の修飾キー」だと思っています。OS判定でif分岐を書く人もいますが、両方trueの瞬間はまずないので、ORで束ねるのが一番シンプルで壊れにくいです。
そしてe.key.toLowerCase()。Shiftを一緒に押すとe.keyが"K"(大文字)になったり、Caps Lockの状態で変わったりします。文字キーを判定するときは小文字に正規化しておくと、無用なすれ違いが消えます。
入力中とIME変換中は必ず止める
ここが冒頭の事故の正体です。そして、ほとんどのショートカットバグはこの一点に集約されます。
documentにkeydownを付けると、検索ボックスでもコメント欄でも、ユーザーがタイプするすべてのキーがハンドラに流れ込みます。sを打てば保存、/を打てばコマンドパレット、では文章が書けません。だから「今フォーカスが入力欄にあるか」を最初に確認して、入力中ならショートカット処理を素通りさせます。
もう一つ、日本語圏で絶対に外せないのがIME(変換)中の判定です。「きーぼーど」と打って変換候補からEnterで確定したいのに、そのEnterがショートカットに食われたら最悪です。これはe.isComposingを見れば一発で防げます。変換中はこれがtrueになります。
この2つを関数にまとめておくと、毎回コピペせずに済みます。
// ショートカットを「無視すべき状況」かどうかを判定する
function shouldIgnore(e) {
// 1. IME(日本語変換)の最中は何もしない
if (e.isComposing || e.keyCode === 229) return true;
// 2. 入力系の要素にフォーカスがあるなら何もしない
const el = e.target;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (el.isContentEditable) return true; // リッチエディタなど
return false;
}
document.addEventListener("keydown", (e) => {
if (shouldIgnore(e)) return; // 入力中・変換中はここで離脱
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === "k") {
e.preventDefault();
openSearch();
}
});
e.keyCode === 229を併記しているのは保険です。一部の古いブラウザやAndroidのソフトキーボードでは、変換中にisComposingが立たず、代わりにkeyCodeが229になることがあります。両方見ておくと取りこぼしが減ります。
例外として、「入力欄にいても効かせたいショートカット」もあります。Cmd+S(保存)やEscape(閉じる)です。その場合はshouldIgnoreを一律で適用せず、キーごとに「入力中も通すか」を持たせます。後半のReact版でそこも作ります。
修飾キーの組み合わせを正しく判定する
Ctrl+Kくらいならmod && e.key === "k"で済みますが、ショートカットが増えると判定がごちゃつきます。Cmd+Shift+PとCmd+Pを別物として扱いたいのに、雑に書くと両方反応する、みたいな事故が起きます。
僕は「押されている修飾キーを文字列にまとめてから比較する」方式に落ち着きました。キーの組み合わせを"mod+shift+p"のような一意の文字列に変換して、登録済みの文字列と突き合わせるだけ。これなら新しいショートカットを足すのが"mod+s": saveの1行になります。
// イベントから "mod+shift+p" のような正規化キーを作る
function toComboString(e) {
const parts = [];
if (e.metaKey || e.ctrlKey) parts.push("mod"); // Cmd/Ctrl をまとめる
if (e.shiftKey) parts.push("shift");
if (e.altKey) parts.push("alt");
parts.push(e.key.toLowerCase());
return parts.join("+");
}
Cmd+Shift+Pを押すと"mod+shift+p"、Cmd+Pなら"mod+p"。修飾キーの並び順をmod→shift→altで固定しているので、登録側の書き順を気にしなくていいのも地味に効きます。あとはこの文字列をキーにしたテーブルを用意すれば、判定ロジックがほぼ消えます。
| 押したキー | toComboString の結果 | 用途の例 |
|---|---|---|
Cmd+K / Ctrl+K | mod+k | 検索を開く |
Cmd+Shift+P | mod+shift+p | コマンドパレット |
Escape | escape | モーダルを閉じる |
?(Shift+/) | ? | ヘルプを表示 |
g → h | (連続入力。後述) | ホームへ移動 |
?が少し特殊です。多くのキーボードで?はShift+/なので、e.shiftKeyはtrueですが、e.keyは"?"という文字そのものが返ります。なのでtoComboStringではshiftが混ざって"shift+?"になりがちです。?だけは「文字で判定する」と割り切って、e.key === "?"を直接見たほうが素直です。
Reactフックにまとめて再利用する
土台ができたので、Reactで使い回せる形にします。コンポーネントごとにaddEventListenerとremoveEventListenerを書くのは、消し忘れによるメモリリークの温床です。フック1個に閉じ込めます。
下のコードはそのまま貼って動きます。useShortcutsに「コンボ文字列 → 関数」のオブジェクトを渡すだけ。入力中の無効化とIME判定は内部で済ませてあります。「入力中も効かせたい」キーはallowInInputに並べます。
import { useEffect, useRef } from "react";
type Handler = (e: KeyboardEvent) => void;
type ShortcutMap = Record<string, Handler>;
// 入力欄・変換中かどうかを判定する
function shouldIgnore(e: KeyboardEvent): boolean {
if (e.isComposing || e.keyCode === 229) return true;
const el = e.target as HTMLElement;
if (!el) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (el.isContentEditable) return true;
return false;
}
// イベントを "mod+shift+p" 形式に正規化する
function toCombo(e: KeyboardEvent): string {
if (e.key === "?") return "?"; // ? は文字で扱う
const parts: string[] = [];
if (e.metaKey || e.ctrlKey) parts.push("mod");
if (e.shiftKey) parts.push("shift");
if (e.altKey) parts.push("alt");
parts.push(e.key.toLowerCase());
return parts.join("+");
}
export function useShortcuts(map: ShortcutMap, allowInInput: string[] = []) {
// 最新の map を ref で保持し、無駄な再登録を防ぐ
const mapRef = useRef(map);
mapRef.current = map;
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const combo = toCombo(e);
const handler = mapRef.current[combo];
if (!handler) return;
// 入力中でも許可リストにあるキーだけは通す
if (shouldIgnore(e) && !allowInInput.includes(combo)) return;
e.preventDefault();
handler(e);
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
// allowInInput は配列なので join で安定比較する
}, [allowInInput.join(",")]);
}
使う側はこうです。読みやすさが段違いになります。
function App() {
useShortcuts(
{
"mod+k": () => openSearch(),
"mod+s": () => save(), // 入力中も保存したい
escape: () => closeModal(),
"?": () => openHelp(),
},
["mod+s", "escape"], // 入力欄にいても効かせるキー
);
return <main>{/* ... */}</main>;
}
useRefで最新のmapを持っている理由は、ハンドラ内で参照する関数が毎レンダリングで作り直されても、addEventListenerを貼り直さずに済むからです。これをやらないと、依存配列にmapを入れる羽目になり、レンダリングのたびにイベントの付け外しが走ります。地味ですが、ショートカットが効いたり効かなかったりする原因の一つです。
?キーでショートカット一覧を出す
ショートカットは「存在を知られて初めて使われる」機能です。どんなに良いキーを割り当てても、隠れていたら誰も使いません。Slack、GitHub、Gmail、Notion——よくできたアプリはたいてい?キーで一覧を出します。これは事実上の業界標準なので、合わせておくとユーザーが説明なしで気づきます。
実装はシンプルです。ショートカットを「定義の配列」として1か所に持ち、その同じ配列からヘルプ画面も生成します。こうすると、新しいショートカットを足したときにヘルプの更新を忘れる、という典型ミスが構造的に起きなくなります。
import { useState } from "react";
// 定義を1か所に集める(ここが唯一の情報源)
const SHORTCUTS = [
{ combo: "mod+k", label: "検索を開く", run: () => openSearch() },
{ combo: "mod+s", label: "保存", run: () => save() },
{ combo: "escape", label: "閉じる", run: () => closeModal() },
] as const;
function useAppShortcuts() {
const [helpOpen, setHelpOpen] = useState(false);
// 配列から useShortcuts 用のオブジェクトを作る
const map = Object.fromEntries(SHORTCUTS.map((s) => [s.combo, s.run]));
map["?"] = () => setHelpOpen(true);
map["escape"] = () => setHelpOpen(false);
useShortcuts(map, ["mod+s"]);
return { helpOpen, setHelpOpen };
}
function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null;
return (
<div role="dialog" aria-modal="true" aria-label="キーボードショートカット一覧">
<h2>キーボードショートカット</h2>
<ul>
{SHORTCUTS.map((s) => (
<li key={s.combo}>
<kbd>{s.combo}</kbd> — {s.label}
</li>
))}
</ul>
<button onClick={onClose}>閉じる</button>
</div>
);
}
SHORTCUTSという1本の配列が、実際の挙動とヘルプ表示の両方を駆動しています。ヘルプに出ているのに動かない(あるいはその逆)という食い違いが、構造上ありえなくなるわけです。<kbd>タグはキー表示用の標準要素なので、見た目もブラウザがそれっぽく整えてくれます。
アクセシビリティと競合回避で外せない点
ショートカットは「マウスを使わない人」にとって便利機能ではなく生命線です。だからこそ、外すと一気に使えなくなる注意点があります。
第一に、ショートカットを唯一の操作手段にしないこと。?で開くヘルプも、メニューやボタンからも開けるようにします。キーだけで完結する設計は、スクリーンリーダー利用者や片手キーボード利用者を締め出します。この考え方はClaude Codeでアクセシビリティ対応を実装する実践ワークフローに通じます。Webアプリ全般のアクセシビリティを詰めるなら、合わせて読むと土台が固まります。
第二に、ブラウザ標準とOSの予約キーを奪わないこと。Cmd+W(タブを閉じる)、Cmd+T(新規タブ)、Cmd+Q(終了)あたりは、preventDefault()しても効かない場合が多いし、無理に奪うとユーザーの体が覚えた操作を壊します。アプリ独自のショートカットはCmd+Kやg連打のような、標準とぶつかりにくいキーに寄せます。
第三に、モーダルやドロップダウンを開いている間は、背後のショートカットを止めること。検索モーダルを開いたままEscapeが裏のリストにも届くと、二重に閉じたりして挙動が読めなくなります。React製のUIライブラリを使うなら、こうしたフォーカス管理を自前でやらずに任せる手もあります。アクセシブルな実装の具体例はClaude CodeとRadix UIでアクセシブルなReact UIを作るが参考になります。
最後に、<kbd>での表示はOSで出し分けると親切です。Macには⌘K、WindowsにはCtrl+Kと見せる。navigator.platformやnavigator.userAgentでMacを判定して、表示文字列だけ切り替えればOKです。判定ロジック自体はe.metaKey || e.ctrlKeyのままで構いません。
僕がやらかした失敗3つ
正直に書きます。最初の実装は穴だらけでした。
ひとつ目は、冒頭の入力中無効化を忘れた事故。documentにハンドラを付けて満足していたら、フォームが全滅しました。shouldIgnoreを最初から噛ませる癖がついたのは、このバグのおかげです。
ふたつ目は、keydownの解除を忘れたこと。ReactのコンポーネントでaddEventListenerだけ書いてreturnでの後始末を書かず、画面を行き来するたびにハンドラが多重登録されました。1回押したら関数が3回走る、という不可解な挙動の正体がこれでした。useEffectのクリーンアップは、面倒でも必ず書きます。
みっつ目は、ヘルプと実装を別々に持ったこと。コードでショートカットを直したのに、ヘルプ画面のリストは手書きのままで古いキーが残り、ユーザーを混乱させました。今はSHORTCUTS配列を唯一の情報源にして、両方そこから生やしています。
よくある質問
Q. keydownとkeyup、どちらで判定すべき?
ショートカットはkeydownです。押した瞬間に反応してほしいのと、キーを押しっぱなしにしたときの自動リピートも拾えるからです。keyupは「離した」タイミングなので、長押し系の判定など特殊用途向けです。
Q. MacとWindowsの修飾キーの違いはどう吸収する?
e.metaKey || e.ctrlKeyでORを取るのが一番楽です。MacユーザーはCmd(=metaKey)、WindowsユーザーはCtrl(=ctrlKey)を主修飾キーとして使うので、両方trueとして扱えば1行で両対応できます。表示文字列だけOSで出し分けると親切です。
Q. 入力欄にいてもショートカットを効かせたい場合は?
キーごとに「入力中も通す」フラグを持たせます。記事中のuseShortcutsではallowInInputという許可リストで、Cmd+SやEscapeだけ例外的に通しています。全部通すと文字が打てなくなるので、必要なキーだけに絞るのがコツです。
Q. 日本語入力(IME)中の誤爆を防ぐには?
e.isComposingがtrueの間はショートカット処理をスキップします。古い環境向けにe.keyCode === 229も併せて見ると取りこぼしが減ります。これを入れないと、変換確定のEnterがショートカットに食われます。
Q. ライブラリを使うべき?自前実装すべき?
小〜中規模なら記事のuseShortcuts程度の自前実装で十分です。g連打のような連続キーや、スコープ管理(このパネル内だけ有効)まで必要になったら、react-hotkeys-hookなどの専用ライブラリを検討します。判断軸は「連続キー・スコープ・競合管理が要るか」です。
実際に試した結果
自分のダッシュボードアプリにこのuseShortcutsを入れてから、ショートカット周りのバグ報告がほぼゼロになりました。効いたのは派手な機能ではなく、shouldIgnoreとe.isComposingの2つです。この2行を最初から入れておくだけで、「文字が打てない」「変換のEnterが効かない」という致命傷が消えます。
逆に、?キーのヘルプは効果が読めませんでした。入れた直後は使われた形跡が薄く、フッターに小さく「?でショートカット一覧」と1行足してようやく使われ始めました。実装より導線、というのを地味に痛感しています。
ショートカットの追加・整理をClaude Codeに任せるなら、「入力中とIME中の無効化を必ず入れて」「定義は1か所の配列にまとめて」とプロンプトで明示すると、抜けが減ります。実装プロンプトを毎回ゼロから書くのが面倒なら、商品一覧のテンプレートに寄せておくと楽です。手を動かす土台としては、まずshouldIgnoreとuseShortcutsをコピーして、自分のアプリで1つだけショートカットを足すところから始めてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。