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

Dialog を実装する: 状態も ARIA も書かないモーダル

この章で、ガイド全体の題材になるダイアログを作ります。お知らせの一覧を表示し、まとめて既読にできるダイアログです。

書き終えたコードには、useState も、aria- で始まる WAI-ARIA の属性も、onKeyDown も出てきません。role は 1 か所だけ手で書いていますが、Base UI ではなく Tailwind CSS の側の事情によるものです。01 章で数えた仕事がどこへ行ったのかを、コードと対応させながら確かめます。

この章で学ぶこと​

  • Base UI と Tailwind CSS をプロジェクトに入れる手順
  • Dialog を構成するパーツと、それぞれの役割
  • className にクラスを直接渡す書き方
  • data-starting-style と data-ending-style を使った開閉の遷移
  • 状態と ARIA の属性を書かずに済む理由
  • 押すと無効になるボタンで、フォーカスを失わない書き方
  • 描画する要素を render prop で差し替える方法

この章で扱わないこと​

観点参照先
Tailwind CSS の導入の詳細と、各クラスの意味環境構築 と Tailwind CSS ガイド
transition と duration の基本アニメーション
このコンポーネントのテスト06 単体テスト 以降

プロジェクトを用意する​

Vite の React と TypeScript のテンプレートから始めます。

npm create vite@latest base-ui-dialog-lab -- --template react-ts

作られたディレクトリで、Base UI と Tailwind CSS を入れます。Base UI は、すべての部品が @base-ui/react という 1 つのパッケージに入っています。

npm install
npm install @base-ui/react
npm install tailwindcss @tailwindcss/vite

Tailwind CSS は Vite のプラグインとして組み込みます。vite.config.ts に import を 1 行足し、plugins に加えます。

vite.config.ts
import tailwindcss from '@tailwindcss/vite'
vite.config.ts
// tailwindcss() を足すと、Vite が CSS を処理するときに Tailwind CSS のクラスが生成される
plugins: [react(), tailwindcss()],

CSS のファイルは、テンプレートが生成した中身を消して次の内容にします。

src/index.css
/* Tailwind CSS v4 は、この 1 行で全ユーティリティを読み込む (tailwind.config.js は要らない) */
@import "tailwindcss";

/*
* Base UI のダイアログは Portal で body の直下に描画される。
* アプリ側を独立した重なりの層にしておくと、アプリ内の z-index に関係なく
* ダイアログが必ず手前に出る。
*/
#root {
isolation: isolate;
}

/*
* iOS 26 以降の Safari では、背景の幕を position: absolute で敷く必要がある。
* ページをスクロールした後も幕が画面全体を覆うよう、body を位置の基準にする。
*/
body {
position: relative;
}

1 行めが Tailwind CSS の読み込みです。残りの 2 つは Base UI のための設定で、どちらも公式ドキュメントの Quick start が求めているものです1。

設定理由
#root に isolation: isolateダイアログは body の直下に描画されます。アプリ側を独立した重なりの層にしておくと、アプリの中でどんな z-index を使っていても、ダイアログが必ず手前に出ます
body に position: relativeiOS 26 以降の Safari 向けです。背景の幕を画面全体に敷くための基準になります

表示用の値を作る関数を置く​

コンポーネントの前に、お知らせのデータの型と、表示用の値を作る関数を用意します。未読の件数、バッジの文言、説明文を作る関数と、すべて既読にした配列を返す関数です。

src/lib/notifications.ts
/** お知らせ 1 件ぶんのデータ。 */
export type Notification = {
id: string
title: string
/** 既読なら true。 */
read: boolean
}

/** バッジに出す件数の上限。これを超えたら「99+」と表示する。 */
const BADGE_MAX = 99

/**
* 未読のお知らせの件数を数える。
*
* @param notifications - お知らせの一覧
* @returns 未読 (`read` が false) の件数
*/
export function countUnread(notifications: readonly Notification[]): number {
return notifications.filter((notification) => !notification.read).length
}

