🌟

Angular Aria で Tab を爆速実装

に公開

これはAngular Advent Calendar 2025の8日目の記事です。昨日は @da1chi さんでした。

はじめに

毎年、Angular CDK の布教活動に勤しんでいる私ですが、今年は Angular v21 で新たに登場した Angular Aria について紹介したいと思います。

Angular Aria は angular.dev にも掲載されてる v21 の目玉機能の一つです。開発は angular/components で行われており、Angular Material や Angular CDK と同じチームが手がけていて、非常に楽しみな新しいライブラリです。

Angular Aria とは?

公式ドキュメントには以下のように説明されています。

アクセシブルなコンポーネントの構築は一見簡単そうですが、W3Cアクセシビリティガイドラインに従って実装するには、多大な労力とアクセシビリティの専門知識が必要です。

Angular Ariaは、一般的なWAI-ARIAパターンを実装する、ヘッドレスでアクセシブルなディレクティブのコレクションです。ディレクティブはキーボードインタラクション、ARIA属性、フォーカス管理、スクリーンリーダーのサポートを処理します。あなたがすべきことは、HTML構造、CSSスタイリング、ビジネスロジックを提供することだけです!

Angular Aria Overview より引用

つまり、Angular Material や Angular CDK とはまた違ったアプローチでUI周りの利便性を向上させてくれるライブラリということです。それぞれの立ち位置は以下のようになります。

  • Angular Material: UI、動き、A11y をすべて提供(カスタマイズ性は低め)
  • Angular CDK: 汎用的な「動き」や「機能」の土台を提供(A11y は一部開発者任せ)
  • Angular Aria: アクセシブルな振る舞い(WAI-ARIA)に特化(見た目は自由)

Angular CDK も「見た目は自由にカスタマイズしたい」という開発者向けですが、Angular Aria はそこからさらに A11y(アクセシビリティ) に特化しているのが特徴です。

これを使うことで、W3C の Patterns に準拠したアクセシブルな実装が可能になります。複雑な WAI-ARIA の仕様を深く理解していなくても、実装の手間をかけずに簡単にアクセシビリティを担保できるようになる。夢のようですよね。

百聞は一見にしかず。ということで今回は、A11y の考慮がない Tab コンポーネントに、Angular Aria を使ってアクセシビリティを追加する方法を紹介します。今日からでも使える内容なので、ぜひ参考にしてみてください。

※ 完成したものはこちら
https://stackblitz.com/edit/stackblitz-starters-rvoljtqb?file=src%2Ftab.ts

A11y の考慮がない Tab の実装

まずは、アクセシビリティを考慮していない Tab コンポーネントをこんな感じで作ってみました。[1]

tab.component.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-tab',
  imports: [],
  template: `
    <div class="tabs">
      <div class="tab-list">
        @for (tab of tabs; track tab.id) {
          <div
            class="tab"
            [class.selected]="selectedTab() === tab.id"
            [tabindex]="0"
            (click)="selectTab(tab.id)"
            (keydown.enter)="selectTab(tab.id)"
          >
            {{ tab.label }}
          </div>
        }
      </div>
      <div class="sliding-window">
        @for (tab of tabs; track tab.id) {
          <div class="tab-panel">
            <div>{{ tab.content }}</div>
          </div>
        }
      </div>
    </div>
  `,
  styleUrl: './tab.css', // CSSの中身はStackblitzを見てください
})
export class TabComponent {
  protected readonly tabs = [
    { id: 'movie', label: 'Movie', content: 'Panel 1' },
    { id: 'cast', label: 'Cast', content: 'Panel 2' },
    { id: 'reviews', label: 'Reviews', content: 'Panel 3' },
  ];

  protected readonly selectedTab = signal('movie');

  selectTab(tabId: string) {
    this.selectedTab.set(tabId);
  }
}

この実装は、見た目は問題なさそうに見えますが、以下のような問題があります。

  • role などの ARIA 属性や表示制御が不足しており、スクリーンリーダーで正しく認識・読み上げがされません
  • 矢印キーによる移動など、タブ UI に求められる標準的なキーボード操作に対応していません

