なぜ Base UI と Tailwind CSS を組み合わせるのか: ヘッドレス UI という分け方
モーダルの見た目は、数行のクラスで書けます。手間がかかるのは、開いている間の振る舞いのほうです。
この章では、モーダルを自作すると何を書くことになるかを先に数えます。そのうえで、どこまでをライブラリに渡せるかを見ます。
この章で学ぶこと
- モーダルを自作したときに抱える仕事
- ヘッドレス UI が引き受ける範囲と、引き受けない範囲
- Base UI がどういうライブラリか
- Tailwind CSS と組み合わせると楽になることと、手元に残ること
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| モーダルを一から自作する手順 | PortalとModal |
| Base UI の導入と実装 | 03 Dialog を実装する |
| Tailwind CSS の考え方と基本のクラス | Tailwind CSS ガイド |
自作のモーダルが抱える仕事
モーダルダイアログに求められる振る舞いは、W3C の WAI-ARIAWAI-ARIAW3C が定めた、HTML の要素に役割・状態・性質を補うための属性の仕様。role 属性と aria- で始まる属性から成り、スクリーンリーダーなどの支援技術に UI の意味を伝える。 Authoring Practices Guide (以下 APG) がまとめています1。キーボードとフォーカスに関する要点は次の 4 つです。
- 開いたら、フォーカスをダイアログの中へ移す
- Tab キーを押してもフォーカスがダイアログの外へ出ず、中を一巡する
- Esc キーで閉じる
- 閉じたら、開く操作をした要素へフォーカスを戻す
これに加えて、その要素がダイアログであることと、ダイアログの名前を、支援技術へ伝える属性 (role="dialog" と aria-labelledby) が要ります。
素の React でここまでを書くと、次のコードになります。番号を振ったコメントが、見た目とは別に書いている仕事です。
import { useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
/**
* Base UI を使わずに書いたモーダル。比較のための例で、実用には足りない。
*
* ここまで書いても、フォーカスの閉じ込めと、閉じるときの遷移はまだ入っていない。
*/
export function HandmadeModal() {
// 1. 開閉の状態を自分で持つ
const [open, setOpen] = useState(false)
// 2. タイトルとダイアログを結ぶ id を自分で用意する
const titleId = useId()
const triggerRef = useRef<HTMLButtonElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
// 3. 開いたら、フォーカスを中へ移す
closeRef.current?.focus()
// 4. Esc キーで閉じる
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
// 5. 背景がスクロールしないようにする
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const trigger = triggerRef.current
return () => {
document.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
// 6. 閉じたら、フォーカスを開いたボタンへ戻す
trigger?.focus()
}
}, [open])
return (
<>
<button
ref={triggerRef}
type="button"
// 7. 開閉の状態を支援技術へ伝える属性も手で書く
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
お知らせ
</button>
{open &&
createPortal(
<>
{/* 8. 背景の幕と、外側を押したら閉じる処理 */}
<div className="fixed inset-0 bg-black/40" onClick={() => setOpen(false)} />
{/* 9. role と aria-* を手で書く */}
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed top-1/2 left-1/2 w-96 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl"
>
<h2 id={titleId} className="text-lg font-bold">
お知らせ
</h2>
<button ref={closeRef} type="button" onClick={() => setOpen(false)}>
閉じる
</button>
</div>
</>,
document.body,
)}
</>
)
}
仕事を 9 つ書きましたが、このモーダルはまだ APG の挙げる振る舞いを満たしていません。
| 足りないもの | 起きること |
|---|---|
| フォーカスの閉じ込めフォーカストラップ (Focus Trap)モーダルなどが開いている間、Tab キーで動くフォーカスをその中に閉じ込める仕組み。背景に隠れた要素へキーボードの操作が抜けるのを防ぐ。 | Shift + Tab を 1 回押すだけで、フォーカスが幕の向こうにある開くボタンへ移ります |
| 閉じるときの遷移 | open が false になった時点で DOM から外しているので、消えていく動きを付けられません |
フォーカスの閉じ込めまで手で書いた例は PortalとModal にあります。同じ記事は、本番で手書きの実装を使うことを勧めていません。代わりに挙げているのは、ネイティブの <dialog> 要素、専用のライブラリ、ヘッドレスコンポーネントの 3 つです。フォーカスできる要素の判定には、非表示の要素や無効な要素といった例外が多く、手書きでは拾いきれないためです。
ここで見ておきたいのは行数ではありません。9 つの仕事のどれも、このモーダルに固有の内容を含んでいないことです。お知らせのダイアログでも、削除の確認でも、設定の画面でも、同じコードをもう一度書くことになります。
ヘッドレス UI が引き受ける範囲
ヘッドレス UI は、見た目を持たない UI 部品です。部品が提供するのは、振る舞いと、HTML の構造と、支援技術向けの属性です。CSS は付いてきません。
UI 部品の用意の仕方を 3 つ並べると、誰が何を書くかの違いが見えます。
| 自作 | スタイル付きのライブラリ | ヘッドレス UI | |
|---|---|---|---|
| 振る舞い (状態、キーボード、フォーカス、ARIA) | 自分で書く | ライブラリが持つ | ライブラリが持つ |
| 見た目 | 自分で書く | ライブラリが持つ。テーマで調整する | 自分で書く |
| 手間がかかる場面 | 振る舞いの実装と、その保守 | 自分たちのデザインに寄せるとき。既定のスタイルを上書きする | 見た目をすべて用意するとき |
ヘッドレス UI は、先ほどの 9 つの仕事と、足りなかった 2 つを引き受けます。引き受けないのは見た目です。背景の幕の色も、ダイアログの角丸も、フォーカスの枠も、使う側が書きます。
この分け方が合うのは、見た目を自分たちで決めたいチームです。デザインの決まりがすでにあり、それを Tailwind CSS で書いているなら、ライブラリが持ち込むスタイルは上書きの対象にしかなりません。最初から何も持ち込まない部品のほうが扱いやすくなります。
ネイティブの dialog 要素という選択肢
モーダルに限れば、HTML 標準の <dialog> 要素という選択肢もあります。showModal() で開くと、フォーカスの閉じ込め、閉じたあとのフォーカスの復帰、Esc キーで閉じる動作を、ブラウザが引き受けます。使い方は PortalとModal が扱っています。
本ガイドがヘッドレス UI の Dialog を題材にするのは、この書き方がモーダル以外にもそのまま通用するからです。Base UI の部品は、メニューもセレクトもタブも、同じ形で書きます。パーツを並べ、className を渡し、data-* 属性で状態を受ける形です。Dialog は、その形を学ぶ最初の 1 つとして選んでいます。
Base UI とは
Base UI は、スタイルを持たない React の部品集です。Radix、Floating UI、Material UI の作者たちが作っています2。2025 年 12 月に v1.0.0 が公開されました。
公式ドキュメントは特徴を 3 つ挙げています。
| 特徴 | 内容 |
|---|---|
| Headless | CSS を同梱せず、スタイルの付け方を指定しません。Tailwind CSS でも CSS Modules でも使えます |
| Accessible | WAI-ARIA のデザインパターンに沿って作られ、ブラウザや支援技術の組み合わせで試験されています |
| Composable | 部品を構成する各要素が公開されていて、足したり外したり、自分の部品で包んだりできます |
部品は Dialog のほかに、Menu、Select、Tabs、Toast など 30 を超えます。すべてが @base-ui/react という 1 つのパッケージに入っていて、使った部品だけがビルドに含まれます。React は 17 以降に対応しています。
本ガイドが Base UI を選んだ理由は 2 つあります。状態を data-* 属性として DOM に出す設計が Tailwind CSS と噛み合うことと、公式ドキュメントのコード例が最初から Tailwind CSS 版を載せていることです。前者は次の章で詳しく見ます。
Tailwind CSS と組み合わせると楽になること
Tailwind CSS を使っている側から見ると、楽になる点は 3 つあります。
- クラスをそのまま渡せます。Base UI の各パーツは HTML の要素を 1 つ描画し、
classNameを受け取ります。包むためのコンポーネントを別に作る必要はありません - 状態を見るための JavaScript が要りません。開いているかどうかは
data-openのような属性で DOM に出ます。Tailwind CSS には属性の有無でスタイルを切り替える variant があるので、data-open:と書けば済みます。isOpen ? 'opacity-100' : 'opacity-0'のような出し分けを書かなくなります - 打ち消すスタイルがありません。部品が CSS を持たないので、既定の余白や色を消すためのクラスが要りません。書いたクラスがそのまま見た目になります
手元に残ること
引き受けてもらえない仕事も、先に確かめておきます。
- 見た目のすべて。フォーカスの枠と色のコントラストも含みます。Base UI はフォーカスをどこへ動かすかを決めますが、フォーカスが当たった要素をどう見せるかは使う側の責任だと、公式ドキュメントが明記しています3
- アプリ側の小さな設定。ダイアログが必ず手前に出るようにする指定など、CSS を 2 か所足します。03 章で扱います
- 任せた部分が動いていることの確認。ライブラリに任せても、タイトルを付け忘れればダイアログは名前を失います。05 章以降のテストは、この確認のためにあります
まとめ
- モーダルの振る舞いを自作すると、状態、Esc、フォーカスの移動と復帰、スクロールの固定、ARIA の属性を毎回書くことになります
- そこまで書いても、フォーカスの閉じ込めと閉じる遷移は別に実装が要ります
- ヘッドレス UI は振る舞いを引き受け、見た目を使う側に残します
- Base UI は状態を
data-*属性で DOM に出すので、Tailwind CSS の variant だけで見た目を切り替えられます - フォーカスの見せ方やコントラストは、引き続き使う側の仕事です
次に読む
- 02 ロジック層とデザイン層 — 2 つの層の境界を、DOM に出る属性から確かめます
- PortalとModal — 同じ振る舞いを手で書くと何が要るかを、順に追えます