/**
* 未読件数を、ボタンに添えるバッジの文言に変える。
*
* @param count - 未読件数
* @returns 0 件以下なら空文字、上限を超えたら「99+」、それ以外は件数そのまま
*/
export function formatUnreadBadge(count: number): string {
if (count <= 0) return ''
if (count > BADGE_MAX) return `${BADGE_MAX}+`
return String(count)
}

/**
* ダイアログの説明文を組み立てる。
*
* @param notifications - お知らせの一覧
* @returns 件数に応じた説明文
*/
export function describeNotifications(notifications: readonly Notification[]): string {
if (notifications.length === 0) return 'お知らせはありません。'

const unread = countUnread(notifications)
if (unread === 0) return 'すべて確認済みです。'
return `未読のお知らせが ${unread} 件あります。`
}

/**
* すべて既読にした新しい配列を返す。
*
* 引数の配列は書き換えない。React の state 更新関数にそのまま渡せる。
*
* @param notifications - お知らせの一覧
* @returns 全件の `read` を true にした新しい配列
*/
export function markAllRead(notifications: readonly Notification[]): Notification[] {
return notifications.map((notification) => ({ ...notification, read: true }))
}

件数の計算や文言の組み立てを、コンポーネントの中ではなく別のファイルに置いています。こう分ける理由は、05 章でテストと一緒に取り上げます。ここでは「お知らせの配列を渡すと、表示用の値が返る関数がある」とだけ捉えてください。

ダイアログを組み立てる​

コンポーネントの全体です。お知らせの配列を props で受け取り、開くボタンとダイアログを描画します。

src/components/notifications-dialog.tsx
import { Button } from '@base-ui/react/button'
import { Dialog } from '@base-ui/react/dialog'
import {
countUnread,
describeNotifications,
formatUnreadBadge,
type Notification,
} from '../lib/notifications'

type NotificationsDialogProps = {
/** 表示するお知らせ。取得は親の仕事で、このコンポーネントは受け取って表示するだけ。 */
notifications: readonly Notification[]
/** 「すべて既読にする」が押されたときに呼ばれる。 */
onMarkAllRead: () => void
/** 最初から開いた状態で表示するか。Storybook に「開いた見た目」を載せるために使う。 */
defaultOpen?: boolean
}

/*
* ボタン 3 つ (開く / すべて既読にする / 閉じる) に共通する見た目。
* focus-visible: は、キーボードでフォーカスしたときだけ枠を出す指定。
* フォーカスを「どこへ動かすか」は Base UI が決め、「どう見せるか」はここで決める。
* 無効のときの見た目は data-disabled: で受ける。Base UI のボタンは、無効の間この属性を出す。
*/
const buttonClass =
'inline-flex h-9 items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-3 text-sm font-medium text-slate-900 select-none hover:not-data-disabled:bg-slate-100 active:not-data-disabled:bg-slate-200 data-disabled:cursor-not-allowed data-disabled:text-slate-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600'

