本文へスキップ

フォーカストラップ

<dialog>.showModal() で開き、背後のページに inert を付けて Tab も読み上げも届かなくする。Tab は中で循環し、閉じると開いたボタンへフォーカスが戻る。

ENGLISH

Focus Trap (dialog + inert)

  • JS
約143行基本のUI部品 / アクセシブルUI の一覧 →

プレビュー

クリック

操作クリック/タップで動きます。

仕様書

向く場面
  • 入力を伴うモーダル(招待・確認)
  • 背後を Tab と読み上げから完全に外したい場面
  • 閉じた後に元のボタンへ戻したいダイアログ
避ける場面
  • 背後を aria-hidden だけで隠す
  • 閉じ方が 1 つしか無いダイアログ
  • 開いた後もフォーカスを動かさない
構成要素
  • 開くボタン
  • 背後のページ(inert)
  • dialog
  • 入力欄
  • キャンセル/送信ボタン
  • Tab 順の帯
持っている状態
  • 通常
  • フォーカス
キーボード

「メンバーを招待」で開くと入力欄へ、Tab/Shift+Tab は中の 3 要素で循環、Esc・キャンセル・背景クリックで閉じて元のボタンへ戻る

支援技術

APG Modal Dialog: <dialog>.showModal+aria-labelledby、背後に inert(Tab も読み上げも届かない)、開閉でフォーカスの移動と復帰

動き

背後の減光 200ms(opacity)。reduced-motion で停止

デザインの要点

  • 背後は aria-hidden ではなく inert にする。aria-hidden は読み上げだけ隠し、Tab では到達できてしまう
  • 開いたら最初の入力欄へ、閉じたら開いたボタンへフォーカスを戻す。戻さないと文書の先頭に飛び、位置を見失う
  • Tab の循環は最後→最初・Shift+Tab の最初→最後の2箇所だけ書けばよい。それ以外はブラウザに任せる
  • Esc・キャンセル・背景クリックのどれでも閉じられるようにする。閉じ方が1つしか無いダイアログは牢屋になる

使いどころ

入力や選択を伴うモーダル——招待、権限変更、取り消せない操作の確認——に使う。背後の画面に触らせたくない、かつ閉じたら元の位置へ戻したい場面が条件。ユーザーの判断を必要としない通知はトーストで足り、モーダルにすると操作を止めるだけになる。入力項目が多い作業や複数ステップの手続きも、狭い枠の中でスクロールと下書き喪失が起きるので専用ページへ逃がす。「閉じる手段が1つしかない」設計になるくらいならモーダルをやめた方がよい。モーダルはユーザーの操作を完全に奪う最も強い割り込みなので、開く理由と、開いた状態から必ず抜けられる道を先に決めてから使う。

仕組み

ネイティブの dialog 要素と showModal() を使い、トラップの大半をブラウザへ任せている。showModal() は ::backdrop を伴うトップレイヤー表示を行い、Esc での閉鎖も標準で効く。背後のページには inert 属性を付けており、これで背後のボタンやリンクは Tab でも読み上げでも到達できなくなる。閉じたときは close イベントで inert を外し、開いたボタンへ focus() を戻す。Tab の循環だけは keydown で補っており、dialog 内のフォーカス可能要素をそのつど集め直して「最後の要素で Tab」「最初の要素で Shift+Tab」の2箇所だけ preventDefault して反対側へ飛ばす。背景クリックは click のターゲットが dialog 自身かどうかで判定しているので、透明なオーバーレイ要素を別に置く必要がない。

実装の注意

背後を aria-hidden="true" だけで隠す実装が最も多い誤りで、読み上げからは消えてもフォーカス順には残るため、Tab を押し続けると見えない背後のリンクへ抜ける。しかも aria-hidden を付けた要素の中にフォーカス可能要素が残る状態は ARIA の仕様違反で、支援技術によっては aria-hidden ごと無視される。inert なら両方が同時に外れる。次に多いのがフォーカスの復帰忘れで、閉じた後に活性要素が body へ戻るとフォーカスは文書の先頭へ落ち、長いページでは元の位置へ戻る手段が無くなる。フォーカス可能要素の一覧を開いた時点で1度だけ計算して使い回すのも危うく、ダイアログの中身が動的に増減するとトラップの端がずれる。

アクセシビリティ

WAI-ARIA Authoring Practices の Modal Dialog パターンに対応する。dialog 要素の showModal() は暗黙に role="dialog" と aria-modal 相当の扱いになるため、これらを手書きする必要はない。代わりに aria-labelledby で見出しと結び、何のためのダイアログかを開いた瞬間に読ませる。背後の遮断は aria-modal に頼りきらず inert を併用するのが確実で、inert はフォーカス・ポインタ操作・支援技術の読み上げを一度に外す。開いたら中の最初の入力欄へフォーカスを移し、閉じたら呼び出し元へ戻す往復を必ず実装する。prefers-reduced-motion では背後の減光のトランジションを止め、表示と非表示の切り替えだけを行う。

調整して使う

まず初期フォーカスの位置。入力があるダイアログは最初の入力欄が自然だが、削除の確認のように誤操作を避けたい場合は「キャンセル」側へ当てる。次に閉じ方の組み合わせで、見本は Esc・キャンセル・背景クリックの3つを許しているが、入力途中の内容を失う危険があるときは背景クリックだけ外して、取り消しを明示的な操作に限る。3つめは幅と位置で、フォームを載せるなら幅を広げて上寄りに置くと、モバイルでソフトウェアキーボードに隠れにくい。スクロールが必要な長さになるときは、見出しと操作ボタンを固定して本文だけをスクロールさせると、閉じるボタンを探して往復せずに済む。

コード(コピーして使えます)

<div class="ftd-stage">
  <div class="ftd-page" id="ftd-page">
    <p class="ftd-h">プロジェクト「春の展示会」</p>
    <div class="ftd-actions">
      <button type="button" id="ftd-open" class="ftd-primary">メンバーを招待</button>
      <button type="button" class="ftd-ghost">設定</button>
      <a href="#ftd-help" class="ftd-link">ヘルプ</a>
    </div>
    <p class="ftd-note">ダイアログが開いている間、このボタンやリンクは inert(Tab でも読み上げでも到達できない)</p>
  </div>
  <dialog class="ftd-dialog" id="ftd-dialog" aria-labelledby="ftd-title">
    <form method="dialog" class="ftd-form">
      <h2 id="ftd-title">メンバーを招待</h2>
      <label class="ftd-field">メールアドレス<input type="email" id="ftd-email" placeholder="name@example.com" autocomplete="off"></label>
      <div class="ftd-foot">
        <button type="button" id="ftd-cancel" class="ftd-ghost">キャンセル</button>
        <button type="submit" class="ftd-primary">招待を送る</button>
      </div>
      <p class="ftd-order"><span class="ftd-tag">Tab の順</span><span id="ftd-order">メール → キャンセル → 招待を送る → メール…</span></p>
    </form>
  </dialog>
  <div class="ftd-band"><span class="ftd-tag">フォーカス</span><span id="ftd-msg">閉じている: 背後のボタンは操作できる</span></div>
</div>

タグ

同じカテゴリ「アクセシブルUI」のほかのパーツ

関連パーツ