APG Patterns
English
English
⌨️

キーボードインターフェースの開発

ネイティブ HTML フォーム要素とは異なり、ブラウザはカスタムウィジェットにキーボードサポートを提供しません。制作者がキーボードアクセスを提供する必要があります。

このプラクティスの詳細は Developing a Keyboard Interface - WAI-ARIA APG を参照してください。 以下は、このサイトの実装例に基づく補足解説です。

概要

キーボードアクセシビリティは、マウスを使用できないユーザーにとって不可欠です。これには運動障害のある人、視覚障害のあるユーザー、キーボードナビゲーションを好むパワーユーザーが含まれます。ネイティブHTMLフォーム要素とは異なり、ブラウザはカスタムウィジェットに対して組み込みのキーボードサポートを提供しないため、開発者が自ら実装する必要があります。

フォーカス管理の基本

キーボードフォーカスを適切に管理するための3つの重要な原則があります:

  • フォーカスインジケーターの可視性: ユーザーがフォーカス位置を容易に識別できること。ブラウザのデフォルトフォーカスインジケーターの使用が推奨されます。
  • フォーカスの永続性: 常にいずれかの要素がフォーカスを持つこと。ダイアログを閉じたり項目を削除する際も、フォーカスを適切な要素に移動させる必要があります。
  • 移動の予測可能性: ユーザーが次のフォーカス位置を容易に推測できること。読む順序に沿った移動パターンや、セクション単位での一貫性が重要です。

要素をフォーカス可能にする

<!-- ネイティブでフォーカス可能な要素 -->
<button>クリック</button>
<a href="/page">リンク</a>
<input type="text" />

<!-- フォーカス不可能な要素をフォーカス可能にする -->
<div role="button" tabindex="0">カスタムボタン</div>

<!-- タブ順序から除外(JavaScriptでのみフォーカス可能) -->
<div tabindex="-1">JavaScriptでのみフォーカス可能</div>

tabindex の値

動作
0DOM上の位置でタブ順序に含まれる
-1JavaScriptでフォーカス可能だがタブ順序には含まれない
1以上の整数避けるべき - 混乱するタブ順序を作成する

フォーカスと選択の違い

フォーカスと選択状態は異なる概念です。フォーカスは「現在操作対象となっている要素」を示し、同時に1つだけ存在します。一方、選択状態(aria-selected="true")は複数の要素が同時に持つことができます。

リストボックスやツリー、タブリストなどでは、フォーカスと選択が別々に存在することがあります。例えば、複数選択リストボックスでは、複数の項目が選択状態を保持しながら、フォーカスは別の項目に移動できます。

視覚的には、フォーカスインジケーターと選択状態のスタイルを明確に区別することが重要です。

選択がフォーカスに自動追従するかの判断

単一選択のウィジェット(タブリスト、単一選択リストボックスなど)では、フォーカス移動時に選択状態も自動的に変更される「選択フォローフォーカス」パターンがあります。

適している場合:

  • タブパネルがすでに DOM に存在し、即座に表示できる場合
  • ユーザーが素早くオプションを確認したい場合

適さない場合:

  • パネル表示にネットワークリクエストやページ更新が必要な場合
  • 選択変更に重い処理が伴う場合

選択フォローフォーカスが適さない場合は、ユーザーが Enter または Space キーで明示的に選択を変更するパターンを採用します。

無効なコントロールのフォーカス可能性

デフォルトでは HTML の無効な要素(disabled)はタブ順序から除外されます。しかし、複合ウィジェット内では無効な要素もフォーカス可能にすることが推奨される場合があります。

要素タイプ無効時の推奨
タブ順序内の単独要素フォーカス不可にする
リストボックスのオプションフォーカス可能に保つ
メニュー項目フォーカス可能に保つ
タブフォーカス可能に保つ
ツリーアイテムフォーカス可能に保つ

無効な要素をフォーカス可能に保つことで、スクリーンリーダーユーザーはその要素の存在と無効状態を認識できます。一方、フォーカス不可にするとキー操作回数は減りますが、要素の存在が隠れてしまう可能性があります。

キーボードナビゲーションパターン

ロービング tabindex