export function NotificationsDialog({
notifications,
onMarkAllRead,
defaultOpen = false,
}: NotificationsDialogProps) {
// 表示用の値は純粋関数で作り、コンポーネントは結果を並べるだけにする
const unreadCount = countUnread(notifications)
const badge = formatUnreadBadge(unreadCount)

return (
// Root が開閉の状態を持つ。useState を書かなくても開閉できるのは、ここが管理しているから
<Dialog.Root defaultOpen={defaultOpen}>
{/* Trigger は <button> を描画し、押すと開く。aria-expanded などの属性も自動で付く */}
<Dialog.Trigger className={buttonClass}>
お知らせ
{badge !== '' && (
<span className="rounded-full bg-blue-600 px-2 py-0.5 text-xs font-bold text-white">
{badge}
{/* sr-only は画面には出さず、スクリーンリーダーにだけ読ませる */}
<span className="sr-only"> 件の未読</span>
</span>
)}
</Dialog.Trigger>

{/* Portal の中身は body の直下に描画され、親要素の overflow や z-index の影響を受けない */}
<Dialog.Portal>
{/*
* Backdrop は背景の幕。
* 開く瞬間は data-starting-style、閉じる間は data-ending-style が付く。
* その間だけ透明にしておくと、transition が間をつないでフェードになる。
*/}
<Dialog.Backdrop className="fixed inset-0 min-h-dvh bg-black/40 transition-opacity duration-200 data-starting-style:opacity-0 data-ending-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute" />

{/* Popup がダイアログ本体。role="dialog" と、タイトル・説明との関連付けが自動で付く */}
<Dialog.Popup className="fixed top-1/2 left-1/2 flex w-96 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-xl bg-white p-6 text-slate-900 shadow-xl transition-[opacity,scale] duration-200 data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0">
<div className="flex flex-col gap-1">
{/* Title は <h2> を描画し、ダイアログの名前 (aria-labelledby の参照先) になる */}
<Dialog.Title className="text-lg font-bold">お知らせ</Dialog.Title>
{/* Description は <p> を描画し、ダイアログの説明 (aria-describedby の参照先) になる */}
<Dialog.Description className="text-sm text-slate-600">
{describeNotifications(notifications)}
</Dialog.Description>
</div>

{notifications.length > 0 && (
/*
* Tailwind CSS は、リストの行頭記号を既定で消す。記号の無いリストは、VoiceOver が
* リストとして読み上げない。role="list" を付けて、リストであることを保つ。
*/
<ul role="list" className="flex flex-col divide-y divide-slate-200 text-sm">
{notifications.map((notification) => (
<li key={notification.id} className="flex items-center justify-between gap-3 py-2">
<span className={notification.read ? 'text-slate-500' : 'font-medium'}>
{notification.title}
</span>
{!notification.read && (
<span className="shrink-0 rounded bg-blue-50 px-1.5 py-0.5 text-xs text-blue-700">
未読
</span>
)}
</li>
))}
</ul>
)}

<div className="flex justify-end gap-2">
{/*
* Button は <button> を描画する。押すと未読が 0 件になり、このボタン自身が無効になる。
* focusableWhenDisabled を付けると、無効になってもフォーカスがこのボタンに残る。
* 付けないと、フォーカスを持ったまま無効になる。Chromium では、その時点で
* フォーカスがダイアログの外 (body) へ外れる。
*/}
<Button
className={buttonClass}
onClick={onMarkAllRead}
disabled={unreadCount === 0}
focusableWhenDisabled
>
すべて既読にする
</Button>
{/* Close は <button> を描画し、押すと閉じる。onClick を書く必要はない */}
<Dialog.Close className={buttonClass}>閉じる</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
)
}

パーツの役割​

Base UI の部品は、複数のパーツの組み合わせでできています。Dialog では、次の 8 つを使いました2。

パーツ描画する要素役割
Dialog.Rootなし開閉の状態を持ち、配下のパーツをまとめます
Dialog.Trigger<button>押すと開きます
Dialog.Portal<div>中身を body の直下へ移します
Dialog.Backdrop<div>背景の幕です
Dialog.Popup<div>ダイアログ本体です。role="dialog" が付きます
Dialog.Title<h2>ダイアログの名前になる見出しです
Dialog.Description<p>ダイアログの説明です
Dialog.Close<button>押すと閉じます

パーツは入れ子の位置で互いを見つけます。Dialog.Title を Dialog.Popup の中に置くだけで、Dialog.Popup の aria-labelledby にタイトルの id が入ります。id を自分で用意して渡す必要はありません。こうして結ばれたタイトルの文字が、ダイアログのアクセシブルネーム (支援技術が読み上げる名前) になります。

Dialog.Portal を使うのは、ダイアログを親要素のスタイルから切り離すためです。開くボタンが overflow: hidden の要素の中にあっても、ダイアログは body の直下に出るので切り取られません。

