結論 — 「閉じ方」が決まれば要素も決まる
overlay UI を組むとき、私が真っ先に決めるのは「どうやって閉じるか」です。<dialog> は明示の確定動作で閉じる、Popover API は外側クリックで気軽に閉じる、<details name> は同じグループの別のものを開いた瞬間に閉じる。閉じ方のデザインを先に決めれば、3 つのうちどれを使うかは半分決まっています。
AetherEchoes の管理画面では、削除確認は <dialog>、タグサジェストは Popover、FAQ アコーディオンは <details name> という配り方に落ち着きました。本稿はその使い分けの判断と、Tailwind v4 のトークンに乗せて整えた書き味の記録です。コード片は React + Tailwind ですが、考え方はフレームワークによらず通用するはずです。
DeleteConfirmDialog を <dialog> に書き換えた話
AetherEchoes の DeleteConfirmDialog.tsx は最初、fixed inset-0 の div を 2 枚重ねた典型的な作りでした。isOpen を React state で持ち、if (!isOpen) return null で切り替え、バックドロップに onClick を仕込む。よく見る型です。
書き換えのきっかけは、ESC キーで閉じないことに気づいた金曜の夜でした。onKeyDown を仕込めば直りますが、開いた瞬間にフォーカスをダイアログ内へ持っていく、閉じた時に呼び出し元へフォーカスを戻す、背後をスクロール禁止にして inert にする。モーダルを真面目に作ろうとした瞬間、付随する仕事が一気に増えます。これを全部 <dialog> に任せてみたら、コンポーネントは 60 行から 35 行に減りました。
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (isOpen) el.showModal();
else el.close();
}, [isOpen]);
return (
<dialog
ref={ref}
onClose={onCancel}
className="rounded-sm border-0 p-0 bg-paper shadow-pop backdrop:bg-ink/50"
>
{/* 中身 */}
</dialog>
);
showModal() を呼ぶと top layer に乗り、ESC で閉じ、フォーカスは自動で内側に入り、閉じた時に呼び出し元へ戻ります。::backdrop 擬似要素にスタイルを当てれば、fixed inset-0 の 2 枚目の div は不要です。Tailwind の backdrop: variant が綺麗に通った瞬間、指がまず喜びました。
注意点もあります。<dialog> は border と padding のデフォルトが効いていて、reset しないと「素の HTML 感」のまま出ます。私は border-0 p-0 で潰し、外側のコンテナで余白を組み直しました。あと、onCancel(ESC 押下のイベント名)と onClose の使い分けで一度ハマっています。両方発火するので、state の同期は onClose に一本化したほうが安全です。
Popover API は何が違うのか
Popover は <dialog> の弟というより、別の生き物です。popover 属性と popovertarget 属性を貼るだけで、JS を 1 行も書かずに開閉できます。
<button popovertarget="tag-suggest">タグを選ぶ</button>
<div id="tag-suggest" popover>
<ul><!-- タグ候補 --></ul>
</div>
これも top layer に乗りますが、<dialog> と違って light dismiss(外側クリックや ESC で勝手に閉じる)が初期動作です。フォーカスも強制的には移動しません。「ユーザーが触っていない時は、いつ消えてもいい」UI、つまりタグサジェスト、ツールチップ、メニューにぴったりです。
私が <dialog> ではなく Popover を選ぶ基準は 2 つあります。第 1 に「確定動作が要らない」こと。削除確認は OK とキャンセルの 2 択を強制したいので <dialog>、タグ選択は他のところを押せば閉じるで十分なので Popover。第 2 に「親要素との位置関係が大事」かどうか。CSS Anchor Positioning と組み合わせれば、トリガーボタンに張り付くツールチップが書けます。
button { anchor-name: --tag-button; }
#tag-suggest {
position-anchor: --tag-button;
top: anchor(bottom);
left: anchor(left);
margin-top: 0.25rem;
}
Anchor Positioning はまだ Chromium 系先行ですが、フォールバックを書く余地のあるサイトであれば、Popover + Anchor の組は Floating UI を入れる前に試す価値があります。私は管理画面のタグサジェストでこれを試して、ライブラリ依存が 1 つ減ったのが小さく嬉しかったです。
<details name> で FAQ アコーディオンを相互排他にする
FAQ セクションは、複数の Q を同時に開けるか、1 つだけ開けるか。私は記事ページの FAQ では「1 つだけ」派です。複数開けると、ユーザーが「全部見たかどうか」を自分で記憶しなければならず、認知負荷が増えるからです。
これを <details> に name 属性を付けるだけで実装できるようになりました。
<details name="faq" open>
<summary>Rails 8 で Sidekiq を使い続けられますか?</summary>
<p>はい。`config.active_job.queue_adapter = :sidekiq` と明示すれば…</p>
</details>
<details name="faq">
<summary>Solid Queue との違いは?</summary>
<p>…</p>
</details>
同じ name を持つ <details> は、ラジオボタンのように相互排他になります。JS は 1 行も要りません。ARIA 属性も内部で適切に管理されます。
AetherEchoes の FAQ レンダラに入れた時に嬉しかったのは、初期表示で open を 1 つだけ付ければ「最初に開いておきたい Q」を制御できることです。アニメーションは interpolate-size と ::details-content 擬似要素の transition で組めます。
details { interpolate-size: allow-keywords; }
details::details-content {
block-size: 0;
overflow: clip;
transition: block-size 200ms ease, content-visibility 200ms allow-discrete;
}
details[open]::details-content { block-size: auto; }
ここは少し未踏感がありますが、1 ヶ月使った範囲では Safari 17 含めて崩れていません。
「閉じる」のデザインが要素ごとに違う
3 つの要素を比べて、デザインが本当に分かれるのは「閉じる」の挙動でした。
| 要素 | 閉じるトリガー | フォーカスの戻り先 | 背後の操作 |
|---|---|---|---|
<dialog showModal> | ESC、close()、form method="dialog" | 呼び出し元(自動) | 不可(inert) |
popover | 外側クリック、ESC、hidePopover() | 呼び出し元(自動) | 可 |
<details name> | 同じ name の別 details が開く | summary に残る | 可 |
「ユーザーが何を確定したいか」で閉じ方は変わります。<dialog> は「決めて閉じる」、Popover は「見て閉じる」、<details> は「読んで閉じる」。テキストにすると当たり前ですが、HTML 要素を選ぶ前にこの 3 行を頭で唱える癖がつくと、ライブラリを呼ぶ前に手が止まります。
トークンを overlay にどう適用するか
Tailwind v4 の @theme で組んだ paper / ink / rule の semantic トークンは、overlay にもそのまま当たります。<dialog> の中身は bg-paper text-ink border border-rule、::backdrop は bg-ink/50。Popover は bg-paper-alt shadow-pop、<details> の summary は bg-paper-sunken hover:bg-paper-alt。
過去記事 B+A Hybrid デザインシステム採択の記録 で書いた「Reader と Admin で別の prose」と同じ考え方で、overlay も「Reader 系(記事内 FAQ)」と「Admin 系(モーダル)」で別の影と角丸を使い分けています。Reader は shadow-paper-soft の浅い影、Admin は shadow-pop の少し強い影。角丸はどちらも rounded-sm で揃え、視覚言語の連続性を保ちます。
色はトークン経由で当てるので、ダークモードでも反転だけで済みます。これは トークン反転だけでダークモードを実装する の続きとして読んでもらえると流れがつながります。::backdrop を bg-ink/50 で書いておけば、ダークモードでは ink が paper 寄りになって自然に半透明の白幕に切り替わる。トークンの嬉しさが overlay でこそ効いてきます。
使い分けの決定木
最後に、私が overlay を新しく組むときにたどる順番を書いておきます。
- ユーザーに「決定」を求めるか? → Yes なら
<dialog>。 - トリガー要素に張り付けたいか? → Yes なら Popover + Anchor Positioning。
- 同じグループのうち 1 つだけ開いていればよいか? → Yes なら
<details name>。 - いずれでもない → JS で素直に書く。
この順で 1 つ前から試し、ライブラリを足すのは最後の手段にする。Radix Dialog や react-modal が悪いという話ではなくて、ネイティブで足りるなら 30 KB を払わずに済む、という単純な経済の話です。
余談ですが、最初に <dialog> の採用を決めた日は、私が Floating UI の version migration ガイドを読むのを諦めた金曜の夜でした。技術選定は、たまに体力で決まります。
まとめ
<dialog> は決定、Popover は閲覧、<details name> は読了。閉じ方のデザインを先に決めれば、3 つの使い分けはほぼ自動で決まります。ネイティブで組めば、Tailwind のトークンも、ダークモードも、フォーカス制御も、ほとんど無料で付いてくる。30 KB と一緒に「自分で UX を握れる感覚」も手元に残ります。
Tags
よくある質問
- `<dialog>` の `::backdrop` に Tailwind のクラスを当てられますか?
- はい。Tailwind v4 では `backdrop:` variant が用意されています。例えば `backdrop:bg-ink/50` と書けば、`::backdrop` 擬似要素に半透明の幕を当てられます。色はトークン経由にしておくとダークモードでも反転だけで済みます。
- Popover API はどのブラウザで使えますか?
- Chrome 114+、Edge 114+、Firefox 125+、Safari 17+ で標準サポートされています。古いブラウザを切れない場合は、`HTMLElement.prototype.togglePopover` の存在チェックで分岐するのが手堅いです。
- `<details name>` のアニメーションは Safari でも動きますか?
- `interpolate-size: allow-keywords` と `::details-content` 擬似要素のセットは Chromium 系で先行実装され、Safari 17.4 以降でも動きます。Firefox は段階的サポート中なので、サポート外の環境では即時開閉にフォールバックします。
- `<dialog>` の中で React の状態管理はどう書けばよいですか?
- `useRef<HTMLDialogElement>` を持ち、`useEffect` で `isOpen` の変化に応じて `showModal()` と `close()` を呼ぶのが素直です。`onClose` で React state を閉じる方向に同期しておけば、ESC キーや form submit との整合が取れます。
- Anchor Positioning が未対応のブラウザでは Popover はどう見えますか?
- Popover 自体は表示されますが、位置指定が当たらず、フローに従って配置されます。実用ではフォールバックとして `position: absolute` と `transform` で大体の位置を当て、Anchor Positioning が効くブラウザだけより精密に揃える、という二段構成が無難です。