Headless UI の Dialog がスクロール固定されない/背景が動く時の対処法
Headless UI の Dialog コンポーネントを使っていると、「ダイアログを開いたのに背景がスクロールしてしまう」という問題に遭遇することがあります。モーダルダイアログは本来、背景のスクロールを固定してユーザーの注意を集中させるべきですが、うまく動作しないケースがあるのです。
この記事では、Headless UI の Dialog で背景スクロールが固定されない原因と、段階的な対処法を詳しく解説します。実際のコード例とともに、トラブルシューティングの手順をご紹介しますので、同じ問題でお困りの方はぜひ参考にしてください。
背景
Headless UI の Dialog とスクロール制御の仕組み
Headless UI は、アクセシビリティに配慮された UI コンポーネントライブラリで、スタイルを持たない「ヘッドレス」な設計が特徴です。Dialog コンポーネントは、モーダルやダイアログの実装を簡単にしてくれる便利な機能を提供します。
モーダルダイアログを表示する際、一般的には以下の動作が期待されます。
- ダイアログが開いている間、背景のスクロールを無効化する
- ユーザーの操作をダイアログ内に限定する
Escキーでダイアログを閉じられるようにする- フォーカスをダイアログ内にトラップする
Headless UI の Dialog は、これらの機能を自動的に提供してくれますが、環境や実装方法によっては期待通りに動作しないことがあるのです。
以下の図は、Dialog 表示時の基本的なフローを示しています。
mermaidflowchart TD
open["Dialog 開く"] --> check["スクロール制御<br/>チェック"]
check --> add["body に<br/>overflow: hidden 付与"]
add --> show["Dialog 表示"]
show --> trap["フォーカストラップ<br/>有効化"]
trap --> wait["ユーザー操作待ち"]
wait --> close["Dialog 閉じる"]
close --> remove["overflow: hidden<br/>削除"]
remove --> restore["スクロール位置<br/>復元"]
図で理解できる要点:
- Dialog 開閉時に body 要素のスタイル制御が行われる
- overflow プロパティの付与と削除でスクロールを制御
- スクロール位置の保存と復元も自動的に処理される
スクロール固定が必要な理由
モーダルダイアログで背景のスクロールを固定する理由は、ユーザー体験の向上にあります。
| # | 理由 | 説明 |
|---|---|---|
| 1 | 注意の集中 | ユーザーの視線と操作をダイアログに集中させる |
| 2 | 誤操作の防止 | 背景のスクロールによる意図しない操作を防ぐ |
| 3 | モバイル対応 | タッチデバイスでのスクロール挙動を適切に制御 |
| 4 | アクセシビリティ | スクリーンリーダー使用時の混乱を避ける |
背景がスクロールしてしまうと、ユーザーは「ダイアログが表示されているのか、通常の画面なのか」を混同してしまい、操作性が大きく損なわれます。
課題
発生する問題の具体例
Headless UI の Dialog を実装したとき、以下のような問題が発生することがあります。
問題 1: Dialog を開いても背景がスクロールする
typescript// 問題のあるコード例
import { Dialog } from '@headlessui/react';
function MyDialog() {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onClose={() => setIsOpen(false)}>
<Dialog.Panel>{/* ダイアログの内容 */}</Dialog.Panel>
</Dialog>
);
}
このコードでは、Dialog を開いても背景ページがスクロールできてしまいます。
問題 2: スクロールバーが消えてレイアウトがずれる
スクロール固定が効いた場合でも、スクロールバーの分だけレイアウトがずれてガタつくことがあります。これは overflow: hidden によってスクロールバーが消えるためです。
問題 3: iOS Safari で効果がない
特に iOS の Safari では、overflow: hidden だけでは背景のスクロールを完全に防げないケースが報告されています。
以下の図は、問題が発生する構造を示しています。
mermaidflowchart LR
user["ユーザー"] -->|スクロール操作| page["背景ページ"]
page -.->|期待: 固定| fixed["スクロール無効"]
page -->|実際: 動く| scroll["スクロール発生"]
dialog["Dialog<br/>コンポーネント"] -.->|制御失敗| page
scroll --> problem1["レイアウト崩れ"]
scroll --> problem2["UX 低下"]
scroll --> problem3["誤操作"]
図で理解できる要点:
- Dialog の制御が背景ページに適切に伝わっていない
- スクロール操作が意図せず背景に影響している
- 結果として複数の問題が連鎖的に発生する
エラーの分類と発生条件
| # | エラータイプ | 発生条件 | 症状 |
|---|---|---|---|
| 1 | スクロール制御失敗 | CSS の優先度が低い、!important の衝突 | 背景がスクロール可能なまま |
| 2 | レイアウトシフト | スクロールバー分の幅調整なし | Dialog 表示時にガタつく |
| 3 | iOS 特有の問題 | position: fixed が効かない | タッチスクロールが止まらない |
| 4 | ネストされた Dialog | 複数の Dialog が重なる | 最前面以外の Dialog も反応 |
これらの問題は、環境や実装方法によって組み合わさって発生することがあり、原因の特定が難しい場合もあります。
解決策
基本的な対処法:Dialog の正しい実装
まず、Headless UI の Dialog を正しく実装することが重要です。以下の手順で段階的に確認しましょう。
ステップ 1: 必要なパッケージのインストール
Headless UI と React が正しくインストールされているか確認します。
bashyarn add @headlessui/react
ステップ 2: Dialog の基本構造を確認
Dialog コンポーネントは、以下の構造で実装する必要があります。
typescriptimport { Dialog } from '@headlessui/react';
import { useState } from 'react';
まず、必要なモジュールをインポートします。Dialog コンポーネントと、状態管理のための useState フックが必要です。
typescriptfunction MyDialog() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
{/* Dialog を開くボタン */}
<button onClick={() => setIsOpen(true)}>
ダイアログを開く
</button>
ダイアログの開閉状態を管理するステートと、開くためのトリガーボタンを用意します。
typescript {/* Dialog コンポーネント */}
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
open プロパティで表示状態を制御し、onClose で閉じる処理を設定します。className で z-index を指定し、他の要素より前面に表示されるようにしましょう。
typescript{
/* 背景オーバーレイ */
}
<div
className='fixed inset-0 bg-black/30'
aria-hidden='true'
/>;
背景を暗くするオーバーレイを配置します。fixed と inset-0 で画面全体を覆い、bg-black/30 で半透明の黒色を適用します。
typescript {/* Dialog の配置コンテナ */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-sm rounded bg-white p-6">
Dialog.Panel は、実際のダイアログコンテンツを格納するコンテナです。中央配置のための親 div で囲み、スタイリングを適用します。
typescript <Dialog.Title className="text-lg font-bold">
タイトル
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
ダイアログの説明文がここに入ります。
</Dialog.Description>
Dialog.Title と Dialog.Description を使うことで、アクセシビリティが向上します。スクリーンリーダーが適切にコンテンツを読み上げてくれます。
typescript <button
onClick={() => setIsOpen(false)}
className="mt-4 rounded bg-blue-500 px-4 py-2 text-white"
>
閉じる
</button>
</Dialog.Panel>
</div>
</Dialog>
</>
)
}
閉じるボタンを配置して、Dialog の基本構造が完成です。
CSS による強制的なスクロール固定
基本実装でも解決しない場合は、CSS でスクロールを強制的に固定する方法があります。
グローバル CSS の追加
プロジェクトのグローバル CSS ファイル(globals.css や app.css など)に以下を追加します。
css/* Dialog が開いているときの body 固定 */
body.dialog-open {
overflow: hidden;
/* iOS Safari 対策 */
position: fixed;
width: 100%;
}
overflow: hidden で基本的なスクロール制御を行い、position: fixed で iOS Safari の問題に対処します。width: 100% でレイアウト崩れを防ぎます。
React でクラスを動的に追加
Dialog の開閉に合わせて、body 要素にクラスを追加・削除します。
typescriptimport { useEffect } from 'react';
副作用フックの useEffect をインポートします。
typescriptfunction MyDialog() {
const [isOpen, setIsOpen] = useState(false)
// Dialog の開閉に応じて body のクラスを制御
useEffect(() => {
if (isOpen) {
// Dialog 開いたとき
document.body.classList.add('dialog-open')
} else {
// Dialog 閉じたとき
document.body.classList.remove('dialog-open')
}
// クリーンアップ関数
return () => {
document.body.classList.remove('dialog-open')
}
}, [isOpen])
useEffect で isOpen の変化を監視し、body 要素のクラスを動的に追加・削除します。クリーンアップ関数で、コンポーネントのアンマウント時にもクラスが確実に削除されます。
スクロールバーのガタつき対策
スクロールバーが消えることでレイアウトがずれる問題には、以下の対処が有効です。
css/* スクロールバー分のパディングを追加 */
body.dialog-open {
overflow: hidden;
padding-right: var(--scrollbar-width, 0px);
}
CSS カスタムプロパティを使って、スクロールバーの幅分だけパディングを追加します。
typescriptuseEffect(() => {
if (isOpen) {
// スクロールバーの幅を計算
const scrollbarWidth =
window.innerWidth -
document.documentElement.clientWidth;
// CSS 変数に設定
document.documentElement.style.setProperty(
'--scrollbar-width',
`${scrollbarWidth}px`
);
document.body.classList.add('dialog-open');
} else {
document.body.classList.remove('dialog-open');
document.documentElement.style.removeProperty(
'--scrollbar-width'
);
}
return () => {
document.body.classList.remove('dialog-open');
document.documentElement.style.removeProperty(
'--scrollbar-width'
);
};
}, [isOpen]);
JavaScript でスクロールバーの幅を動的に計算し、CSS カスタムプロパティに設定します。これにより、環境に応じた正確な幅調整が可能になります。
iOS Safari 特有の問題への対処
iOS Safari では、position: fixed だけでは不十分な場合があります。
typescriptuseEffect(() => {
if (isOpen) {
// 現在のスクロール位置を保存
const scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
} else {
// スクロール位置を復元
const scrollY = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);
}
}, [isOpen]);
スクロール位置を保存してから position: fixed を適用し、閉じるときに元の位置に復元します。これにより、iOS でも確実にスクロールが固定されます。
具体例
パターン 1: シンプルな実装(Tailwind CSS 使用)
最もシンプルで推奨される実装パターンをご紹介します。
typescript// components/SimpleDialog.tsx
import { Dialog } from '@headlessui/react';
import { useState, useEffect } from 'react';
typescriptexport default function SimpleDialog() {
const [isOpen, setIsOpen] = useState(false)
// スクロール固定の制御
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
最小限のコードでスクロール固定を実現します。overflow プロパティを直接操作するシンプルな方法です。
typescript return (
<div className="p-8">
<button
onClick={() => setIsOpen(true)}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
ダイアログを開く
</button>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
{/* 背景オーバーレイ */}
<div className="fixed inset-0 bg-black/50" aria-hidden="true" />
{/* 中央配置コンテナ */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<Dialog.Title className="text-xl font-bold text-gray-900">
確認ダイアログ
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-600">
この操作を実行してもよろしいですか?
</Dialog.Description>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setIsOpen(false)}
className="rounded bg-gray-200 px-4 py-2 text-gray-800 hover:bg-gray-300"
>
キャンセル
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
実行
</button>
</div>
</Dialog.Panel>
</div>
</Dialog>
</div>
)
}
パターン 2: カスタムフックで再利用可能に
スクロール制御のロジックをカスタムフックとして切り出すと、複数の Dialog で再利用できます。
typescript// hooks/useScrollLock.ts
import { useEffect } from 'react';
typescript/**
* Dialog 開閉時にスクロールをロックするカスタムフック
* @param isLocked - スクロールをロックするかどうか
*/
export function useScrollLock(isLocked: boolean) {
useEffect(() => {
if (!isLocked) return;
// 現在のスクロール位置を保存
const scrollY = window.scrollY;
const scrollbarWidth =
window.innerWidth -
document.documentElement.clientWidth;
// スクロール固定を適用
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
document.body.style.paddingRight = `${scrollbarWidth}px`;
// クリーンアップ: スクロール位置を復元
return () => {
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
document.body.style.paddingRight = '';
window.scrollTo(0, scrollY);
};
}, [isLocked]);
}
このカスタムフックは、スクロール位置の保存、固定、復元をすべて処理します。
typescript// components/AdvancedDialog.tsx
import { Dialog } from '@headlessui/react';
import { useState } from 'react';
import { useScrollLock } from '@/hooks/useScrollLock';
typescriptexport default function AdvancedDialog() {
const [isOpen, setIsOpen] = useState(false);
// カスタムフックでスクロールロック
useScrollLock(isOpen);
return (
<div className='p-8'>
<button
onClick={() => setIsOpen(true)}
className='rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-700'
>
高度なダイアログを開く
</button>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className='relative z-50'
>
<div
className='fixed inset-0 bg-black/60'
aria-hidden='true'
/>
<div className='fixed inset-0 flex items-center justify-center p-4'>
<Dialog.Panel className='w-full max-w-lg rounded-lg bg-white p-8 shadow-2xl'>
<Dialog.Title className='text-2xl font-bold text-gray-900'>
フォーム入力ダイアログ
</Dialog.Title>
<div className='mt-4 space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700'>
お名前
</label>
<input
type='text'
className='mt-1 block w-full rounded border border-gray-300 px-3 py-2'
placeholder='山田太郎'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700'>
メールアドレス
</label>
<input
type='email'
className='mt-1 block w-full rounded border border-gray-300 px-3 py-2'
placeholder='example@example.com'
/>
</div>
</div>
<div className='mt-6 flex justify-end gap-3'>
<button
onClick={() => setIsOpen(false)}
className='rounded bg-gray-200 px-5 py-2 text-gray-800 hover:bg-gray-300'
>
キャンセル
</button>
<button
onClick={() => setIsOpen(false)}
className='rounded bg-purple-600 px-5 py-2 text-white hover:bg-purple-700'
>
送信
</button>
</div>
</Dialog.Panel>
</div>
</Dialog>
</div>
);
}
実装パターンの比較
以下の表で、各実装パターンの特徴を比較します。
| # | パターン | メリット | デメリット | 推奨度 |
|---|---|---|---|---|
| 1 | overflow のみ | シンプルで実装が簡単 | iOS で効かない場合がある | ★★★ |
| 2 | position: fixed | iOS でも確実に動作 | スクロール位置の復元が必要 | ★★★★★ |
| 3 | カスタムフック化 | 再利用可能で保守性が高い | 初期実装の工数がやや多い | ★★★★★ |
| 4 | CSS クラス制御 | CSS との分離がしやすい | グローバル CSS が必要 | ★★★★ |
以下の図は、各パターンの実装フローを比較したものです。
mermaidflowchart TD
start["Dialog 開く"] --> choice{"実装パターン"}
choice -->|パターン1| overflow["overflow: hidden<br />設定"]
choice -->|パターン2| fixed["position: fixed<br />+ スクロール位置保存"]
choice -->|パターン3| hook["useScrollLock<br />フック呼び出し"]
overflow --> result1["シンプルだが<br />環境依存あり"]
fixed --> result2["確実だが<br />実装やや複雑"]
hook --> result3["再利用可能で<br />保守性高い"]
result1 --> dialogEnd["Dialog 表示"]
result2 --> dialogEnd
result3 --> dialogEnd
図で理解できる要点:
- 各パターンにはメリット・デメリットがある
- プロジェクトの規模や要件に応じて選択する
- カスタムフック化が最も保守性が高い
まとめ
Headless UI の Dialog でスクロール固定が効かない問題は、環境や実装方法によって発生する一般的なトラブルです。この記事では、以下の対処法を段階的にご紹介しました。
主な解決策のまとめ:
- 基本実装の確認 - Dialog コンポーネントの構造を正しく実装する
- CSS による制御 -
overflow: hiddenで基本的なスクロール固定を実現 - iOS 対策 -
position: fixedとスクロール位置の保存・復元で確実に制御 - ガタつき対策 - スクロールバー幅を計算してパディングで調整
- 再利用可能な実装 - カスタムフックで複数の Dialog に対応
特に iOS Safari での動作を考慮する場合は、position: fixed を使った方法が最も確実です。カスタムフックとして実装することで、プロジェクト全体で再利用でき、保守性も向上します。
スクロール制御は、ユーザー体験を大きく左右する重要な要素ですので、環境に応じた適切な実装を選択しましょう。
この記事の内容を参考に、ぜひ快適なダイアログ体験を実現してください。
関連リンク
articleHeadless UI の Dialog がスクロール固定されない/背景が動く時の対処法
articleHeadless UI とは?コンポーネントの中身だけで柔軟な UI を実現する仕組みを徹底解説
articleWebSocket Close コード早見表:正常終了・プロトコル違反・ポリシー違反の実務対応
articleStorybook 品質ゲート運用:Lighthouse/A11y/ビジュアル差分を PR で自動承認
articleWebRTC で高精細 1080p/4K 画面共有:contentHint「detail」と DPI 最適化
articleSolidJS フォーム設計の最適解:コントロール vs アンコントロールドの棲み分け
articleWebLLM 使い方入門:チャット UI を 100 行で実装するハンズオン
articleShell Script と Ansible/Make/Taskfile の比較:小規模自動化の最適解を検証
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来