T-CREATOR

Web Components で作るアクセシブルなタブ UI:キーボード操作& ARIA 完備

Web Components で作るアクセシブルなタブ UI:キーボード操作& ARIA 完備

Web アプリケーションのユーザーインターフェースにおいて、タブコンポーネントは非常によく使われるパターンです。しかし、見た目は美しくても、キーボード操作ができなかったり、スクリーンリーダーで正しく読み上げられなかったりと、アクセシビリティに課題を抱えているケースが少なくありません。

今回は、Web Components を活用して、WAI-ARIA のタブパターンに準拠した完全にアクセシブルなタブ UI を実装する方法をご紹介します。キーボード操作やフォーカス管理、ARIA 属性の正しい使い方まで、実践的なコード例とともに解説していきますね。

背景

Web Components は、再利用可能なカスタム要素を作成できる標準技術として、モダンな Web 開発で広く採用されています。フレームワークに依存せず、Shadow DOM によってスタイルやスクリプトをカプセル化できるため、コンポーネントライブラリの構築に最適です。

一方、アクセシビリティは Web サイトやアプリケーションを、障害の有無に関わらず誰もが利用できるようにするための取り組みです。W3C が策定する WAI-ARIA(Accessible Rich Internet Applications)は、動的なコンテンツやウィジェットに対して、支援技術が理解できる情報を付与するための仕様になります。

Web Components とアクセシビリティの関係

以下の図は、Web Components でアクセシブルなタブ UI を構築する際の技術要素を示しています。

mermaidflowchart TB
  wc["Web Components"] --> shadow["Shadow DOM<br/>スタイル・構造の<br/>カプセル化"]
  wc --> custom["Custom Elements<br/>独自タグの定義"]
  wc --> template["Template要素<br/>再利用可能な<br/>HTML構造"]

  aria["WAI-ARIA"] --> roles["Role属性<br/>要素の役割を定義"]
  aria --> states["State/Property<br/>状態・プロパティ"]
  aria --> keyboard["Keyboard<br/>Navigation<br/>キーボード操作"]

  shadow --> accessible["アクセシブルな<br/>タブUI"]
  custom --> accessible
  template --> accessible
  roles --> accessible
  states --> accessible
  keyboard --> accessible

Web Components と WAI-ARIA を組み合わせることで、再利用性とアクセシビリティを両立したコンポーネントが実現できます。

課題

従来のタブ UI 実装では、以下のようなアクセシビリティ上の問題が発生しがちです。

マウス操作のみに対応

多くの実装では、クリックイベントのみを処理し、キーボード操作が考慮されていません。これにより、キーボードユーザーや支援技術を使用するユーザーが操作できなくなってしまいます。

ARIA 属性の不足または誤用

role="tab"aria-selected などの ARIA 属性が適切に設定されていない場合、スクリーンリーダーは要素の役割や状態を正しく伝えられません。

フォーカス管理の不備

タブキーで移動する際に、すべてのタブボタンがタブストップになってしまうと、キーボードナビゲーションの効率が著しく低下します。WAI-ARIA のタブパターンでは、矢印キーでタブを切り替え、Tab キーでタブリストとタブパネルを移動する「ローミングタブインデックス」という手法が推奨されています。

視覚的なフォーカスインジケーターの欠如

:focus 状態のスタイリングが不十分だと、キーボードユーザーは現在どこにフォーカスがあるのか把握できません。

以下の図は、アクセシビリティ課題とその影響範囲を示しています。

mermaidflowchart LR
  issues["アクセシビリティ<br/>課題"] --> mouse["マウス操作<br/>のみ"]
  issues --> aria_lack["ARIA属性<br/>不足"]
  issues --> focus["フォーカス管理<br/>不備"]
  issues --> visual["視覚的<br/>フィードバック<br/>欠如"]

  mouse --> keyboard_user["キーボード<br/>ユーザー"]
  aria_lack --> screen_reader["スクリーン<br/>リーダー<br/>ユーザー"]
  focus --> nav_inefficient["ナビゲーション<br/>非効率"]
  visual --> confusion["操作位置<br/>不明"]

  keyboard_user --> impact["利用困難"]
  screen_reader --> impact
  nav_inefficient --> impact
  confusion --> impact

