言語を切り替える
テーマを切り替える

Dialog、Sheet、Popover:オーバーレイ系コンポーネントのアクセシビリティとフォーカス管理

お客様からこんなメールが届きました。「御社サイトのオーバーレイを開いた後、Tab キーを押すとフォーカスが背景ページへ飛んでしまい、キーボードユーザーがまったく操作できません」。

このメール、なかなか気まずいものでした——というのも、このオーバーレイは先週書いたばかりだったからです。

コードを開いてみると、問題は一目瞭然でした。Dialog を開いてもフォーカスが背景のボタンに残ったままで、ユーザーが Tab キーを押せば当然そのまま背景ページへ抜けていきます。スクリーンリーダーのユーザーはもっと深刻です——フォーカスが移っておらず ARIA 属性も設定されていないため、オーバーレイが開いたことすら分からないのです。

この記事では、Dialog、Sheet、Popover という 3 種類のオーバーレイコンポーネントのアクセシビリティとフォーカス管理について話します。踏んできた落とし穴を、できれば避けてもらえたらと思います。


まず整理:3 種類のコンポーネントの核心的な違い

正直なところ、多くの人は——以前の私も含めて——この 3 つのコンポーネントの違いがかなり曖昧でした。どれもオーバーレイだろう、だいたい同じ、と思いがちです。ですが実際には、その核心的な違いがアクセシビリティの扱い方を決めています。

Dialog(モーダルダイアログ)

Dialog は背景操作を完全にブロックするモーダルオーバーレイです。

たとえば「注文を削除」ボタンを押すと、確認ダイアログが表示されます。このとき背景ページはオーバーレイで覆われ、背景のどの要素もクリックできません——これが Dialog の核心的な特徴、つまり ユーザーに目の前のタスクを強制的に処理させる という性質です。

利用シーン:

  • 重要な案内(削除確認、操作警告)
  • フォーム入力(ログインフォーム、登録フォーム)
  • ユーザーの即時応答が必要な操作

アクセシビリティの要:aria-modal="true" を必ず設定し、フォーカストラップを必ず実装すること。

Sheet(サイドドロワー)

Sheet は画面の端からスライドして出てくるドロワー型のパネルです。本質的には Dialog と同じで、どちらもモーダルオーバーレイ——背景操作をブロックし、フォーカストラップが必要です。唯一の違いは視覚的な位置です。Sheet は側面からスライドし、Dialog は中央に表示されます。

利用シーン:

  • ナビゲーションメニュー(モバイルのサイドバー)
  • 設定パネル(環境設定、テーマ切り替え)
  • 詳細表示(商品詳細、記事プレビュー)

正直、Sheet という言葉は私も最初はかなり馴染みがありませんでした。あとで分かったのですが、これは Drawer の別名なのです——Drawer と呼ぶ UI ライブラリもあれば、Sheet と呼ぶものもあり、Radix UI と shadcn/ui では Sheet を使っています。

アクセシビリティの要:Dialog と同じく、aria-modal="true"、フォーカストラップ、Esc で閉じる。

Popover(ポップオーバー)

Popover は背景操作をブロックしない非モーダルオーバーレイです。

この点がとても重要です——Popover を開いてもユーザーは背景の要素をクリックでき、フォーカスが Popover 内に強制的に閉じ込められることはありません。

たとえば「その他の操作」ボタンを押すと、「編集」「複製」「削除」を含む小さなパネルが出てきます。これが Popover です。背景の別のボタンをクリックでき、すると Popover は自動的に閉じます。

利用シーン:

  • ドロップダウンメニュー(操作メニュー、選択肢リスト)
  • ツールチップ(リッチテキストのヒント、使い方説明)
  • クイック操作(編集、複製、削除)

アクセシビリティの要:aria-modal="false"(または省略)、フォーカスは強制トラップしない、外側のクリックで閉じる。

1 枚の表で違いを把握

正直、この表は自分で書いているときも見比べながらでした——以前はいくつかの概念が本当に曖昧だったのです。