これらの問題を解決するには、ARIA 属性を適切に設定し、キーボード操作を実装する必要があります。

自前で A11y 対応

自前で対応する場合は、role や ARIA 属性を正しく設定し、キーボードイベントを監視して適切な振る舞いを実装する必要があります。

ARIA 属性と表示制御の対応

まずは、スクリーンリーダーなどで正しく認識されるように、適切な ARIA 属性を付与します。
また、inert 属性を使って、選択されていないタブの内容が支援技術から見えないように制御します。

tab.component.ts
   template: `
     <div class="tabs">
-      <div class="tab-list">
+      <div class="tab-list" role="tablist" aria-label="Entertainment Selection">
         @for (tab of tabs; track tab.id) {
           <div
             class="tab"
-            [class.selected]="selectedTab() === tab.id"
-            [tabindex]="0"
+            role="tab"
+            [id]="tab.id + '-tab'"
+            [attr.aria-selected]="selectedTab() === tab.id"
+            [attr.aria-controls]="tab.id + '-panel'"
+            [tabindex]="selectedTab() === tab.id ? 0 : -1"
             (click)="selectTab(tab.id)"
             (keydown.enter)="selectTab(tab.id)"
           >
             {{ tab.label }}
           </div>
         }
       </div>
       <div class="sliding-window">
         @for (tab of tabs; track tab.id) {
-          <div class="tab-panel">
+          <div 
+            class="tab-panel"
+            role="tabpanel"
+            [id]="tab.id + '-panel'"
+            [attr.aria-labelledby]="tab.id + '-tab'"
+            [attr.inert]="tab.id !== selectedTab() ? true : undefined"
+            [attr.tabindex]="tab.id === selectedTab() ? 0 : -1"
+          >
             <div>{{ tab.content }}</div>
           </div>
         }
       </div>
     </div>

これで、スクリーンリーダーなどでの支援を受けることが出来るようになりましたが、正直これらの属性をすべて覚えるのは大変ですよね。毎回 W3C の要件定義書を確認しながら、正しく実装されているかチェックすることになるでしょう。

キーボード操作の実装

次に、矢印キーでフォーカスを移動したり、Home/End キーで最初・最後のタブに移動したりといった、キーボード操作を実装します。

tab.component.ts
-import { Component, signal } from '@angular/core';
+import { Component, ElementRef, signal, viewChildren } from '@angular/core';

 @Component({
   selector: 'app-tab',
   imports: [],
   template: `
     <div class="tabs">
       <div class="tab-list" role="tablist" aria-label="Entertainment Selection">
         @for (tab of tabs; track tab.id) {
           <div 
             class="tab"
             role="tab"
             [id]="tab.id + '-tab'"
             [attr.aria-selected]="selectedTab() === tab.id"
             [attr.aria-controls]="tab.id + '-panel'"
+            [tabindex]="focusedTabId() === tab.id ? 0 : -1"
             (click)="selectTab(tab.id)"
-            (keydown.enter)="selectTab(tab.id)">
+            (keydown)="onKeydown($event, tab.id)"
+            #tabBtn
           >
             {{ tab.label }}
           </div>
         }
       </div>
       <!-- 省略 -->
     </div>
   `,
   styleUrl: './tab.css',
 })
 export class TabComponent {
   protected readonly tabs = [
     { id: 'movie', label: 'Movie', content: 'Panel 1' },
     { id: 'cast', label: 'Cast', content: 'Panel 2' },
     { id: 'reviews', label: 'Reviews', content: 'Panel 3' },
   ];

   protected readonly selectedTab = signal('movie');
+  protected readonly focusedTabId = signal('movie');

+  readonly tabElements = viewChildren<ElementRef<HTMLElement>>('tabBtn');
+
   selectTab(tabId: string) {
     this.selectedTab.set(tabId);
+    this.focusedTabId.set(tabId);
   }

+  onKeydown(event: KeyboardEvent, tabId: string) {
+    const currentIndex = this.tabs.findIndex((t) => t.id === tabId);
+    switch (event.key) {
+      case 'ArrowLeft': {
+        event.preventDefault();
+        let nextIndex = currentIndex - 1;
+        if (nextIndex < 0) nextIndex = this.tabs.length - 1;
+        this.focusTab(nextIndex);
+        break;
+      }
+      case 'ArrowRight': {
+        event.preventDefault();
+        let nextIndex = currentIndex + 1;
+        if (nextIndex >= this.tabs.length) nextIndex = 0;
+        this.focusTab(nextIndex);
+        break;
+      }
+      case 'Home': {
+        event.preventDefault();
+        this.focusTab(0);
+        break;
+      }
+      case 'End': {
+        event.preventDefault();
+        this.focusTab(this.tabs.length - 1);
+        break;
+      }
+      case 'Enter':
+      case ' ': {
+        event.preventDefault();
+        this.selectTab(tabId);
+        break;
+      }
+      default:
+        return;
+    }
+  }

+  private focusTab(index: number) {
+    const tab = this.tabs[index];
+    this.focusedTabId.set(tab.id);
+
+    const element = this.tabElements().at(index);
+    if (element) {
+      element.nativeElement.focus();
+    }
+  }
 }

これで完成です。簡単でしょう?とはちょっと言えないですよね。どのキーがどのようなアクションをすべきかを正しく把握する必要があり、要件を満たせていないケースも少なくありません。

Angular Aria を使った実装

では、Angular Aria を使うとどうなるでしょう?A11y未考慮のTabから実装してみます。

tab.component.ts
-import { Component, signal } from '@angular/core';
+import { Component } from '@angular/core';
+import { Tab, Tabs, TabList, TabPanel, TabContent } from '@angular/aria/tabs';

 @Component({
   selector: 'app-tab',
-  imports: [],
+  imports: [Tabs, Tab, TabList, TabPanel, TabContent],
   template: `