これらの課題を解決するには、WAI-ARIA のガイドラインに準拠した実装が不可欠です。

解決策

WAI-ARIA のタブパターンに準拠したアクセシブルなタブ UI を実装するためには、以下の要素を組み込む必要があります。

適切な ARIA 属性の設定

タブリスト、タブボタン、タブパネルそれぞれに適切な role と状態属性を設定します。

#要素role 属性主な ARIA 属性説明
1タブリストtablistaria-label または aria-labelledbyタブボタンの親コンテナ
2タブボタンtabaria-selectedaria-controlstabindex各タブの切り替えボタン
3タブパネルtabpanelaria-labelledbytabindexタブに対応するコンテンツ領域

キーボード操作のサポート

WAI-ARIA ガイドラインで推奨される以下のキーボード操作を実装します。

#キー動作実装のポイント
1右矢印 / 下矢印次のタブへフォーカス移動最後のタブで最初のタブへループ
2左矢印 / 上矢印前のタブへフォーカス移動最初のタブで最後のタブへループ
3Home最初のタブへフォーカス移動即座に先頭へジャンプ
4End最後のタブへフォーカス移動即座に末尾へジャンプ
5Tabタブパネルへフォーカス移動タブリストから抜ける
6Enter / Spaceタブを選択・アクティブ化自動選択モードでは不要な場合も

以下の図は、キーボード操作のフローを示しています。

mermaidstateDiagram-v2
  [*] --> TabList: Tabキーで入場
  TabList --> Tab1: 最初のタブに<br/>フォーカス
  Tab1 --> Tab2: 右矢印/下矢印
  Tab2 --> Tab3: 右矢印/下矢印
  Tab3 --> Tab1: 右矢印<br/>(ループ)
  Tab3 --> Tab2: 左矢印/上矢印
  Tab2 --> Tab1: 左矢印/上矢印
  Tab1 --> Tab3: 左矢印<br/>(ループ)
  Tab1 --> TabPanel1: Enter/Spaceで<br/>選択&Tabで移動
  Tab2 --> TabPanel2: Enter/Spaceで<br/>選択&Tabで移動
  Tab3 --> TabPanel3: Enter/Spaceで<br/>選択&Tabで移動
  TabPanel1 --> [*]: Shift+Tabで<br/>タブリストへ戻る

ローミングタブインデックス

タブリスト内では、選択されているタブのみが tabindex="0"(タブストップ対象)となり、他のタブは tabindex="-1"(フォーカス可能だがタブストップ外)に設定します。これにより、Tab キーでページ内を移動する際の効率が向上します。

自動選択 vs 手動選択

タブパターンには 2 つのモードがあります。

#モード動作利点欠点
1自動選択矢印キーでフォーカス移動すると同時にタブが選択される操作がシンプル、ステップ数が少ないタブパネルの内容が重い場合に負荷
2手動選択矢印キーでフォーカス移動後、Enter/Space で選択タブパネルの読み込みを制御可能操作ステップが増える

本記事では、より一般的な自動選択モードを実装します。

Web Components での実装戦略

Web Components を使うことで、以下のメリットが得られます。

  • 再利用性:どのプロジェクトでも <accessible-tabs> タグを使うだけで利用可能
  • カプセル化:Shadow DOM により、外部スタイルの影響を受けない
  • 保守性:タブのロジックとスタイルが 1 つのコンポーネントにまとまる

具体例

ここからは、実際のコードを段階的に見ていきましょう。完全なアクセシブルタブコンポーネントを構築します。