特性DialogSheetPopover
背景のブロック✅ 必須✅ 必須❌ しない
フォーカストラップ必須必須任意(強制しない方が推奨)
Esc で閉じる必須必須推奨
外側クリックで閉じる任意任意デフォルト動作
ARIA ロールdialogdialogpopover
aria-modal"true""true""false" または省略
視覚的な位置中央側面スライドトリガー要素を基準に配置

一言でまとめると、Dialog と Sheet はモーダルオーバーレイ、Popover は非モーダルオーバーレイ です。モーダルオーバーレイはフォーカストラップを必ず実装し、非モーダルオーバーレイは強制しなくてもかまいません。


WCAG アクセシビリティ標準の詳解

正直、WCAG 標準は読み始めはかなり退屈です。英語の専門用語が並び、まるで法律の条文のように感じます。ですが実際のプロジェクトで問題にぶつかると、これらの標準が本当に役立つと分かります——検査をしのぐためではなく、ユーザーが正常に操作できるようにするためです。

ARIA 属性の必須項目

オーバーレイコンポーネントの ARIA 属性のうち、3 つは必須です。

1. role="dialog"

この属性は支援技術(スクリーンリーダー)に「これはダイアログです」と伝えます。

<div role="dialog">
  <!-- オーバーレイの内容 -->
&lt;/div>

2. aria-labelledby

この属性はオーバーレイのタイトル要素を関連付けます。スクリーンリーダーはオーバーレイを開くと、まずタイトルを読み上げます。

&lt;div role="dialog" aria-labelledby="dialog-title">
  &lt;h2 id="dialog-title">削除の確認&lt;/h2>
  &lt;p>この操作は取り消せません。&lt;/p>
&lt;/div>

3. aria-modal="true"(モーダルオーバーレイのみ)

この属性はスクリーンリーダーに「背景の内容にはアクセスできません」と伝えます。

&lt;div role="dialog" aria-modal="true">
  <!-- モーダルオーバーレイの内容 -->
&lt;/div>

正直、私は以前よく aria-labelledby を忘れていました。あとでスクリーンリーダーでテストして初めて気づいたのです——この属性がないと、ユーザーがオーバーレイを開いても聞こえてくるのは空白で、これが何なのか分からないのです。

キーボードナビゲーションの要件

WCAG はオーバーレイのキーボードナビゲーションに明確な要件を定めています。

Tab キー:オーバーレイ内でフォーカスを循環させる
ユーザーが Tab キーを押すと、フォーカスはオーバーレイ内の操作可能な要素の間を循環し、背景ページへ抜けてはいけません。

Shift+Tab:フォーカスを逆方向に循環させる
ユーザーが Shift+Tab を押すと、フォーカスは逆方向に循環します。

Esc キー:オーバーレイを閉じる
ユーザーが Esc キーを押すと、オーバーレイは閉じるべきです。これは必須です——Esc でオーバーレイを閉じる習慣のあるユーザーもおり、対応していなければ中に閉じ込められてしまいます。

Enter/Space:ボタンを実行する
この 2 つのキーはボタンやリンクを実行するのに使います。

正直、Tab 循環は以前に落とし穴を踏みました。オーバーレイを開いた後、フォーカスがオーバーレイ内に制限されておらず、ユーザーが Tab キーを押すとフォーカスが背景ページへ抜けてしまったのです——これが冒頭で触れたお客様の苦情の問題でした。

フォーカス管理の規範

フォーカス管理はオーバーレイのアクセシビリティで最も見落とされやすい部分です。WCAG の要件はとてもシンプルです。

オーバーレイを開いたとき
フォーカスはオーバーレイ内の最初の操作可能な要素(多くは閉じるボタンか最初の入力欄)へ移すべきです。

オーバーレイを閉じたとき
フォーカスはトリガー要素(オーバーレイを開いたボタン)へ戻すべきです。

正直、閉じた後にフォーカスを戻すというのは、以前はまったく意識していませんでした。あとでキーボードでテストして初めて気づいたのです——オーバーレイを閉じると、フォーカスがどこへ行ったか分からず、キーボードユーザーは探し直すはめになります。この体験は本当にひどいものでした。