このほかに、中身が長くてスクロールが要るときの配置に使う Dialog.Viewport があります。本ガイドのダイアログは短いので使っていません。

「すべて既読にする」のボタンは Dialog のパーツではなく、Button という別の部品です。あとの節で扱います。

クラスを直接渡す​

Dialog.Root を除く各パーツは HTML の要素を 1 つ描画するので、className をそのまま受け取ります。スタイルのためにパーツを包むコンポーネントを作る必要はありません。

上のコードでは、ボタンのクラスを buttonClass という定数にまとめています。開くボタン (Dialog.Trigger)、「すべて既読にする」の Button、閉じるボタン (Dialog.Close) の 3 つに同じ見た目を当てるためです。

className には、文字列のほかに、パーツの状態を受け取ってクラスを返す関数も渡せます3。ただし、状態に応じた見た目の切り替えは、次の節のように data-* 属性で書けることがほとんどです。本ガイドでは関数の形を使いません。

見た目の層が外した意味を戻す​

お知らせの一覧の ul には、role="list" を付けています。Base UI のパーツではない素の要素で、このコードの中で role を手で書いた唯一の箇所です。

Tailwind CSS は、リストの行頭記号を既定で消します。公式ドキュメントは、行頭記号の無いリストを VoiceOver がリストとして読み上げない、と注意しています。内容が本当にリストなら role="list" を足すように、という案内です4。

ul はもともとリストの役割を持つ要素なので、同じ役割を書くことを冗長として指摘する lint の規則があります。eslint-plugin-jsx-a11y の no-redundant-roles は、既定では nav への navigation だけを許可しています5。この規則を使っているプロジェクトでは、ul への list も許可する設定を足します。本ガイドのテンプレートに入っている lint (oxlint) の既定の設定では、この行は指摘されませんでした。

01 章で、見た目は使う側の仕事として手元に残る、と書きました。見た目を自分で書くと、スタイルが意味に及ぼす影響も自分で見ることになります。ここはその一例です。

開閉の遷移を CSS だけで書く​

開くときにふわっと現れ、閉じるときに消えていく動きは、背景の幕とダイアログ本体のクラスだけで付けています。該当する部分を抜き出します。

src/components/notifications-dialog.tsx
<Dialog.Portal>
{/*
* Backdrop は背景の幕。
* 開く瞬間は data-starting-style、閉じる間は data-ending-style が付く。
* その間だけ透明にしておくと、transition が間をつないでフェードになる。
*/}
<Dialog.Backdrop className="fixed inset-0 min-h-dvh bg-black/40 transition-opacity duration-200 data-starting-style:opacity-0 data-ending-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute" />

{/* Popup がダイアログ本体。role="dialog" と、タイトル・説明との関連付けが自動で付く */}
<Dialog.Popup className="fixed top-1/2 left-1/2 flex w-96 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-xl bg-white p-6 text-slate-900 shadow-xl transition-[opacity,scale] duration-200 data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0">

背景の幕に付けたクラスのうち、遷移に関わるのは次の 4 つです。

クラス働き
transition-opacity不透明度が変わるとき、時間をかけて変化させます
duration-200変化にかける時間を 200 ミリ秒にします
data-starting-style:opacity-0data-starting-style が付いている間、透明にします
data-ending-style:opacity-0data-ending-style が付いている間、透明にします

この 4 つで起きることを、02 章で見た属性の移り変わりに重ねます。

時点付いている属性不透明度
開く操作の直後data-open と data-starting-style0 (透明)
次の瞬間data-open0 から 1 へ、200 ミリ秒かけて変化
開いている間data-open1
閉じる操作の直後data-closed と data-ending-style1 から 0 へ、200 ミリ秒かけて変化
変化が終わったあとDOM から外れている値なし

開くときは、最初に透明な状態で DOM に入り、直後に data-starting-style が外れます。外れた瞬間に不透明度の指定が変わるので、transition が働いてフェードインになります。