HTML 構造の定義

まず、タブコンポーネントの基本的な HTML 構造を定義します。以下は、コンポーネントの使用例です。

html<!-- タブコンポーネントの使用例 -->
<accessible-tabs>
  <button slot="tab">プロフィール</button>
  <div slot="panel">
    <h3>プロフィール情報</h3>
    <p>ユーザーの基本情報を表示します。</p>
  </div>

  <button slot="tab">設定</button>
  <div slot="panel">
    <h3>設定画面</h3>
    <p>アカウント設定を変更できます。</p>
  </div>

  <button slot="tab">通知</button>
  <div slot="panel">
    <h3>通知一覧</h3>
    <p>最新の通知を確認できます。</p>
  </div>
</accessible-tabs>

slot 属性を使うことで、タブボタンとパネルを柔軟に定義できます。

Web Component クラスの基本構造

次に、Web Component のクラス定義を作成します。まずは基本的な構造から見ていきましょう。

typescript// accessible-tabs.ts
// タブコンポーネントのクラス定義

class AccessibleTabs extends HTMLElement {
  // プライベートプロパティ
  private tablist: HTMLElement | null = null;
  private tabs: HTMLElement[] = [];
  private panels: HTMLElement[] = [];
  private selectedIndex: number = 0;

  constructor() {
    super();
    // Shadow DOM を作成
    this.attachShadow({ mode: 'open' });
  }

  // コンポーネントが DOM に追加されたときに呼ばれる
  connectedCallback() {
    this.render();
    this.setupTabs();
    this.attachEventListeners();
  }
}

コンストラクタで Shadow DOM を作成し、connectedCallback でコンポーネントの初期化処理を行います。

Shadow DOM のレンダリング

Shadow DOM 内に、タブリストとパネルを配置するテンプレートを作成します。

typescript// render メソッド:Shadow DOM の構築
private render(): void {
  if (!this.shadowRoot) return;

  // テンプレートを作成
  this.shadowRoot.innerHTML = `
    <style>
      ${this.getStyles()}
    </style>
    <div class="tabs-container">
      <div class="tablist" role="tablist" aria-label="コンテンツタブ">
        <slot name="tab"></slot>
      </div>
      <div class="panels">
        <slot name="panel"></slot>
      </div>
    </div>
  `;
}

<slot> を使って、外部から渡されたタブボタンとパネルを配置します。role="tablist"aria-label を設定することで、スクリーンリーダーがタブリストとして認識できます。

スタイルの定義

タブ UI の見た目とフォーカスインジケーターを定義します。視覚的なフィードバックは、キーボードユーザーにとって非常に重要です。

typescript// getStyles メソッド:CSS スタイルの定義
private getStyles(): string {
  return `
    .tabs-container {
      font-family: system-ui, sans-serif;
    }

    .tablist {
      display: flex;
      border-bottom: 2px solid #e5e7eb;
      gap: 4px;
    }

    /* slot された button のスタイル */
    ::slotted([slot="tab"]) {
      padding: 12px 24px;
      border: none;
      background: transparent;
      cursor: pointer;
      font-size: 16px;
      color: #6b7280;
      border-bottom: 3px solid transparent;
      transition: all 0.2s;
    }
  `;
}

::slotted() セレクタを使うことで、slot に配置された要素にスタイルを適用できます。

フォーカスと選択状態のスタイル

続いて、選択状態とフォーカス状態のスタイルを追加します。

typescript// getStyles メソッドの続き
private getStyles(): string {
  return `
    /* ...前のスタイル... */

    /* 選択されたタブ */
    ::slotted([slot="tab"][aria-selected="true"]) {
      color: #2563eb;
      border-bottom-color: #2563eb;
      font-weight: 600;
    }

    /* ホバー状態 */
    ::slotted([slot="tab"]:hover) {
      background: #f3f4f6;
    }

    /* フォーカス状態(キーボード操作時) */
    ::slotted([slot="tab"]:focus) {
      outline: 3px solid #93c5fd;
      outline-offset: -3px;
      z-index: 1;
    }

    /* パネルのスタイル */
    ::slotted([slot="panel"]) {
      padding: 24px;
      display: none;
    }

    /* 選択されたパネル */
    ::slotted([slot="panel"][aria-hidden="false"]) {
      display: block;
    }
  `;
}