特殊なケース
オーバーレイに重要な案内(たとえば操作説明)がある場合は、フォーカスをまずコンテナ要素に置き、スクリーンリーダーに案内を先に読み上げさせてから、ユーザーに操作させるべきです。

やり方は、コンテナに tabindex="0" を付けることです。

&lt;div role="dialog" aria-modal="true" tabindex="0">
  &lt;h2>操作説明&lt;/h2>
  &lt;p>以下の内容をよく読んでから操作してください...&lt;/p>
  &lt;button>確認&lt;/button>
&lt;/div>

こうすると、オーバーレイを開いたときにフォーカスがまずコンテナに置かれ、スクリーンリーダーが内容全体を先に読み上げてから、ユーザーが Tab でボタンへ移れます。


フォーカストラップの実装原理

正直、フォーカストラップは複雑そうに聞こえますが、原理は実はとてもシンプルです——Tab キーをオーバーレイ内で循環させるだけです。

フォーカストラップとは

フォーカストラップの定義は、ユーザーの Tab ナビゲーションを特定の領域内で循環させること です。

たとえば、オーバーレイを開いた後、ユーザーが Tab キーを押すとフォーカスが「閉じるボタン」から「確認ボタン」へ移り、さらに Tab キーを押すとフォーカスがまた「閉じるボタン」へ戻ります——これがフォーカストラップです。

必要性:ユーザーが背景の内容を誤操作するのを防ぐためです。フォーカスが背景ページへ抜けてしまうと、ユーザーが背景のボタンをうっかり押し、意図しない操作につながりかねません。

JavaScript での実装の考え方

フォーカストラップの中心となるロジックはとてもシンプルです。オーバーレイ内のすべての操作可能な要素を見つけ、Tab キーを監視し、最初と最後の要素の間で循環させます。

function trapFocus(modal) {
  // 操作可能な要素をすべて取得
  const focusableElements = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );

  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];

  // キーボードイベントを監視
  modal.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
      // Shift+Tab:最初の要素にいるときは最後へ移動
      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
      // Tab:最後の要素にいるときは最初へ移動
      else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }

    // Esc でオーバーレイを閉じる
    if (e.key === 'Escape') {
      closeModal();
    }
  });
}

正直、このコードは何度も書き直してやっと動きました。主な落とし穴は次の点です。

  • focusableElements のセレクターは網羅的にすること。どれかの種類を漏らすとフォーカスが抜けていきます
  • e.preventDefault() は必ず呼ぶこと。そうしないとブラウザのデフォルト動作でフォーカスが外へ移ってしまいます

focus-trap ライブラリの紹介

フォーカストラップを自分で書きたくなければ、既存のライブラリ——focus-trap-react を使えます。

import FocusTrap from 'focus-trap-react';

&lt;FocusTrap>
  &lt;div className="modal">
    &lt;button>閉じる&lt;/button>
    &lt;button>確認&lt;/button>
  &lt;/div>
&lt;/FocusTrap>

このライブラリは、フォーカス循環、Esc で閉じる、多層オーバーレイなどのケースを自動的に処理してくれます。

正直、私は今ではこのライブラリをほとんど使っていません——shadcn/ui が内部でフォーカス管理をすでに統合しているからです。Radix UI(shadcn/ui の土台)がフォーカストラップのロジックをすべて自動処理するため、追加のライブラリを導入する必要はありません。


shadcn/ui 実践:Dialog の実装

正直、shadcn/ui を使い始めてから、オーバーレイコンポーネントを自分で手書きすることはなくなりました。怠けているからではなく——手書きのオーバーレイはいつもアクセシビリティの問題を抱えるのに対し、shadcn/ui は Radix UI ベースで、アクセシビリティの細部をすべて自動処理してくれるからです。

インストールと基本的な使い方

npx shadcn@latest add dialog

インストールすると components/ui/dialog.tsx ファイルが自動生成されます。

完全なコード例

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"