閉じるときは、data-ending-style が付いて透明へ向かいます。Base UI はこの変化が終わるのを待ってから、要素を DOM から外します6。01 章の自作のモーダルは、閉じた瞬間に DOM から外していたので、消えていく動きを付ける余地がありませんでした。

ダイアログ本体も同じ仕組みで、不透明度に加えて大きさ (scale-95) を変えています。transition-[opacity,scale] は、その 2 つの性質だけを変化の対象にする指定です。

キーフレームではなく transition を使う理由

開閉の動きは @keyframes を使ったアニメーションでも書けます。Base UI の公式ドキュメントは transition のほうを勧めています。開いている途中で閉じる操作をしたときに、transition なら途中の状態からそのまま逆向きに変化できるためです。

状態と ARIA を書かずに済む理由​

01 章の自作のモーダルで番号を振った仕事が、このコードのどこに行ったのかを対応させます。

01 章で書いた仕事このコードでは
1. 開閉の状態を持つDialog.Root が持ちます。useState は書いていません
2. タイトルと結ぶ id を用意するDialog.Title を置くだけで結ばれます
3. 開いたらフォーカスを中へ移すBase UI が行います。移す先は initialFocus で変えられます
4. Esc キーで閉じるBase UI が行います
5. 背景のスクロールを止めるBase UI が行います
6. 閉じたらフォーカスを戻すBase UI が行います。戻す先は finalFocus で変えられます
7. aria-haspopup と aria-expanded を付けるDialog.Trigger に自動で付きます
8. 背景の幕と、外側を押したら閉じる処理幕は Dialog.Backdrop です。外側を押すと閉じる処理は Base UI が行います
9. role と aria-* を付けるDialog.Popup に自動で付きます
足りなかった、フォーカスの閉じ込めBase UI が行います
足りなかった、閉じるときの遷移前の節の data-ending-style で書けます

このコンポーネントが自分で書いているのは、アプリの都合で決まることだけです。お知らせの中身をどう並べるか、どんな見た目にするか、ボタンをいつ無効にするか、押されたことをどう親へ伝えるかです。開閉やフォーカスの動かし方は 1 行も書いていません。先に見た role="list" は、見た目を書いた側の後始末として足したものです。

開閉の状態を持たない書き方を、Base UI は非制御と呼んでいます。Dialog.Root に何も渡さなければ非制御になり、開閉は Base UI の中で完結します。このコードで defaultOpen を渡しているのは、最初から開いた状態で描画できるようにするためです。04 章の Storybook で使います。

アプリの側から開閉を動かしたいときは、open と onOpenChange を渡して制御します。フォームの送信が終わったら閉じる、といった場面です。02 章で触れたとおり、見た目を変えるためだけに制御へ切り替える必要はありません。

押すと無効になるボタン​

「すべて既読にする」は、素の <button> ではなく Base UI の Button で書いています。<button> を描画する部品で、@base-ui/react/button から import します。

Button を使ったのは、このボタンが押すと自分で無効になるからです。押すと未読が 0 件になり、disabled に渡している式が true に変わります。その時点で、フォーカスはこのボタンにあります。

素の disabled 属性で同じことをすると、フォーカスが行き場を失います。Chromium で確かめたところ、押したあとの document.activeElement は body になりました。モーダルの背景は支援技術から隠されているので、キーボードやスクリーンリーダーで操作している人は、ダイアログの中での現在位置を失います。

focusableWhenDisabled を付けると、Base UI は disabled 属性の代わりに aria-disabled="true" を付け、フォーカスを受けられる状態を保ちます7。Chromium の上で同じ操作をして、違いを記録しました。ボタンは Enter キーで押しています。