:focus スタイルで明確なアウトラインを表示し、キーボードユーザーが現在の位置を把握できるようにしています。

タブとパネルの初期化

次に、slot に配置されたタブとパネルを取得し、ARIA 属性を設定します。

typescript// setupTabs メソッド:タブとパネルの初期化
private setupTabs(): void {
  if (!this.shadowRoot) return;

  // タブリストを取得
  this.tablist = this.shadowRoot.querySelector('[role="tablist"]');

  // slot された要素を取得
  const tabSlot = this.shadowRoot.querySelector('slot[name="tab"]') as HTMLSlotElement;
  const panelSlot = this.shadowRoot.querySelector('slot[name="panel"]') as HTMLSlotElement;

  if (tabSlot && panelSlot) {
    this.tabs = tabSlot.assignedElements() as HTMLElement[];
    this.panels = panelSlot.assignedElements() as HTMLElement[];
  }

  // 各タブに ARIA 属性を設定
  this.setupAriaAttributes();
}

assignedElements() メソッドで、slot に配置された実際の要素を取得できます。

ARIA 属性の設定

タブとパネルに必要な ARIA 属性を設定します。これにより、支援技術が正しく情報を伝えられるようになります。

typescript// setupAriaAttributes メソッド:ARIA 属性の設定
private setupAriaAttributes(): void {
  this.tabs.forEach((tab, index) => {
    const panel = this.panels[index];

    // ユニークな ID を生成
    const tabId = `tab-${index}`;
    const panelId = `panel-${index}`;

    // タブの ARIA 属性
    tab.setAttribute('role', 'tab');
    tab.setAttribute('id', tabId);
    tab.setAttribute('aria-controls', panelId);
    tab.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
    tab.setAttribute('tabindex', index === 0 ? '0' : '-1');

    // パネルの ARIA 属性
    panel.setAttribute('role', 'tabpanel');
    panel.setAttribute('id', panelId);
    panel.setAttribute('aria-labelledby', tabId);
    panel.setAttribute('aria-hidden', index === 0 ? 'false' : 'true');
    panel.setAttribute('tabindex', '0');
  });
}

aria-controlsaria-labelledby で、タブとパネルの関連性を明示しています。最初のタブのみ tabindex="0" とし、ローミングタブインデックスを実装します。

イベントリスナーの登録

タブのクリックとキーボード操作のイベントリスナーを登録します。

typescript// attachEventListeners メソッド:イベントリスナーの登録
private attachEventListeners(): void {
  this.tabs.forEach((tab, index) => {
    // クリックイベント
    tab.addEventListener('click', () => {
      this.selectTab(index);
    });

    // キーボードイベント
    tab.addEventListener('keydown', (event) => {
      this.handleKeydown(event as KeyboardEvent, index);
    });
  });
}

クリックとキーボード両方の操作をサポートすることで、すべてのユーザーがタブを操作できます。

タブ選択ロジック

タブが選択されたときの処理を実装します。ARIA 属性の更新とフォーカス管理を行います。

typescript// selectTab メソッド:タブの選択処理
private selectTab(index: number): void {
  // 範囲チェック
  if (index < 0 || index >= this.tabs.length) return;

  // 前のタブの選択を解除
  this.tabs[this.selectedIndex].setAttribute('aria-selected', 'false');
  this.tabs[this.selectedIndex].setAttribute('tabindex', '-1');
  this.panels[this.selectedIndex].setAttribute('aria-hidden', 'true');

  // 新しいタブを選択
  this.selectedIndex = index;
  this.tabs[index].setAttribute('aria-selected', 'true');
  this.tabs[index].setAttribute('tabindex', '0');
  this.panels[index].setAttribute('aria-hidden', 'false');

  // フォーカスを移動
  this.tabs[index].focus();
}