export function DeleteConfirmDialog() {
  return (
    &lt;Dialog>
      &lt;DialogTrigger asChild>
        &lt;Button variant="outline">注文を削除&lt;/Button>
      &lt;/DialogTrigger>
      &lt;DialogContent>
        &lt;DialogHeader>
          &lt;DialogTitle>削除の確認&lt;/DialogTitle>
          &lt;DialogDescription>
            この操作は取り消せません。本当にこの注文を削除しますか?
          &lt;/DialogDescription>
        &lt;/DialogHeader>
        &lt;div className="flex justify-end gap-2 mt-4">
          &lt;Button variant="outline">キャンセル&lt;/Button>
          &lt;Button variant="destructive">削除&lt;/Button>
        &lt;/div>
      &lt;/DialogContent>
    &lt;/Dialog>
  )
}

正直、このコードは簡単そうに見えますが、裏では Radix UI が多くの細部を自動処理しています。

  • オーバーレイを開いたとき、フォーカスが最初のボタン(「キャンセル」)へ移る
  • オーバーレイを閉じたとき、フォーカスが「注文を削除」ボタンへ戻る
  • Tab キーがオーバーレイ内で循環する
  • Esc キーでオーバーレイが閉じる
  • aria-labelledbyDialogTitle と自動的に関連付けられる
  • aria-describedbyDialogDescription と自動的に関連付けられる

鍵となるアクセシビリティ特性

1. フォーカスの自動管理

Radix UI の Dialog は、開いたときにフォーカスがオーバーレイ内の最初の操作可能な要素へ自動的に移ります。閉じたときには、フォーカスがトリガー要素へ自動的に戻ります。

2. ARIA 属性の自動関連付け

DialogTitlearia-labelledby と自動的に関連付けられ、DialogDescriptionaria-describedby と自動的に関連付けられます。

<!-- Radix UI が生成する HTML -->
&lt;div role="dialog" aria-modal="true" aria-labelledby="radix-:r1:" aria-describedby="radix-:r2:">
  &lt;h2 id="radix-:r1:">削除の確認&lt;/h2>
  &lt;p id="radix-:r2:">この操作は取り消せません...&lt;/p>
&lt;/div>

正直、こうした細部は手書きだと簡単に漏れます。shadcn/ui を使えばまったく心配いりません。

3. Esc キーで自動的に閉じる

Esc キーを押すとオーバーレイが自動的に閉じ、フォーカスがトリガー要素へ自動的に戻ります。

4. オーバーレイをクリックして閉じる

オーバーレイの外側(オーバーレイの外のグレーの背景)をクリックしてもオーバーレイを閉じられます。この動作は DialogContentonInteractOutside 属性で防げます。

&lt;DialogContent onInteractOutside={(e) => e.preventDefault()}>
  <!-- 外側をクリックしてもオーバーレイは閉じない -->
&lt;/DialogContent>

shadcn/ui 実践:Sheet の実装

Sheet のアクセシビリティ特性は Dialog とまったく同じで、唯一の違いは視覚的な位置——Sheet は側面からスライドします。

インストールと基本的な使い方

npx shadcn@latest add sheet

完全なコード例

import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"

export function NavigationSheet() {
  return (
    &lt;Sheet>
      &lt;SheetTrigger asChild>
        &lt;Button variant="outline">メニューを開く&lt;/Button>
      &lt;/SheetTrigger>
      &lt;SheetContent side="left">
        &lt;SheetHeader>
          &lt;SheetTitle>ナビゲーションメニュー&lt;/SheetTitle>
          &lt;SheetDescription>
            アクセスしたいページを選んでください
          &lt;/SheetDescription>
        &lt;/SheetHeader>
        &lt;nav className="flex flex-col gap-4 mt-4">
          &lt;a href="/" className="hover:underline">ホーム&lt;/a>
          &lt;a href="/about" className="hover:underline">概要&lt;/a>
          &lt;a href="/contact" className="hover:underline">お問い合わせ&lt;/a>
        &lt;/nav>
      &lt;/SheetContent>
    &lt;/Sheet>
  )
}

Dialog との違い

正直、Sheet と Dialog のコードはほとんど同じで、違うのはコンポーネント名だけです。主な違いは次の点です。

1. 側面スライドのアニメーション

Sheet はデフォルトで右側からスライドし、side 属性で方向を制御できます。