ツールバー、タブリスト、メニューなどの複合ウィジェットでは、グループ全体を1つの Tab ストップとして扱います。ユーザーは Tab でグループに入り、矢印キーでグループ内を移動し、再度 Tab で次のウィジェットへ移動します。

基本原則

  • グループ内で 1つの要素だけtabindex="0" を持つ
  • 他のすべての要素は tabindex="-1" を持つ
  • 矢印キーでフォーカスが移動するたびに、tabindex の値を入れ替える
<div role="toolbar" aria-label="テキスト書式設定">
<button tabindex="0">太字</button>
<button tabindex="-1">斜体</button>
<button tabindex="-1">下線</button>
</div>
初期状態: 最初のボタンのみ tabindex="0" でタブ順序に含まれます。
<div role="toolbar" aria-label="テキスト書式設定">
<button tabindex="-1">太字</button>
<button tabindex="0">斜体</button>
<button tabindex="-1">下線</button>
</div>
→ キーを押した後: 2番目のボタンが tabindex="0" になりフォーカスを受け取ります。

実装パターン

class RovingTabIndex {
  constructor(container, selector) {
    this.items = [...container.querySelectorAll(selector)];
    this.currentIndex = 0;
    this.init();
  }

  init() {
    // 最初の要素のみ tabindex="0"、他は tabindex="-1"
    this.items.forEach((item, index) => {
      item.setAttribute('tabindex', index === 0 ? '0' : '-1');
    });

    // キーボードイベントを設定
    this.items.forEach((item) => {
      item.addEventListener('keydown', (e) => this.handleKeyDown(e));
    });
  }

  handleKeyDown(event) {
    let newIndex = this.currentIndex;

    switch (event.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        newIndex = (this.currentIndex + 1) % this.items.length; // 循環
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        newIndex = (this.currentIndex - 1 + this.items.length) % this.items.length;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = this.items.length - 1;
        break;
      default:
        return; // 他のキーは処理しない
    }

    event.preventDefault();
    this.moveFocus(newIndex);
  }

  moveFocus(newIndex) {
    // 現在の要素を tabindex="-1" に
    this.items[this.currentIndex].setAttribute('tabindex', '-1');

    // 新しい要素を tabindex="0" にしてフォーカス
    this.currentIndex = newIndex;
    this.items[this.currentIndex].setAttribute('tabindex', '0');
    this.items[this.currentIndex].focus();
  }
}

なぜ roving tabindex が必要か

複合ウィジェット内のすべての要素が tabindex="0" だと、ユーザーは各要素を個別に Tab で移動する必要があります。10個のタブがあるタブリストでは、次のセクションに移動するために10回 Tab を押すことになります。

roving tabindex により:

  • Tab: ウィジェット全体を1ステップでスキップ
  • 矢印キー: ウィジェット内の細かいナビゲーション

この分離により、キーボードユーザーは効率的にページをナビゲートできます。

aria-activedescendant によるフォーカス管理

roving tabindex の代替として、aria-activedescendant を使用したフォーカス管理があります。この手法では、コンテナ要素が常に DOM フォーカスを保持し、aria-activedescendant 属性で現在アクティブな子要素を示します。

基本原則

  • コンテナ要素が tabindex="0" を持ち、フォーカスを受け取る
  • 子要素は tabindex を持たない(フォーカス可能にしない)
  • コンテナの aria-activedescendant 属性にアクティブな子要素の id を設定
  • 矢印キーで aria-activedescendant の値を更新
<ul role="listbox" tabindex="0" aria-activedescendant="option-2" aria-label="フルーツを選択">
<li id="option-1" role="option">りんご</li>
<li id="option-2" role="option" aria-selected="true">バナナ</li>
<li id="option-3" role="option">オレンジ</li>
</ul>
aria-activedescendant を使用したリストボックス — DOM フォーカスは ul に留まり、option-2(バナナ)が現在アクティブな項目です。

実装パターン

class ActiveDescendant {
  constructor(container, selector) {
    this.container = container;
    this.items = [...container.querySelectorAll(selector)];
    this.currentIndex = 0;
    this.init();
  }