-    <div class="tabs">
-      <div class="tab-list">
+    <div ngTabs>
+      <div ngTabList selectionMode="explicit" selectedTab="movie">
         @for (tab of tabs; track tab.id) {
-          <div
-            class="tab"
-            [class.selected]="selectedTab() === tab.id"
-            [tabindex]="0"
-            (click)="selectTab(tab.id)"
-            (keydown.enter)="selectTab(tab.id)"
-          >
-            {{ tab.label }}
-          </div>
+          <div ngTab [value]="tab.id">{{ tab.label }}</div>
         }
       </div>
       <div class="sliding-window">
         @for (tab of tabs; track tab.id) {
-          <div class="tab-panel">
-            <div>{{ tab.content }}</div>
-          </div>
+          <div ngTabPanel [preserveContent]="true" [value]="tab.id">
+            <ng-template ngTabContent>{{ tab.content }}</ng-template>
+          </div>
         }
       </div>
     </div>
   `,
   styleUrl: './tab.css',
 })
 export class TabComponent {
   protected readonly tabs = [
    { id: 'movie', label: 'Movie', content: 'Panel 1' },
    { id: 'cast', label: 'Cast', content: 'Panel 2' },
    { id: 'reviews', label: 'Reviews', content: 'Panel 3' },
  ];
-
-  protected readonly selectedTab = signal('movie');
-
-  selectTab(tabId: string) {
-    this.selectedTab.set(tabId);
-  }
 }

これで完成です。簡単でしょう?いや、本当に驚くほど簡単です。これなら、本当に今日からでも使い始められそうです。

まとめ

ドキュメントを見たときから期待はしていましたが、実際に試してみるとその手軽さに驚かされました。これまで実装の複雑さからアクセシブルな Tab コンポーネントを敬遠していた方も、今こそ挑戦する時です。

Angular Aria はTab以外にもコンボボックスやメニューなど様々なパターンが既に実装されています。また、W3Cのパターンを参考にしていることからも今後もどんどん拡充していくと思います。

みなさんもぜひ、これまでTabコンポーネントに限らず、実装を躊躇していたコンポーネントを Angular Aria を使って作ってみてください!

明日はUdomomoさんです!

脚注
  1. 本記事で紹介している Tab コンポーネントの実装コードは、Angular 公式ガイドのサンプルを参考にしています。 ↩︎

Discussion