&lt;SheetContent side="left">   <!-- 左からスライド -->
&lt;SheetContent side="right">  <!-- 右からスライド(デフォルト) -->
&lt;SheetContent side="top">    <!-- 上からスライド -->
&lt;SheetContent side="bottom"> <!-- 下からスライド -->

2. アクセシビリティ特性は同じ

Sheet のアクセシビリティ特性は Dialog とまったく同じです。

  • role="dialog"
  • aria-modal="true"
  • フォーカストラップ、Esc で閉じる、フォーカスの復帰

正直、Sheet というコンポーネントは主にモバイルのナビゲーションメニューに使っています。側面スライドの視覚効果は、モバイルの操作習慣によりよく合います。


shadcn/ui 実践:Popover の実装

Popover は非モーダルオーバーレイで、Dialog や Sheet との核心的な違いは、背景操作をブロックしない ことです。

インストールと基本的な使い方

npx shadcn@latest add popover

完全なコード例

import {
  Popover,
  PopoverContent,
  PopoverHeader,
  PopoverTitle,
  PopoverDescription,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Button } from "@/components/ui/button"

export function ActionPopover() {
  return (
    &lt;Popover>
      &lt;PopoverTrigger asChild>
        &lt;Button variant="outline">その他の操作&lt;/Button>
      &lt;/PopoverTrigger>
      &lt;PopoverContent>
        &lt;PopoverHeader>
          &lt;PopoverTitle>クイック操作&lt;/PopoverTitle>
          &lt;PopoverDescription>
            以下の操作を選んでください
          &lt;/PopoverDescription>
        &lt;/PopoverHeader>
        &lt;div className="flex flex-col gap-2 mt-2">
          &lt;Button size="sm">編集&lt;/Button>
          &lt;Button size="sm">複製&lt;/Button>
          &lt;Button size="sm" variant="destructive">削除&lt;/Button>
        &lt;/div>
      &lt;/PopoverContent>
    &lt;/Popover>
  )
}

鍵となる違い

正直、Popover のコードは Dialog や Sheet とよく似ていますが、裏の動作はまったく異なります。

1. 非モーダル

Popover を開いてもユーザーは背景の要素をクリックできます。フォーカスが Popover 内に強制的に閉じ込められることはありません。

2. フォーカスは強制しない

ユーザーが Tab キーを押すと、フォーカスは Popover から背景の要素へ移れます。この点が Dialog とまったく違います。

3. 外側のクリックで閉じる

Popover の外側のどの要素をクリックしても、Popover は自動的に閉じます。これはデフォルト動作で、onInteractOutside 属性で防げます。

&lt;PopoverContent onInteractOutside={(e) => e.preventDefault()}>
  <!-- 外側をクリックしても閉じない -->
&lt;/PopoverContent>

4. 柔軟な配置

Popover は align 属性で水平方向の揃え方を制御します。

&lt;PopoverContent align="start">  <!-- 左揃え -->
&lt;PopoverContent align="center"> <!-- 中央揃え(デフォルト) -->
&lt;PopoverContent align="end">    <!-- 右揃え -->

正直、Popover は主に操作メニューに使っています——ボタンを押すとクイック操作の選択肢がいくつか出てくる形です。このようなシーンでは背景操作をブロックする必要がなく、Popover がちょうど合っています。


上級テクニックとよくある落とし穴

正直、オーバーレイコンポーネントの落とし穴はたくさん踏んできました。ここでは特によくあるものをいくつか話します。

フォーカス復帰の落とし穴:トリガー要素が削除された

シーン:オーバーレイを開いた後、トリガー要素(ボタン)が削除された。オーバーレイを閉じるとき、フォーカスの戻り先がない。

解決策:

  1. トリガー要素を削除せず、隠すだけにする
  2. または、フォーカスを戻す対象の要素を記録しておく
const [triggerElement, setTriggerElement] = useState&lt;HTMLElement | null>(null);

// オーバーレイを開くときにトリガー要素を記録
const handleOpen = (e: React.MouseEvent&lt;HTMLButtonElement>) => {
  setTriggerElement(e.currentTarget);
  setOpen(true);
};

// オーバーレイを閉じるときにフォーカスを戻す
const handleClose = () => {
  setOpen(false);
  triggerElement?.focus();
};