  init() {
    // 各アイテムに一意のIDを付与
    this.items.forEach((item, index) => {
      if (!item.id) {
        item.id = `${this.container.id}-item-${index}`;
      }
    });

    // 初期のアクティブ要素を設定
    this.container.setAttribute('aria-activedescendant', this.items[0].id);

    // キーボードイベントはコンテナで処理
    this.container.addEventListener('keydown', (e) => this.handleKeyDown(e));
  }

  handleKeyDown(event) {
    let newIndex = this.currentIndex;

    switch (event.key) {
      case 'ArrowDown':
        newIndex = Math.min(this.currentIndex + 1, this.items.length - 1);
        break;
      case 'ArrowUp':
        newIndex = Math.max(this.currentIndex - 1, 0);
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = this.items.length - 1;
        break;
      default:
        return;
    }

    event.preventDefault();
    this.setActiveDescendant(newIndex);
  }

  setActiveDescendant(newIndex) {
    // 視覚的なハイライトを更新
    this.items[this.currentIndex].classList.remove('active');
    this.items[newIndex].classList.add('active');

    // aria-activedescendant を更新
    this.currentIndex = newIndex;
    this.container.setAttribute('aria-activedescendant', this.items[newIndex].id);
  }
}

roving tabindex との比較

観点roving tabindexaria-activedescendant
DOM フォーカス各アイテムに移動コンテナに固定
実装の複雑さ中程度やや複雑
スクリーンリーダー対応広く対応対応状況にばらつきあり
動的コンテンツ要素追加時に tabindex 管理必要ID の付与のみ
推奨ユースケースツールバー、タブリストリストボックス、コンボボックス

使い分けの指針

roving tabindex を選ぶ場合:

  • 広範なスクリーンリーダーサポートが必要
  • シンプルな実装を優先
  • ツールバー、メニュー、タブリストなど

aria-activedescendant を選ぶ場合:

  • コンテナにテキスト入力がある(コンボボックス)
    • フォーカスが入力欄に留まるため、候補を選択しながらテキスト入力を継続できる
  • 大量のアイテムを持つリスト
  • アイテムが動的に追加・削除される
<!-- コンボボックスでの使用例 -->
<div class="combobox">
  <input
    type="text"
    role="combobox"
    aria-expanded="true"
    aria-haspopup="listbox"
    aria-activedescendant="suggestion-2"
    aria-controls="suggestions"
  />
  <ul id="suggestions" role="listbox">
    <li id="suggestion-1" role="option">東京</li>
    <li id="suggestion-2" role="option">大阪</li>
    <li id="suggestion-3" role="option">名古屋</li>
  </ul>
</div>

focusgroup 属性(新しい標準仕様)

focusgroup HTML属性は、JavaScriptなしで矢印キーナビゲーションを宣言的に実現する新しいWeb標準です。従来、複合ウィジェット(ツールバー、タブリスト、グリッドなど)のキーボードナビゲーションを実装するには、ロービング tabindex や aria-activedescendant を管理するための大量のJavaScriptが必要でした。focusgroup 属性は、これをブラウザにネイティブで処理させることを目指しています。

動作の仕組み

コンテナ要素に focusgroup を追加すると、ブラウザが以下を自動的に行います:

  1. フォーカス可能な子孫要素を1つのTabストップに集約 — 複数の <button> があっても、Tabで到達できるのは1つだけ
  2. 子要素間の矢印キーナビゲーションを有効化
  3. ロービング tabindex を自動管理 — ブラウザが内部的に tabindex 値を制御
  4. 最後にフォーカスした要素を記憶 — TabでグループToリーブし戻ると、前回フォーカスした要素に戻る
<!-- 従来: 手動ロービング tabindex(JavaScript が必要) -->
<div role="toolbar" aria-label="書式設定">
<button tabindex="0">太字</button>
<button tabindex="-1">斜体</button>
<button tabindex="-1">下線</button>
</div>

<!-- focusgroup: 宣言的(JavaScript 不要) -->
<div role="toolbar" aria-label="書式設定" focusgroup="toolbar">
<button>太字</button>
<button>斜体</button>
<button>下線</button>
</div>
focusgroup 属性は手動のロービング tabindex 管理を置き換えます。ブラウザがボタンを1つの Tab ストップに自動的に集約し、矢印キーナビゲーションを有効にします。