focusableWhenDisabled なしfocusableWhenDisabled あり
無効の間に付く属性disabledaria-disabled="true"
押したあとにフォーカスを持つ要素body「すべて既読にする」のボタン
そこから Tab キーを押したときの移動先「閉じる」だけ「閉じる」と「すべて既読にする」を行き来する
続けて Enter キーをもう一度押す親へ知らせない。フォーカスが body にあり、キー入力がボタンに届かない親へ知らせない。フォーカスはボタンにあるが、無効なので働かない
Esc キーを押す閉じる閉じる
未読が無い状態で開いたときの、最初のフォーカス「閉じる」のボタン「すべて既読にする」のボタン (無効のまま)

最後の行は、focusableWhenDisabled を付けたことで変わる点です。未読が無い状態で開くと、フォーカスは無効の「すべて既読にする」から始まります。無効でもフォーカスを受ける要素として残したためです。これが合わない場面では、Dialog.Popup の initialFocus で、最初に移す先を変えられます。

フォーカスを保つ方法は、ほかにもあります。押したあとに「閉じる」へフォーカスを移す処理を自分で書く方法や、ボタンを無効にせず、押しても何も起きないようにする方法です。本ガイドでは、Base UI が用意している指定を使い、フォーカスの動かし方を自分では書かない形を選びました。

無効のときの見た目は、どちらの場合も data-disabled で受けられます。Base UI のボタンは、無効の間この属性を出します。buttonClass の data-disabled:text-slate-400 と hover:not-data-disabled:bg-slate-100 がその指定です。disabled: で書くと disabled 属性があるときにしか効かないので、aria-disabled で無効にしたボタンには当たりません。

ここでも、aria-disabled を自分では書いていません。「無効だがフォーカスは受ける」という振る舞いを Base UI に頼み、見た目だけを data-disabled で書いています。

描画する要素を差し替える​

パーツが描画する要素は、render prop で差し替えられます。自前のボタンのコンポーネントを開くボタンとして使いたい、といったときに使います。

src/examples/render-prop.tsx
import { Dialog } from '@base-ui/react/dialog'
import type { ComponentProps } from 'react'

/**
* 自前のボタン。
*
* 受け取った props をすべて <button> へ渡す。React 19 では ref も props に含まれるので、
* これだけで Base UI が渡してくる ref とイベントハンドラが <button> に届く。
*/
function PrimaryButton({ className = '', ...props }: ComponentProps<'button'>) {
return (
<button
type="button"
{...props}
className={`rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white ${className}`}
/>
)
}

export function RenderPropExample() {
return (
<Dialog.Root>
{/*
* render に渡した要素が、Trigger が本来描画する <button> の代わりに使われる。
* 押すと開く動作や aria-* の属性は、PrimaryButton の props として届く。
*/}
<Dialog.Trigger render={<PrimaryButton />}>お知らせ</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>お知らせ</Dialog.Title>
{/* Description が描画する <p> を <div> に替える。要素の種類だけを差し替える使い方 */}
<Dialog.Description render={<div />}>中にリストを入れたいときなどに使います。</Dialog.Description>
<Dialog.Close render={<PrimaryButton />}>閉じる</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
)
}

render に渡した要素は、パーツが本来描画する要素の代わりになります。押したら開く動作も、aria-expanded などの属性も、渡した要素の props として届きます。そのため、渡すコンポーネントは受け取った props を自分の DOM 要素へすべて渡す必要があります8。PrimaryButton が {...props} と書いているのはそのためです。

Radix UI を使ったことがあれば、asChild と同じ目的の仕組みだと考えてください。書き方が違います。asChild は子要素として渡し、render は prop として渡します。asChild の仕組みは Radix UI の Slot が扱っています。Base UI に asChild はありません。

アプリに組み込む​

最後に、アプリから使う側のコードです。

src/App.tsx
import { useState } from 'react'
import { NotificationsDialog } from './components/notifications-dialog'
import { markAllRead, type Notification } from './lib/notifications'

// 画面の確認用のサンプルデータ。実際のアプリでは API から取得した結果がここに入る
const initialNotifications: Notification[] = [
{ id: 'n1', title: 'メンテナンスを 10 月 12 日 2 時から行います', read: false },
{ id: 'n2', title: 'レポートの書き出しに CSV を追加しました', read: false },
{ id: 'n3', title: '9 月分の請求書を発行しました', read: true },
]