正直、この落とし穴は私も踏みました。ユーザーがレコードを 1 件削除した後、オーバーレイが閉じても、フォーカスがどこへ行ったか分からなかったのです。あとでフォーカスをリストの 1 つ前のレコードへ戻すようにして、ようやく解決しました。

スクリーンリーダーの落とし穴:オーバーレイの内容が読み上げられない

シーン:オーバーレイを開いた後、スクリーンリーダーが内容を読み上げず、ユーザーはオーバーレイの中身が分からない。

原因:

  1. aria-labelledby 属性が欠けている
  2. フォーカスがオーバーレイ内へ移っていない

解決策:
DialogTitleDialogDescription の両方を設定していることを確認してください。shadcn/ui が ARIA 属性を自動的に関連付けます。

&lt;DialogContent>
  &lt;DialogHeader>
    &lt;DialogTitle>削除の確認&lt;/DialogTitle>  <!-- 必須 -->
    &lt;DialogDescription>この操作は取り消せません&lt;/DialogDescription>  <!-- 必須 -->
  &lt;/DialogHeader>
&lt;/DialogContent>

正直、私は以前よく DialogDescription を漏らしていました。あとで NVDA(スクリーンリーダー)でテストして初めて気づいたのです——説明がないと、ユーザーはオーバーレイのタイトルだけ分かって、具体的な内容が分かりません。

多層オーバーレイの落とし穴:フォーカス管理が混乱する

シーン:オーバーレイ A がオーバーレイ B を開き、B を閉じた後にフォーカスがどこへ行ったか分からない。

解決策:
Radix UI の Dialog と Sheet は入れ子に対応しています。内側のオーバーレイを閉じると、フォーカスは内側のトリガー要素(外側のオーバーレイ内のボタンかもしれません)へ戻ります。

&lt;Dialog>
  &lt;DialogTrigger>オーバーレイ A を開く&lt;/DialogTrigger>
  &lt;DialogContent>
    &lt;DialogTitle>オーバーレイ A&lt;/DialogTitle>

    <!-- オーバーレイ A の中でオーバーレイ B を開く -->
    &lt;Dialog>
      &lt;DialogTrigger>オーバーレイ B を開く&lt;/DialogTrigger>
      &lt;DialogContent>
        &lt;DialogTitle>オーバーレイ B&lt;/DialogTitle>
      &lt;/DialogContent>
    &lt;/Dialog>
  &lt;/DialogContent>
&lt;/Dialog>

正直、多層オーバーレイの状況はできるだけ避けています。どうしても必要なときは、Radix UI の入れ子サポートを使って、フォーカスを自動的に処理させます。

アニメーション遅延の落とし穴:フォーカスがオーバーレイ内にない

シーン:オーバーレイにアニメーション(たとえばフェードイン)があり、アニメーション中はフォーカスがオーバーレイ内にない。

原因:
アニメーションの開始時点ではオーバーレイがまだ完全に表示されておらず、フォーカス設定が失敗します。

解決策:
Radix UI はこの問題を自動的に処理します。アニメーションが完了してからフォーカスを設定します。

自分で実装する場合は、アニメーションの完了を待つ必要があります。

modal.addEventListener('animationend', () => {
  const firstFocusable = modal.querySelector('button, [href], input');
  firstFocusable?.focus();
});

正直、この落とし穴も踏みました。自分で書いたオーバーレイは、開いたときにフォーカスがまだ背景のボタンに残っていたのです。アニメーションが完了する前にフォーカスを設定しようとしたためでした。あとで animationend イベントのリスナーを追加して、ようやく解決しました。


まとめ

いろいろ話してきましたが、核心は実は次の 3 点です。

1. 3 種類のコンポーネントの核心的な違い

Dialog と Sheet はモーダルオーバーレイ——背景操作をブロックし、フォーカストラップが必須。
Popover は非モーダルオーバーレイ——背景操作をブロックせず、フォーカスは強制しない。

2. WCAG アクセシビリティの三大要件