利用可能な値

focusgroup 属性はスペース区切りのトークンでナビゲーション動作を制御します:

動作
toolbarfocusgroupを作成し、デフォルトでインライン(水平)方向の矢印キーナビゲーションを有効化
inlineインライン軸(LTRでは左右)の矢印キーのみでナビゲート
blockブロック軸(上下)の矢印キーのみでナビゲート。toolbar のデフォルトのinlineを上書き
wrap最後の項目から最初の項目へ(またはその逆に)フォーカスがラップする
no-memoryTabは常に先頭から入り、フォーカス履歴を無視する
extendネストされたfocusgroupを親のfocusgroupに統合し、1つの論理グループとして機能する
none祖先のfocusgroupからサブツリーを除外する

これらの値を組み合わせることができます:

<!-- ラップする水平ツールバー -->
<div role="toolbar" focusgroup="toolbar wrap">
<button>切り取り</button>
<button>コピー</button>
<button>貼り付け</button>
</div>

<!-- 垂直ツールバー -->
<div role="toolbar" focusgroup="toolbar block"
   aria-orientation="vertical">
<button>ファイル</button>
<button>編集</button>
<button>表示</button>
</div>
異なるナビゲーションパターンに対する focusgroup 値の組み合わせ。

ネストされた focusgroup と extend

focusgroupがネストされている場合、デフォルトではそれぞれが独立したナビゲーションスコープを作成します。extend キーワードを使うと、子のfocusgroupが親に統合され、1つの論理グループとして機能します:

<!-- 独立したネスト focusgroup -->
<div focusgroup="toolbar" aria-label="メインツールバー">
<button>保存</button>
<button>印刷</button>
<div role="group" focusgroup="toolbar" tabindex="0"
     aria-label="テキスト書式設定">
  <!-- 別スコープ: Tab で入り、矢印キーで内部を移動 -->
  <button>太字</button>
  <button>斜体</button>
</div>
<button>閉じる</button>
</div>

<!-- extend で統合 -->
<div focusgroup="toolbar" aria-label="フラットツールバー">
<button>A1</button>
<div focusgroup="extend">
  <!-- 親と同じスコープ: A1 → B1 → B2 → A2 -->
  <button>B1</button>
  <button>B2</button>
</div>
<button>A2</button>
</div>
extend なしではネストされた focusgroup は独立した Tab ストップになります。extend を使うと子の項目が親の矢印キーナビゲーションに統合されます。

Shadow DOM との連携

focusgroup 属性はデフォルトで Shadow DOM 境界を越えて動作します。シャドウホストに宣言されたfocusgroupは、そのホストのシャドウツリー内のフォーカス可能な要素を含みます。シャドウツリー内で除外したい場合は focusgroup="none" を使用します。

キーの競合処理

focusgroup内に <input><textarea> のようなフォーカス可能な子要素がある場合、矢印キーはインタラクティブ要素のネイティブな用途(カーソル移動など)に使用されます。Tab または Shift+Tab でfocusgroupナビゲーションに戻ることができます。

インタラクティブデモ

No-memory ツールバー (focusgroup="toolbar no-memory")

矢印キーで3番目以降のボタンへ移動してから Tab で離脱し、再度 Tab で戻ります。no-memory が効いていれば常に先頭ボタンにフォーカスが戻ります。記憶されていれば前回のボタンに戻ります。

基本ツールバー (focusgroup="toolbar")

Tab キーでツールバーに入り、 矢印キーでナビゲートします。もう一度 Tab を押すと離脱します。端でフォーカスが止まります。

ラップツールバー (focusgroup="toolbar wrap")

矢印キーを使用します。最後から最初へ、またはその逆にフォーカスがラップします。

垂直ツールバー (focusgroup="toolbar block")

矢印キーでナビゲートします。 は無効です。blocktoolbar のデフォルトのインライン軸を上書きします。

JavaScript ベースのアプローチとの比較

