はじめに: 「Shadow DOMは見えるのか」の先にある問い
このシリーズの第 1 回「aria-labelを書いたあと、何が起こっている?」(自著の過去記事であり、この記事の一次情報ではありません)では、HTML から DOM、Accessibility Tree(アクセシビリティツリー)、Platform Accessibility API、支援技術、ユーザーへと続く情報伝達の経路を追いました。第 2 回「Shadow DOMの向こう側は、支援技術からどう見えるのか?」では、その経路に Shadow DOM が加わったとき、カプセル化とアクセシビリティの関係がどうなるかを整理しました。
HTML / UI Framework
↓
DOM
↓
Accessibility Tree
↓
Platform Accessibility API
↓
支援技術
↓
ユーザー
第 2 回を書き終えて、ひとつ引っかかりが残りました。「支援技術から見えるか」は確かに大事な問いです。ただ、UI ライブラリの作り手は、それ以外の問題にも向き合っています。フォーカス、Portal(DOM 上の別の場所への描画)、Overlay、Dialog、キーボード操作などです。この記事ではその具体例を一次情報で追います。
そこで第 3 回では、Fluent UI を題材に、UI ライブラリはこの複雑さとどう向き合っているのかを考えます。API の紹介記事ではなく、設計判断の記事です。結論を先に言うと、Fluent UI が提供しているのは Button という部品ではなく、利用者が期待する操作体験だと私は考えています。この記事は、その根拠を確認する旅です。
この記事は、一次情報に基づいて書くことを最優先にしています。根拠は次の 3 種類に分け、本文中のリンクでどれに当たるか分かるようにしました。
- 仕様: W3C / WHATWG / React 公式ドキュメント
- 設計方針: Fluent UI の公式ドキュメント、README、RFC
- 実装: ソースコード、Issue、PR
Fluent UI は 1 つの製品ではありません。 公式リポジトリは React v9 / React v8 / Web Components の 3 プロジェクトを抱えています(microsoft/fluentui README)。この記事で単に「Fluent UI」と書く場合も、コードや挙動を述べるときは、必ず Fluent UI React v9(@fluentui/react-components)か Fluent UI Web Components かを明示します。v8、FAST、Fluent 2 デザインシステムとは区別します。
Buttonは簡単。でも現代のUIはButtonだけではない
まず、いちばん簡単な例から始めます。
<button>保存</button>
この 1 行で、ブラウザーはほとんどすべて面倒を見てくれます。HTML-AAM では button 要素が button ロールにマップされ(HTML-AAM: button)、名前は subtree のテキストから計算されます(HTML-AAM: accessible name computation)。キーボードでは、APG の Button パターンが定める Space / Enter での起動が使えます(APG: Button)。ARIA の使い方についての「第一原則」も同じ方向で、ネイティブ要素で足りるならそれを使うことを勧めています(Using ARIA 1st rule。なお、この文書は 2026 年 2 月に Discontinued Draft となっているため、補助的な参照にとどめます)。
でも、業務システムの画面は Button だけでできていません。Dialog、Menu、TreeView、Combobox、Data Grid、Date Picker、Command Bar など、さまざまな部品が登場します。
APG はこうした部品を「パターン」として定義し、パターンごとにキーボード操作を規定しています。たとえば Tree View は「階層的なリストを提示するウィジェット」、Combobox は「関連づけられたポップアップを持つ入力ウィジェット」、Grid は「矢印キーや Home / End などの方向キーで操作できるコンテナー」とされています(APG: Tree View、Combobox、Grid)。
「ネイティブ HTML には Combobox が無い」とまでは言えません。HTML-AAM では、ドロップダウン表示の select や、候補つきの input が combobox ロールにマップされています。ここで言いたいのは、フィルター入力つきでカスタム描画する Combobox のように、業務 UI が求める振る舞いについては、<button> 1 つのようには面倒を見てくれない領域がある、という程度です。
Button はブラウザーが解決してくれる問題でした。では、解決してくれない領域は誰が担うのでしょうか。次は UI ライブラリの責務から考えます。
UIライブラリの責務は見た目ではない
UI ライブラリと聞くと、色や余白、角丸といった「デザインの提供」を思い浮かべがちです。私自身、以前はそう捉えていました。
ところが、Fluent UI React v9 の公式ドキュメントは、スコープをもう少し広く書いています。公式のアクセシビリティ概念ページは、Fluent UI のコンポーネントが WCAG と ARIA の実践の両方を尊重することを目指す、と述べています。スコープとして挙げているのは、正しい要素や role による DOM 構造、ARIA 属性、キーボード操作、フォーカス管理などです。同じ文書は、「コンポーネントを使うこと自体が、アプリやページのアクセシビリティを保証するわけではない」とも明記しています(AccessibleComponents.mdx)。
つまり、見た目だけでなく次のような責務も含まれます。
| 🧭 観点 | UI ライブラリが関わること |
|---|---|
| 🎹 Keyboard Navigation | 矢印キー、Tab、Escape など部品ごとの操作 |
| 🎯 Focus Management | 開閉時のフォーカス移動・復帰、トラップ |
| 🏷️ Role / Name / State | 何者で、何という名前で、どんな状態か |
| 🔗 Relationship | ラベルとの関連づけ、親子・ポップアップの関係 |
第 1 回の経路で言えば、Role・Name・State は Accessibility Tree に載る情報です。ARIA 1.2 は Accessibility Tree を「UI の構造を表すアクセシブルオブジェクトのツリー」、Accessible Name を「UI 要素の名前」と定義しています(WAI-ARIA 1.2: accessibility tree、accessible name)。ライブラリはこの入口、つまり DOM の作り方に責任を持っています。
一方で、公式ドキュメントはアプリ全体のフォーカス処理をスコープ外とし、アプリ側で(できれば Tabster で)行うよう書いています。国際化、キーボードショートカット、言語検出も意図的に対象外です(同上)。ライブラリがすべてやってくれるわけではなく、どこまでがライブラリで、どこからがアプリかという線引きも、設計の一部だと言えます。
ここまでの整理で、見た目の統一だけではない、意味・操作・フォーカスの設計という責務が見えてきました。
「Fluent UI は CSS ライブラリではない」と言いたくなる理由は、ここにあります。ただし、これは私の要約であり、公式の言い回しではありません。公式が述べているのは、上のスコープの広さです。
Shadow DOMで本当に難しいのは何か
第 2 回では「Shadow DOM の向こうは支援技術から見えるのか」を中心に見ました。読者の関心もそこに集まりやすいと思います。
しかし、UI ライブラリの開発という視点で見ると、難所は別のところにも現れます。
Shadow DOM の「見えるか」問題
└─ role / name / state が Accessibility Tree に届くか(第 2 回)
UI ライブラリ開発で効いてくる問題
├─ Focus : 今どこにフォーカスがあり、どこへ戻すのか
├─ Portal : 論理上の親と DOM 上の親がずれる
├─ Overlay : 背面を操作させない/最前面に出す(Dialog の節)
└─ Dialog : 上の全部を同時に満たす(Focus / Portal の節)
具体例を 1 つ挙げます。Shadow DOM の内側にフォーカスがあるとき、document.activeElement は何を返すでしょうか。HTML Standard の activeElement の定義では、フォーカス中の領域の DOM anchor を、そのツリーに対して retarget(再ターゲット)してから返します。retarget は、shadow root 内にある要素を shadow host に置き換える処理です(HTML: activeElement、DOM: retarget)。この 2 つの仕様から、次のことが言えます。Shadow DOM 内の要素にフォーカスがあっても、document.activeElement からは host しか見えません。内側の要素は shadowRoot.activeElement で辿る必要があります。
Shadow DOM のカプセル化は、フォーカスを「今、何が持っているか」を知る方法にも影響します。この点が Tabster の話につながります。なお、Overlay は次節以降の Dialog と Portal の話の中で扱います。top layer(ブラウザーが最前面に描画する仕組み)はこの記事では掘り下げません。
Focusはなぜ難しいのか
フォーカスが難しいのは、次のような要素が絡み合うからです。
- 今フォーカスされている要素の特定(
activeElement。Shadow DOM では retarget される) - Dialog 内に Tab を閉じ込める Focus Trap
- Modal のとき、背面を操作させない
- 開いたときの初期フォーカスと、閉じたときの復帰先
- 矢印キーによる部品内の移動と、Tab による部品間の移動の使い分け
Dialog について、APG の Dialog (Modal) パターンは次のように定めています。モーダルの下にあるウィンドウは inert(操作不能)であること。Tab / Shift+Tab でフォーカスがダイアログの外に出ないこと。開いたらフォーカスはダイアログ内の要素に移ること。閉じたらフォーカスは呼び出し元に戻ること。Escape で閉じること(APG: Dialog (Modal))。
注意したいのは aria-modal です。WAI-ARIA 1.2 は、作者は「モーダル要素の子孫だけで UI を操作できるようにしなければならない」(MUST)と書き、他のコンテンツは可能なら inert にすべき(SHOULD)としています(WAI-ARIA 1.2: aria-modal)。aria-modal="true" を書くだけでフォーカスが閉じ込められるわけではなく、フォーカス制御は作者、つまりライブラリの責務です。
なぜ Fluent UI は Tabster を使うのか
Fluent UI React v9 は、フォーカス管理に Tabster を使っています。公式のアクセシビリティ概念ページは、Fluent UI のコンポーネントはフォーカス処理に Tabster を使うため、アプリ側の Tabster 機能と統合しやすい、と説明しています(AccessibleComponents.mdx)。
では、なぜ採用したのでしょうか。理由は RFC に書かれています(2021 年頃、Tabster を試す段階での理由です)。v0 / v8 では FocusZone を使ってきましたが、v9 への収束にあたって Tabster を試す、というのが出発点でした。RFC が挙げる理由は次のとおりです(RFC: Moving from FocusZone to Tabster)。
- React 中心の
FocusZoneではなく、DOM レベルで動き、外部のランタイム依存を持たない、フレームワーク非依存の解決策を使うため - フォーカス管理をコンポーネントで行うパターンは、React のコンポーネント分離を壊すため
-
FocusZoneにはネストしたフォーカス可能要素の挙動が不整合になる問題があり、Tabster はそれを明示的に解決しようとしているため
Tabster 自身の説明は「Tabindex on steroids」で、複雑なアプリでキーボードナビゲーションとフォーカス管理のポリシーを組み合わせるための、小さなフレームワーク非依存のライブラリだとされています。ブラウザー API はフォーカスと順次ナビゲーションのプリミティブを提供するものの、動的なアプリではそれらを組み合わせる必要がある、という位置づけです(Tabster README)。
README の「What's included」には、概念としてたとえば次が並んでいます。
| 🧩 概念 | README の説明(要約) |
|---|---|
| Mover | 兄弟要素間の矢印キー / Home / End などによる移動 |
| Groupper | Enter / Escape による単一エントリーのグループ、任意でフォーカストラップ |
| Modalizer | モーダル領域やポップアップが有効な間のフォーカスとアクセシビリティ境界の管理 |
| Restorer / Deloser | フォーカスされた要素が DOM から消えたときの復帰 |
重要なのは、Tabster は ダイアログのセマンティクスを置き換えない、と公式が明記している点です。適切な HTML と ARIA の role は、作者が付ける必要があります(Tabster: Browser support)。ここにも責務の分担があります。
Fluent UI React v9 の Dialog が実際にやっていること
実装を見ると、設計判断が分かります。useDialog は @fluentui/react-tabster の useModalAttributes を使い、non-modal 以外ではフォーカスをトラップします(useDialog.ts)。useModalAttributes は Tabster の Modalizer と Restorer を使います(useModalAttributes.ts)。
DialogSurface はネイティブの <dialog> ではなく div で、role="dialog"(alert のときは alertdialog)と aria-modal を付けています(useDialogSurface.ts)。HTML の <dialog> を showModal() で開くと、ブラウザーが背面を inert にし、top layer に載せ、フォーカスの記録と復帰も行います(HTML: dialog element)。Fluent UI React v9 はその道を既定では選ばず、Tabster の Modalizer / Restorer で、背面の隔離・フォーカストラップ・復帰を組み立てています。ただし仕組みはネイティブと同じではありません。背面の扱いは既定で aria-hidden と Tab 順からの除外であり、ネイティブの inert や top layer とは異なります(Tabster: Modalizer)。描画先は別の話で、DialogSurface は <Portal mountNode={state.mountNode}> の中に描画されます(renderDialogSurface.tsx)。
<dialog> を使わない理由ではありませんが、トラップ方式の既定については PR #26942 が説明しています。ネイティブの inert によるフォーカス制御には iframe を絡めたエッジケースがあるため、legacyTrapFocus を既定にし、inertTrapFocus で inert 方式を選べるようにした、とあります(PR #26942)。
DialogSurface はモーダルの種類を問わず、Escape キーで requestOpenChange を呼びます(useDialogSurface.ts)。一方、設計文書 Spec.md は alert を Escape やバックドロップでは閉じられないと書いています(Dialog Spec.md)。Spec.md はモーダルの Tab がダイアログ外へ出ると閉じる趣旨にも読め、これも既定のトラップ方式とは合いません。ただし、Spec.md は PR #26942 より前の古い記述である可能性があり、確証はありません。なお、移行ガイドの Focus.mdx は、legacyTrapFocus を使わない場合はブラウザー自体の UI へフォーカスが出られると説明しており、これが inert 方式の挙動に当たります(Focus.mdx)。設計文書と現行実装には、こうした食い違いが見られます。
ここまでは「設計判断」と「実装」の話です。なぜ <dialog> を使わなかったのか、という点を直接述べた一次情報は、私は確認できていません。PR が述べているのは、inert に関するエッジケースが理由で legacyTrapFocus を既定にした、というところまでです。
フォーカスの話は、Dialog が「どこに描画されるか」という次の問題とつながります。
PortalはDOM構造を壊しているのか
Dialog や Menu を使うとき、私たちは次のように書きます。
<Dialog>
<DialogTrigger>
<Button>開く</Button>
</DialogTrigger>
<DialogSurface>…</DialogSurface>
</Dialog>
しかし、実際の DOM では DialogSurface が別の階層に描画されます。React 公式ドキュメントによれば、Portal は DOM ノードの物理的な配置だけを変えます。それ以外の点では、Portal にレンダリングした JSX は、それをレンダリングしたコンポーネントの子として振る舞います。コンテキストは親ツリーのものを参照でき、イベントは DOM ツリーではなく React ツリーに沿ってバブリングします(React: createPortal)。
論理構造(React ツリー) 実際の DOM
App body
└─ Dialog ├─ div#root
├─ DialogTrigger │ └─ …(Trigger はここ)
└─ DialogSurface ─────┐ └─ div[data-portal-node]
└──────→ └─ div[role="dialog"]
論理的なコンポーネント構造と実際の DOM 構造は、一致しないことがあります。React 公式は、overflow: hidden のコンテナの外にダイアログやツールチップを出せる用途を、Portal の例として挙げています(同上)。
ここで「Portal がアクセシビリティを壊す」とは言えません。React 公式は、Portal を使うときはアプリがアクセシブルであることを確認することが重要だと書き、たとえばキーボードフォーカスを Portal の内外で自然に移動できるよう管理する必要があるかもしれない、と注意しています。モーダルは WAI-ARIA の Modal Authoring Practices に従うよう勧めています(同上)。つまり、Portal は便利な道具であり、フォーカスなどの管理は引き続き作者の仕事という整理です。
Fluent UI React v9 の Portal
Fluent UI React v9 の Portal は、ReactDOM.createPortal() の薄いラッパーで、Fluent のスタイル(tokens)とテキスト方向を伝搬します。Portal が作った DOM は data-portal-node 属性で識別できます(PortalDescription.md)。既定の描画先は document.body で、mountNode で変更できます(react-portal README)。
設計文書はこの背景を次のように説明しています(Portal Spec.md)。
- スタイリングが DOM に書き込まれた CSS 変数を使うため、すべての Portal を、その CSS 変数が使える DOM の一部に描画する必要がある
- Portal にも、親の React ツリーと同じ
dir属性を付ける必要がある - v0 からの変更として、Portal 自体はフォーカストラップをしません。必要なら Tabster で行います
実装では、Portal の元の位置に <span hidden> を置き、その中で ReactDOM.createPortal を呼びます(renderPortal.tsx)。setVirtualParent で、論理上の親子関係もつないでいます(usePortal.ts)。README は、「外側クリック」の判定で、Portal の要素が DOM 順序から外れているため element.contains(event.target) に頼れない問題を挙げ、elementContains を用意しています(react-portal README)。
Menu、Popover、Tooltip、Dialog は既定で Portal を使います。Menu と Popover には、DOM 順序で描画する inline もあります。Tooltip と Dialog の DialogSurface には inline がありません(各コンポーネントの型定義、例: Menu.types.ts)。
inline にしたときの Tab 順序やスクリーンリーダーの挙動への影響を述べた公式ドキュメントは、私は見つけられませんでした。「DOM 順序に従うので Tab 順序が自然になる」といった説明は推論であり、この記事では事実として扱いません。
見た目や配置を守るための Portal が、フォーカスや論理構造の問題を持ち込みます。ここまでの話を、開発者が書く <Button /> 1 つの視点に引き戻してみます。
Fluent UIは何を隠蔽しているのか
ここが記事の中心です。
<Button>保存</Button>
この 1 行の裏側で、Fluent UI React v9 は何を引き受けているのでしょうか。Button の実装と公式ドキュメントから、確認できる範囲で整理します。
<Button>保存</Button>
│
├─ HTML : 既定でネイティブ <button> を描画
├─ type : as が 'a' 以外なら type="button" を付与
├─ Keyboard : <a> などの非ネイティブ要素では Enter / Space を実装
├─ State : disabled / aria-disabled の整合
├─ Role : 非ネイティブ要素に role="button" を付与
├─ Name : アイコンのみの場合は aria-label を要求(利用者側で)
└─ Style : forced-colors・reduced-motion
根拠は次のとおりです。
- 公式の Button Accessibility Spec は、Button は既定でネイティブ
<button>を描画してその組み込みのアクセシビリティを継承し、as="a"のときは追加の ARIA 属性とキーハンドラーを適用する、と説明しています。アイコンのみの Button にはaria-labelが必要、とも明記されています(ButtonAccessibilitySpec.mdx)。 - 実装の
useButtonは、ルートの props をuseARIAButtonPropsから得ており、as !== 'a'のときはtype="button"を付けています(useButton.ts)。HTML ではtypeが未指定だと Auto 状態となり、form 内では submit ボタンとして扱われます(HTML: button element)。type="button"はそれを避ける役割と読めますが、これは実装コードと仕様の突き合わせによる私の解釈です。 -
useARIAButtonPropsは、<button>を描画する場合はdisabledとaria-disabledを正しく設定するだけで済ませます。<a>や<div>など非ネイティブ要素の場合は、href がなければrole="button"を付け、Enter は keydown で、Space は keyup でclick()を呼びます(useARIAButtonProps.ts)。コードの冒頭コメントは「ARIA の第一原則を守るため」と読めます。 - スタイルは Griffel で書かれ、
@media (forced-colors: active)の中でButtonTextなどのシステムカラーを使っています(useButtonStyles.styles.ts)。また Button Accessibility Spec は、状態変化時のトランジションをprefers-reduced-motion: reduceのとき 0.01ms に短縮する、と書いています(ButtonAccessibilitySpec.mdx)。
Button の仕組みも、はっきりした設計思想の上にあります。公式の「Building custom controls」では、コンポーネントを 3 層で説明しています。use{Component}Base_unstable が挙動・状態・意味構造(ARIA 属性とキーボード操作のパターンを含む)を担当し、use{Component}_unstable がデザイン上の関心事を適用し、{Component} が既定のスタイルつき体験になる、というものです(BuildingCustomControls.mdx)。意味と振る舞いを、見た目から分離して持っていることが分かります。
disabledFocusable については、公式の Button Accessibility Spec の記述と実装が食い違っている可能性があります。ドキュメントには「Tab 順序から外れる」という趣旨の文がありますが、<button> の場合の useARIAButtonProps は disabled を false にして aria-disabled を true にし、tabIndex を操作しません。実機での Tab 順序の確認は、私はまだ行っていません。この記事では、この点を根拠にした主張はしません。
つまり、<Button /> を書く開発者は、HTML 要素の選択、type、キー操作、disabled の扱い、フォーカス表示、強制カラーへの対応といった判断を、ライブラリに預けています。次は、ライブラリ側の選択を見てみます。
Fluent UI v9はどんな選択をしたのか
ここでは、Fluent UI React v9 の選択を整理します。繰り返しますが、v8、Web Components、FAST とは区別します。
React 中心の設計と Fluent 2
v9 は、@fluentui/react と @fluentui/react-northstar を収束(converge)させる取り組みの成果です(react-components README)。Fluent 2 は Fluent デザイン言語の次のバージョンで、v9 はそのレイアウトとスタイルを使います(KeepingDesignConsistent.mdx)。Fluent 2 のサイトも、React で Fluent 2 の体験を作るには Fluent UI React v9 が必要だと述べています(Fluent 2: Develop)。
Griffel の前身 makeStyles が目指したこと(2020 年 12 月の提案時点)
v9 のスタイリングは Griffel です。公式は、makeStyles は Griffel 由来で、アトミックな CSS クラスを生成する自社製(homegrown)の CSS-in-JS だと説明しています(StylingComponents.mdx)。Griffel 自身は「ほぼゼロのランタイム、SSR 対応、JavaScript オブジェクトでスタイルを定義する CSS-in-JS」です。ビルド時の最適化(AOT コンパイルと CSS 抽出)も備えます。ただし表記は混在していて、README では CSS 抽出は Experimental とされ、公式サイトのビルド最適化ページでは通常の機能として扱われています(2026 年 10 月時点。Griffel README、Build optimization)。
その前身にあたる @fluentui/make-styles の makeStyles を提案した PR #16082 は、課題を 3 つ挙げています。パフォーマンス(同じコンポーネントが複数あるときの重複計算)、予測可能な上書き(詳細度の問題)、開発者体験(Northstar のスタイルファイルの複雑さ)です。設計としては、コストの高い処理とクラス名の生成をビルド時に回し、軽いマージだけを実行時に行う、としています(PR #16082)。この PR の時点では Griffel という名前はなく、のちに @fluentui/make-styles が @griffel/core に置き換えられました(PR #21320)。
なお、v8 の merge-styles から移行した理由を直接述べた単独の RFC は、私は確認できませんでした。根拠は PR や RFC に分散しています。
アクセシビリティと Griffel の関係
Griffel の README と公式サイトには、アクセシビリティ機能としての記述を確認できませんでした。ただし、forced-colors のメディアクエリとシステムカラーを書く手段にはなります。Fluent UI の RFC は「Windows のハイコントラストモードは Fluent UI で既定でサポートされる」とし、方針は「できるだけ何もせず、OS のパレットが不十分なときだけ介入する」としています(high-contrast-theme.md)。スタイルのハンドブックも、色の直接指定は避け、例外は forced-colors のメディアクエリ内のシステムカラーだとしています(styles-handbook.md)。
Tabster を採用した理由
前述のとおり、RFC に理由があります。ただし、その RFC は 2021 年頃に Tabster を試す段階で書かれた文書であり、当時の理由です。加えて、@fluentui/react-tabster は Tabster の機能を React フックとして公開しています。useArrowNavigationGroup は Mover、useFocusableGroup は Groupper、useModalAttributes は Modalizer と Restorer を使います(useArrowNavigationGroup.ts、useFocusableGroup.ts、useModalAttributes.ts)。
v9 は FocusZone や FocusTrapZone のコンポーネントを直接は提供しません。まず TabList、Menu、Tree、Dialog などの、必要な意味と操作をすでに持つコンポーネントを使うよう勧めています。フックはカスタム複合ウィジェット向けです(Focus.mdx)。ここでも、ウィジェット単位で意味と操作を提供するという設計が貫かれています。
Shadow DOM との関係
v9 は、既定では Shadow DOM を前提にしていません。公式ドキュメントは、v9 を Web Components と併用するには既定の動作を変更する必要があり、その実装は fluentui-contrib にある、と説明しています。Shadow DOM を使う場合は、FluentProvider を Light DOM に置く必要があります(UsingFluentReactWithWebComponents.mdx)。
Tabster 側には、Shadow DOM を貫通するための DOM API の差し替え(getShadowDOMAPI())があり(Tabster: Shadow DOM)、Fluent v9 の useTabster は window.__tabsterShadowDOMAPI があればそれを使います(useTabster.ts)。その設定を行うのは本体ではなく @fluentui-contrib/pierce-dom です(pierce-dom)。つまり、Shadow DOM を越えるフォーカス管理は、第 2 回で見た複雑さがそのまま現れる領域であり、既定ではなく選択的に対応する設計になっています。ただし、pierce-dom の README は open shadow root のみ対応で、slot は未実装と書いており、Tabster 本体のドキュメントとの整合は、確認できていません。
Fluent UI Web Components は別の選択をしている
一方、Fluent UI Web Components は、私が確認した Button などの実装では、FAST の既定(open な shadow root)に従って Shadow DOM を使っています。全コンポーネントを網羅して確認したわけではありません。以下は Web Components v3(@fluentui/web-components 3.x、2026 年 10 月時点の最新は 3.1.3)についての記述です。v2 は @microsoft/fast-foundation を合成していましたが、v3 は @microsoft/fast-element v3 を peer dependency とし、fast-foundation には依存していません(v2 README、v3 package.json)。FAST Element は、shadowOptions が未指定なら open な shadow root を作ります(fast-definitions.ts)。
v3 の Button 実装は、次のことをしています。
-
attachInternals()で ElementInternals を取得する - form-associated custom element として宣言する
- role を
this.elementInternals.role = 'button'で設定する -
aria-disabledもElementInternals経由で設定する
HTML Standard は、internals.role を、作者が role 属性で上書きしない限り使われる既定のロールとして定めています(HTML: accessibility semantics)。これは第 2 回で見た「ElementInternals で Custom Element 自身に既定の意味を与える」方法です。関連する記事として、Shadow DOMの向こう側へ、UIの「意味」はどう届く?もあわせてご覧ください。
なお、Microsoft Learn の Web Components 概要ページには、v2 時代の説明(fast-foundation を合成する)が残っている可能性があります。v3 の記述と食い違うため、混同しないよう注意してください(Learn: Fluent UI Web Components)。
Fluent UI React v9 : React + Griffel、Shadow DOM 非前提、Tabster でフォーカス
Fluent UI Web Components v3 : FAST Element + Shadow DOM(既定)+ ElementInternals
同じ Fluent 2 の体験を目指しつつ、DOM の扱いは大きく異なります。Fluent 2 のサイトは、どちらも Fluent 2 の体験を構築する手段として載せています(Fluent 2: Develop)。ただし、同サイトの Web Components の記述はバージョン番号を明示していません。
Fluent UI React v9 と Fluent UI Web Components v3 を同一視しないことが大切です。この記事で「v9 は Shadow DOM を使わない」と書いているのは、既定の動作についてです。Shadow DOM との併用ができない、という意味ではありません。
選択はそれぞれでも、共通しているのは「複雑さをライブラリの内側に寄せる」ことです。この視点で、第 1 回に戻ります。
だからアクセシビリティは後付けできない
第 1 回の経路をもう一度見てみます。
設計 → コンポーネント → DOM → Accessibility Tree → 支援技術 → ユーザー
ここまで見てきた Fluent UI の判断を並べると、フォーカス、Portal、Modal、role、キー操作は、ほとんどがコンポーネントを設計する段階で決まっています。Dialog では、どこに描画し、どこにフォーカスを入れ、閉じたらどこに戻し、背面をどう扱うかが、最初から設計に入っています。APG の Dialog (Modal) パターンも、開いたらダイアログ内へ、閉じたら呼び出し元へ、というフォーカスの動きを最初から振る舞いとして定めています(前述)。
× 実装完了 → axe → 修正(ユーザーはここに登場しない)
○ 設計 → コンポーネント → DOM → Accessibility Tree → 支援技術 → ユーザー
(各段階に設計判断がある)
自動テストを否定するわけではありません。Fluent UI の公式ドキュメントも、axe-core を開発時とビルド時にコンポーネントの検証へ使い、手動テストも行うと書いています(AccessibleComponents.mdx)。axe は、設計の結果が壊れていないかを確かめる手段です。後から「修正」して整えるものではなく、最初に設計しておくものがある、というのが私の整理です。
また、ライブラリを使うだけで保証されるものでもありません。前述のとおり、公式ドキュメントは「コンポーネントを使うことで、アプリやページがアクセシブルになることが保証されるわけではない」とはっきり書いています。たとえばアイコンだけの Button には aria-label が必要です。Dialog はフォーカス可能な要素を少なくとも 1 つ持つべき(should)で、ない場合は閉じる手段がバックドロップのクリックだけになる、と公式は説明しています(DialogNoFocusableElement.md)。ライブラリと利用者の間にも、責務の境界があります。
まとめ
Fluent UI が提供しているのは、Button コンポーネントそのものではありません。この記事で見てきたとおり、<Button /> や <Dialog /> の裏側では、DOM の構造、role、フォーカス、キーボード、Portal、Overlay といった、ブラウザーが面倒を見てくれない部分が設計されています。Fluent UI が提供しているのは、HTML / CSS / React Component そのものではなく、利用者が期待する操作体験だと私は考えます。
- Fluent UI が戦っている相手は、見た目の統一ではありません。DOM / Shadow DOM / ARIA / Focus / Portal / Accessibility Tree / 支援技術のあいだにある複雑さです
- 私たちが
<Button>1 つで済んでいるのは、問題が簡単だからではなく、ライブラリ作者がその複雑さを肩代わりしているからです - それでも、どこまでが肩代わりされ、どこからが自分の責任かを知っておく必要があります(意味・操作・フォーカスの設計はライブラリが担い、アプリ全体のフォーカスや国際化、
aria-labelなどは利用者側に残ります)
Shadow DOM との両立の仕方も、v9 と Web Components v3 で異なります。v9 は Shadow DOM を前提にせず、Tabster の DOM API を差し替えて対応する余地を残します。Web Components v3 は Shadow DOM を既定とし、ElementInternals で意味を持たせます。
最後に、この記事の限界も書いておきます。ここで述べたのは公式ドキュメントと、ある時点のソースコードから確認できた範囲です。ドキュメントと実装の食い違い(disabledFocusable、alert ダイアログの Escape と Tab 末尾の挙動)も見つかっており、実機の検証はしていません。実際に導入するときは、利用するバージョンのドキュメントと挙動を確認してください。
シリーズ第 4 回は、ライブラリを使う側の責務、つまり「自分で組み立てるときに何を引き受けるのか」を考えてみます。
参考資料
仕様
- WAI-ARIA 1.2(aria-modal)
- HTML-AAM 1.0
- Core-AAM 1.2
- Using ARIA(1st rule、Discontinued Draft)
- APG: Button / Dialog (Modal) / Tree View / Combobox / Grid
- HTML Standard: dialog element
- HTML Standard: activeElement
- HTML Standard: button element
- HTML Standard: Accessibility semantics (ElementInternals)
- DOM Standard: retarget
- React: createPortal
Fluent UI 設計方針
- microsoft/fluentui README
- Accessibility: AccessibleComponents.mdx
- Button Accessibility Spec
- Building custom controls
- RFC: Moving from FocusZone to Tabster
- Migration: FocusZone / FocusTrapZone
- Portal Spec.md / Portal README
- Styles Handbook / High contrast RFC
- Using Fluent React with Web Components
- Fluent 2: Develop
- Tabster README / Tabster: Browser support / Tabster: Shadow DOM
- Griffel README
実装
- useDialog.ts / renderDialogSurface.tsx / useDialogSurface.ts
- useModalAttributes.ts / useTabster.ts
- PR #26942(legacyTrapFocus / inertTrapFocus)
- PR #16082(新しい makeStyles の提案)
- renderPortal.tsx / usePortal.ts
- useButton.ts / useARIAButtonProps.ts / useButtonStyles.styles.ts
- pierce-dom(fluentui-contrib)
- Web Components v3: button.base.ts / package.json
- FAST Element: fast-definitions.ts