function App() {
// お知らせのデータは親が持つ。ダイアログの開閉の状態は持たない (Base UI の Root が持つ)
const [notifications, setNotifications] = useState(initialNotifications)

return (
<main className="mx-auto flex max-w-2xl flex-col gap-4 p-8">
<h1 className="text-2xl font-bold text-slate-900">ダッシュボード</h1>
<p className="text-slate-600">右のボタンからお知らせを確認できます。</p>
<div>
<NotificationsDialog
notifications={notifications}
onMarkAllRead={() => setNotifications(markAllRead)}
/>
</div>
</main>
)
}

export default App

App が持っている状態は、お知らせのデータだけです。ダイアログが開いているかどうかは持っていません。

状態持っている場所理由
お知らせのデータApp (親)アプリのデータだからです。ほかの画面でも使います
ダイアログの開閉Dialog.Root (Base UI)画面の一時的な状態で、ほかのどこからも参照しないからです

NotificationsDialog はデータを props で受け取り、「すべて既読にする」が押されたことを onMarkAllRead で親へ知らせます。データをどこから取ってくるか、既読にしたことをどこへ保存するかを、このコンポーネントは知りません。この形にしておくと、Storybook とテストのどちらにも、配列を 1 つ渡すだけで載せられます。次の章でそれを確かめます。

npm run dev で開発サーバーを起動すると、この画面を確かめられます。

まとめ​

  • Dialog は 8 つのパーツの組み合わせで書きます。パーツは入れ子の位置で互いを見つけ、id や aria-* を自動で結びます
  • 各パーツは className を受け取るので、Tailwind CSS のクラスを直接渡せます
  • 開閉の遷移は、data-starting-style と data-ending-style が付いている間の見た目を指定するだけで書けます。Base UI は遷移が終わるのを待ってから DOM から外します
  • 開閉の状態は Dialog.Root が持ちます。アプリの都合で動かしたいときだけ、open と onOpenChange で制御します
  • 押すと無効になるボタンは、Button の focusableWhenDisabled でフォーカスを保ちます。見た目は data-disabled で受けます
  • 描画する要素を替えたいときは render prop を使います
  • コンポーネントはデータを props で受け取り、取得や保存を知らない形にしておきます

次に読む​

Footnotes​

  1. Quick start の「Portals」と「iOS 26+ Safari」の節です。iOS 向けには、背景の幕を position: fixed ではなく absolute で敷くことも求められています。コードの Dialog.Backdrop に付けた supports-[-webkit-touch-callout:none]:absolute がその指定で、公式のコード例と同じ書き方です。 ↩

  2. 各パーツが描画する要素は、Dialog の API reference に記載があります。 ↩

  3. Styling の「CSS classes」の節です。 ↩

  4. Tailwind CSS の Preflight の「Lists are unstyled」と「Accessibility considerations」の節です。 ↩

  5. eslint-plugin-jsx-a11y の no-redundant-roles の「Rule options」の節です。要素ごとに、許可する役割を足せます。 ↩

  6. Animation に、data-starting-style と data-ending-style の使い方と、transition を勧める理由があります。Base UI は要素のアニメーションが終わったかどうかを element.getAnimations() で調べます。 ↩

  7. Button の「Loading states」の節と API reference です。公式ドキュメントは、押したあとに読み込み中になるボタンを例に、フォーカスをボタンに残すために focusableWhenDisabled を指定する、と説明しています。data-disabled も同じページにあります。Base UI 1.8 のソース (useFocusableWhenDisabled) では、この指定を付けたボタンは、無効の間、Tab キー以外のキー入力の既定の動作を止めています。 ↩

  8. Composition に、渡すコンポーネントは ref を転送し、受け取った props をすべて DOM 要素へ渡すこと、とあります。React 19 では ref が props に含まれるので、{...props} だけで両方を満たせます。 ↩