ロービング tabindex(JS)
JavaScript の必要性
あり
Tab ストップ管理
手動で tabindex を入替え
矢印キーの処理
カスタム keydown ハンドラ
フォーカス記憶
手動実装が必要
ラップ動作
手動実装が必要
垂直ナビゲーション
手動でキー方向を切替
ブラウザサポート
全ブラウザ
スクリーンリーダー対応
広く対応
aria-activedescendant(JS)
JavaScript の必要性
あり
Tab ストップ管理
コンテナがフォーカスを保持
矢印キーの処理
カスタム keydown ハンドラ
フォーカス記憶
手動実装が必要
ラップ動作
手動実装が必要
垂直ナビゲーション
手動でキー方向を切替
ブラウザサポート
全ブラウザ
スクリーンリーダー対応
対応状況にばらつき
focusgroup(HTML)
JavaScript の必要性
なし
Tab ストップ管理
自動
矢印キーの処理
ブラウザ組み込み
フォーカス記憶
組み込み(デフォルト動作)
ラップ動作
wrap キーワード
垂直ナビゲーション
block キーワード
ブラウザサポート
Chromium 146+(オリジントライアル)
スクリーンリーダー対応
ロービング tabindex と同等

ブラウザサポートとプログレッシブエンハンスメント

2026年初頭時点で、スコープ版の focusgroup 属性はChromiumベースのブラウザ(Chrome/Edge 146+)でオリジントライアルとして利用可能です。初期のスコープなしプロトタイプは Chrome 133 でフラグ付きで提供されていました。Firefox や Safari ではまだサポートされていません。

推奨アプローチ: 既存のJavaScriptベースのキーボードナビゲーションと併用して、focusgroup をプログレッシブエンハンスメントとして使用します。これにより、サポートされたブラウザではネイティブ動作が得られ、それ以外はJavaScript実装にフォールバックします:

<div
role="toolbar"
aria-label="書式設定"
focusgroup="toolbar wrap"
>
<button>太字</button>
<button>斜体</button>
<button>下線</button>
</div>

<script>
// focusgroup がサポートされていない場合のみ
// JS ベースのロービング tabindex を追加
if (!('focusgroup' in document.createElement('div'))) {
  initRovingTabIndex(toolbar, 'button');
}
</script>
プログレッシブエンハンスメント: サポートされている場合は focusgroup を使用し、それ以外は JavaScript ベースのロービング tabindex にフォールバック。

リソース

モーダル用のフォーカストラップ

モーダルダイアログ内にフォーカスを閉じ込めます。モーダルを開くとき:

  1. フォーカスがあった要素を保存する
  2. モーダル内の最初のフォーカス可能な要素にフォーカスを移動する
  3. Tab/Shift+Tab をモーダル内に閉じ込める
  4. 閉じるとき、保存した要素にフォーカスを戻す

必須のキーバインディング

キー一般的なアクション
Enter / Spaceボタンの有効化、オプションの選択
矢印キー複合ウィジェット内のナビゲーション
Escapeダイアログを閉じる、操作をキャンセル
Home / End最初/最後のアイテムへジャンプ
Tab次のフォーカス可能な要素へ移動
Shift + Tab前のフォーカス可能な要素へ移動

ショートカットキーの実装

アプリケーションにショートカットキーを追加することで、パワーユーザーの操作効率を向上させることができます。ただし、適切に実装しないとアクセシビリティの問題を引き起こす可能性があります。

修飾キーとの組み合わせ

ショートカットキーは通常、修飾キー(Ctrl、Alt、Shift)と組み合わせて使用します:

document.addEventListener('keydown', (event) => {
  // Ctrl+S で保存
  if (event.ctrlKey && event.key === 's') {
    event.preventDefault();
    saveDocument();
  }

  // Ctrl+Shift+P でコマンドパレットを開く
  if (event.ctrlKey && event.shiftKey && event.key === 'p') {
    event.preventDefault();
    openCommandPalette();
  }
});

競合を避ける

ブラウザやOS、支援技術のショートカットと競合しないよう注意が必要です:

避けるべきキー理由
Ctrl+N/T/Wブラウザのタブ/ウィンドウ操作
Ctrl+P印刷ダイアログ
Ctrl+Fページ内検索
Alt+文字メニューバーアクセス
F1 - F12ブラウザやOSの機能

