メインコンテンツまでスキップ

なぜ Base UI と Tailwind CSS を組み合わせるのか: ヘッドレス UI という分け方

モーダルの見た目は、数行のクラスで書けます。手間がかかるのは、開いている間の振る舞いのほうです。

この章では、モーダルを自作すると何を書くことになるかを先に数えます。そのうえで、どこまでをライブラリに渡せるかを見ます。

この章で学ぶこと​

  • モーダルを自作したときに抱える仕事
  • ヘッドレス UI が引き受ける範囲と、引き受けない範囲
  • Base UI がどういうライブラリか
  • Tailwind CSS と組み合わせると楽になることと、手元に残ること

この章で扱わないこと​

観点参照先
モーダルを一から自作する手順PortalとModal
Base UI の導入と実装03 Dialog を実装する
Tailwind CSS の考え方と基本のクラスTailwind CSS ガイド

自作のモーダルが抱える仕事​

モーダルダイアログに求められる振る舞いは、W3C の WAI-ARIA Authoring Practices Guide (以下 APG) がまとめています1。キーボードとフォーカスに関する要点は次の 4 つです。

  • 開いたら、フォーカスをダイアログの中へ移す
  • Tab キーを押してもフォーカスがダイアログの外へ出ず、中を一巡する
  • Esc キーで閉じる
  • 閉じたら、開く操作をした要素へフォーカスを戻す

これに加えて、その要素がダイアログであることと、ダイアログの名前を、支援技術へ伝える属性 (role="dialog" と aria-labelledby) が要ります。

素の React でここまでを書くと、次のコードになります。番号を振ったコメントが、見た目とは別に書いている仕事です。

src/examples/handmade-modal.tsx
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 の挙げる振る舞いを満たしていません。

足りないもの起きること
フォーカスの閉じ込め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 つ挙げています。

特徴内容
HeadlessCSS を同梱せず、スタイルの付け方を指定しません。Tailwind CSS でも CSS Modules でも使えます
AccessibleWAI-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 だけで見た目を切り替えられます
  • フォーカスの見せ方やコントラストは、引き続き使う側の仕事です

次に読む​

Footnotes​

  1. Dialog (Modal) Pattern の「Keyboard Interaction」と「WAI-ARIA Roles, States, and Properties」の節です。 ↩

  2. About Base UI の冒頭に記載があります。特徴の 3 つと、対応する React の版も同じページです。全部品が 1 つのパッケージに入っていることは Quick start に、v1.0.0 の公開日はリリースノートにあります。部品の数は、v1.8 のドキュメントが載せている 37 個を数えたものです。 ↩

  3. Accessibility の「Focus management」と「Color contrast」の節です。 ↩