選択されていないタブは tabindex="-1" に設定し、選択されたタブのみ tabindex="0" にすることで、ローミングタブインデックスを実現しています。

キーボードナビゲーションの実装

矢印キーや Home/End キーでタブを移動する処理を実装します。

typescript// handleKeydown メソッド:キーボード操作の処理
private handleKeydown(event: KeyboardEvent, currentIndex: number): void {
  let newIndex = currentIndex;

  switch (event.key) {
    case 'ArrowRight':
    case 'ArrowDown':
      // 次のタブへ(ループ)
      newIndex = (currentIndex + 1) % this.tabs.length;
      event.preventDefault();
      break;

    case 'ArrowLeft':
    case 'ArrowUp':
      // 前のタブへ(ループ)
      newIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;
      event.preventDefault();
      break;
  }

  // インデックスが変更された場合、タブを選択
  if (newIndex !== currentIndex) {
    this.selectTab(newIndex);
  }
}

矢印キーでタブを移動し、最後のタブから最初のタブへループできるようにしています。event.preventDefault() で、ページのスクロールを防止します。

Home/End キーのサポート

さらに、Home キーと End キーで先頭・末尾のタブへジャンプできるようにします。

typescript// handleKeydown メソッドの続き(switch 文内に追加)
private handleKeydown(event: KeyboardEvent, currentIndex: number): void {
  let newIndex = currentIndex;

  switch (event.key) {
    // ...前のケース...

    case 'Home':
      // 最初のタブへ
      newIndex = 0;
      event.preventDefault();
      break;

    case 'End':
      // 最後のタブへ
      newIndex = this.tabs.length - 1;
      event.preventDefault();
      break;
  }

  if (newIndex !== currentIndex) {
    this.selectTab(newIndex);
  }
}

Home/End キーで瞬時に先頭・末尾へ移動できるため、タブが多い場合でも効率的にナビゲーションできます。

カスタム要素の登録

最後に、カスタム要素として登録します。

typescript// カスタム要素として登録
customElements.define('accessible-tabs', AccessibleTabs);

これで、<accessible-tabs> タグがブラウザで認識され、使用できるようになります。

完成した使用例

以下は、完成したタブコンポーネントの使用例です。

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>アクセシブルタブデモ</title>
    <script src="accessible-tabs.js" defer></script>
  </head>
  <body>
    <main>
      <h1>ユーザーダッシュボード</h1>

      <accessible-tabs>
        <button slot="tab">プロフィール</button>
        <div slot="panel">
          <h2>プロフィール情報</h2>
          <p>名前:山田太郎</p>
          <p>メール:yamada@example.com</p>
          <button>編集する</button>
        </div>

        <button slot="tab">設定</button>
        <div slot="panel">
          <h2>アカウント設定</h2>
          <label>
            <input type="checkbox" checked />
            メール通知を受け取る
          </label>
          <button>保存</button>
        </div>

        <button slot="tab">通知</button>
        <div slot="panel">
          <h2>通知一覧</h2>
          <ul>
            <li>新しいメッセージが届きました</li>
            <li>プロフィールが更新されました</li>
          </ul>
        </div>
      </accessible-tabs>
    </main>
  </body>
</html>

このコードをブラウザで開くと、完全にアクセシブルなタブ UI が動作します。

アクセシビリティの検証方法

実装したタブコンポーネントが正しくアクセシブルかどうか、以下の方法で検証できます。