aria-keyshortcuts 属性

ショートカットキーを支援技術に伝えるために aria-keyshortcuts を使用します:

<button aria-keyshortcuts="Control+S" onclick="save()">保存</button>

<button aria-keyshortcuts="Control+Shift+P" onclick="openPalette()">コマンドパレット</button>

記法のルール:

  • 修飾キーは ControlAltShiftMeta を使用
  • キーは + で連結
  • 複数のショートカットは空白で区切る

accesskey 属性の問題点

HTML の accesskey 属性は避けることを推奨します:

<!-- 推奨されない -->
<button accesskey="s">保存</button>

問題点:

  • ブラウザやOSによって修飾キーが異なる(Alt、Alt+Shift、Ctrl+Alt など)
  • 既存のショートカットと競合しやすい
  • ユーザーがショートカットを発見しにくい
  • 国際化の問題(キーボードレイアウトによってキーの位置が異なる)

ショートカットの発見可能性

ユーザーがショートカットを見つけられるよう、以下の方法を検討してください:

<!-- ツールチップやラベルにショートカットを表示 -->
<button title="保存 (Ctrl+S)">
  💾 保存
  <kbd class="shortcut">Ctrl+S</kbd>
</button>

<!-- キーボードショートカット一覧を提供 -->
<dialog id="shortcuts-help">
  <h2>キーボードショートカット</h2>
  <dl>
    <dt><kbd>Ctrl</kbd>+<kbd>S</kbd></dt>
    <dd>ドキュメントを保存</dd>
    <dt><kbd>Ctrl</kbd>+<kbd>Z</kbd></dt>
    <dd>元に戻す</dd>
  </dl>
</dialog>

単一キーショートカットの注意点

修飾キーなしの単一キーショートカット(例:? でヘルプ表示)は、以下の場合に問題を引き起こします:

  • 音声入力ユーザーが意図せずコマンドを実行してしまう
  • テキスト入力中に誤ってトリガーされる

WCAG 2.1 の達成基準 2.1.4 では、単一キーショートカットには以下のいずれかが必要です:

  1. ショートカットをオフにできる
  2. 別のキーにリマップできる
  3. フォーカスがコンポーネント上にあるときのみ有効
// フォーカスがある要素でのみショートカットを有効にする例
toolbar.addEventListener('keydown', (event) => {
  if (event.key === 'b' && !event.ctrlKey && !event.altKey) {
    event.preventDefault();
    toggleBold();
  }
});

実装のポイント

可視フォーカスインジケーター

常に可視のフォーカスインジケーターを提供する:

/* 代替手段なしにこれを行わない */
:focus {
  outline: none;
}

/* 明確なフォーカススタイルを提供 */
:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

キーボードイベントの処理

element.addEventListener('keydown', (event) => {
  switch (event.key) {
    case 'Enter':
    case ' ': // Space
      event.preventDefault();
      activateElement();
      break;
    case 'ArrowDown':
      event.preventDefault();
      focusNextItem();
      break;
    case 'ArrowUp':
      event.preventDefault();
      focusPreviousItem();
      break;
  }
});

スペースキーでのスクロールを防止

スペースキーで要素をアクティブにする際、デフォルトのスクロールを防止する:

if (event.key === ' ') {
  event.preventDefault();
  // アクティベーションを処理
}

よくある間違い

マウス専用のインタラクション

<!-- 悪い例: マウスでのみ動作 -->
<div onclick="showMenu()">メニュー</div>

<!-- 良い例: キーボードでも動作 -->
<button type="button" onclick="showMenu()">メニュー</button>

フォーカスが画面外に移動

フォーカスが非表示または画面外の要素に移動しないようにする。コンテンツを非表示にする際:

// 非表示にする前に、可視要素にフォーカスを移動
focusElement.focus();
hiddenElement.hidden = true;

画面の向きを無視

適切な場合は、水平方向と垂直方向の両方の矢印キーナビゲーションをサポートする。特に、異なる向きで表示される可能性のあるウィジェットの場合。

リソース