ARIA 属性(role="dialog"aria-labelledbyaria-modal="true"
キーボードナビゲーション(Tab 循環、Shift+Tab で逆方向、Esc で閉じる)
フォーカス管理(開いたときにフォーカス、閉じたときに復帰)

3. shadcn/ui が細部をすべて自動処理する

Radix UI がフォーカストラップ、ARIA 属性、キーボードナビゲーションを自動処理します。shadcn/ui を使えば、アクセシビリティの問題はほとんど心配いりません。

正直、これだけ多くのオーバーレイコンポーネントを書いてきて、今の私の原則はとてもシンプルです。本番環境では shadcn/ui を優先する。オーバーレイコンポーネントを手書きすると、アクセシビリティの問題が次々と出てくるのに対し、shadcn/ui は Radix UI ベースで、こうした細部をきちんと処理してくれます。

唯一注意すべきは、原理を理解することです。Radix UI が裏で何をしているかを知っていれば、問題にぶつかったときにすばやく切り分けられます。


参考資料


FAQ

Dialog、Sheet、Popover の 3 つのコンポーネントは何が違うのですか?
Dialog と Sheet はモーダルオーバーレイで、開くと背景操作をブロックし、フォーカストラップを実装する必要があります。Popover は非モーダルオーバーレイで、背景操作をブロックせず、フォーカスは自由に動けます。核心的な違いは、背景操作をブロックするかどうかです。
オーバーレイコンポーネントが実装すべきアクセシビリティ要件は何ですか?
WCAG は 3 つの側面を求めます。ARIA 属性(role="dialog"、aria-labelledby、aria-modal="true")、キーボードナビゲーション(Tab 循環、Shift+Tab で逆方向、Esc で閉じる)、フォーカス管理(開いたときはオーバーレイ内へフォーカスを移し、閉じたときはトリガー要素へ戻す)です。
フォーカストラップとは何ですか?なぜモーダルオーバーレイには必須なのですか?
フォーカストラップは、ユーザーの Tab ナビゲーションを特定の領域内で循環させる技術です。モーダルオーバーレイがフォーカストラップを実装すべき理由は、ユーザーが背景の内容を誤操作するのを防ぐためです。フォーカスが背景ページへ抜けてしまうと、ユーザーが背景のボタンをうっかり押し、意図しない操作につながりかねません。
shadcn/ui の Dialog コンポーネントが自動処理するアクセシビリティの細部は何ですか?
shadcn/ui は Radix UI ベースで、次の細部を自動処理します。

• オーバーレイを開いたとき、フォーカスが最初の操作可能な要素へ自動的に移る
• オーバーレイを閉じたとき、フォーカスがトリガー要素へ自動的に戻る
• Tab キーがオーバーレイ内で循環する
• Esc キーでオーバーレイが自動的に閉じる
• aria-labelledby が DialogTitle と自動的に関連付けられる
• aria-describedby が DialogDescription と自動的に関連付けられる
オーバーレイを閉じた後、フォーカスはどこへ戻すべきですか?
フォーカスはトリガー要素(オーバーレイを開いたボタン)へ戻すべきです。これは WCAG の明確な要件です。トリガー要素が削除された場合(たとえば削除操作)には、論理的に次の要素、たとえばリストの 1 つ前のレコードへフォーカスを戻すべきです。
オーバーレイにアニメーションがあるとき、フォーカス設定が失敗したらどうすればよいですか?
アニメーションの開始時点ではオーバーレイがまだ完全に表示されておらず、フォーカス設定が失敗することがあります。解決策は、アニメーションが完了してからフォーカスを設定することで、animationend イベントを監視します。Radix UI はこの問題を自動的に処理します。
スクリーンリーダーにオーバーレイの内容を読み上げさせるにはどうすればよいですか?
DialogTitle と DialogDescription の両方を設定していることを確認してください。shadcn/ui は aria-labelledby と aria-describedby を自動的に関連付けます。重要な案内がオーバーレイにある場合は、コンテナに tabindex="0" を付けてフォーカスをまずコンテナに置くと、スクリーンリーダーが内容全体を先に読み上げます。

6分で読めます · 公開日: 2026年3月29日 · 更新日: 2026年6月8日

関連記事

コメント

GitHubアカウントでログインしてコメントできます