#検証項目確認方法期待される動作
1キーボード操作Tab、矢印キー、Home/End を使用すべてのタブがキーボードで操作可能
2フォーカスインジケーターキーボードでフォーカス移動明確なアウトラインが表示される
3スクリーンリーダーNVDA や VoiceOver で読み上げタブの役割、状態、パネルが正しく読まれる
4ARIA 属性ブラウザの開発者ツールで検証すべての必須属性が正しく設定されている
5自動テストaxe DevTools や Lighthouse を実行アクセシビリティエラーがゼロ

特に、スクリーンリーダーでは「タブ、プロフィール、1 / 3、選択済み」のように、役割、ラベル、位置、状態が正しく読み上げられることを確認しましょう。

TypeScript での型定義

TypeScript を使用する場合、より型安全な実装が可能です。

typescript// types.ts
// タブコンポーネントの型定義

interface TabElements {
  tab: HTMLElement;
  panel: HTMLElement;
  id: string;
}

interface TabConfig {
  autoActivate?: boolean; // 自動選択モード
  orientation?: 'horizontal' | 'vertical'; // タブの向き
}

型定義を追加することで、コードの保守性が向上し、エラーを事前に防げます。

実装のポイント整理

図解:アクセシブルなタブ UI の実装要素と、それぞれが解決する課題の関係性を以下に示します。

mermaidflowchart TD
  impl["アクセシブル<br/>タブUI実装"]

  impl --> aria["ARIA属性<br/>設定"]
  impl --> keyboard["キーボード<br/>操作"]
  impl --> focus["フォーカス<br/>管理"]
  impl --> visual["視覚的<br/>フィードバック"]

  aria --> sr["スクリーンリーダー<br/>対応"]
  keyboard --> kb_user["キーボード<br/>ユーザー対応"]
  focus --> efficient["効率的<br/>ナビゲーション"]
  visual --> clear["明確な<br/>操作状態"]

  sr --> accessible_final["すべてのユーザーが<br/>利用可能"]
  kb_user --> accessible_final
  efficient --> accessible_final
  clear --> accessible_final

図で理解できる要点:

  • ARIA 属性が支援技術へ情報を提供
  • キーボード操作とフォーカス管理が操作性を向上
  • 視覚的フィードバックが状態理解を助ける

まとめ

Web Components を使ってアクセシブルなタブ UI を実装する方法を、WAI-ARIA のタブパターンに準拠した形でご紹介しました。

重要なポイントを振り返ると、以下の通りです。

ARIA 属性の適切な使用は、支援技術が要素の役割と状態を理解するために不可欠でした。role="tablist"role="tab"role="tabpanel" に加えて、aria-selectedaria-controlsaria-labelledby などの属性を正しく設定することで、スクリーンリーダーユーザーにも情報が正確に伝わります。

キーボード操作のサポートでは、矢印キーでのタブ移動、Home/End キーでの先頭・末尾へのジャンプを実装しました。これにより、マウスを使わないユーザーも快適にタブを操作できるようになります。

ローミングタブインデックスという手法を用いることで、タブリスト内では矢印キーで移動し、Tab キーでタブパネルへ移動するという直感的なナビゲーションが実現できました。これは WAI-ARIA が推奨するベストプラクティスです。

視覚的なフィードバックとして、明確なフォーカスインジケーターと選択状態のスタイリングを実装しました。これにより、キーボードユーザーは現在の位置を常に把握できますね。

Web Components を活用することで、フレームワークに依存しない再利用可能なコンポーネントとして、どんなプロジェクトでも利用できる点も大きなメリットです。

アクセシビリティは、すべてのユーザーに平等な体験を提供するための重要な取り組みです。今回のタブコンポーネントの実装方法を参考に、他の UI コンポーネントにもアクセシビリティを組み込んでいくことで、より多くの人に使いやすい Web アプリケーションが実現できるでしょう。

ぜひ、この実装を基に、あなたのプロジェクトでもアクセシブルなコンポーネント開発に挑戦してみてください。

関連リンク