T-CREATOR

Headless UI の Dialog がスクロール固定されない/背景が動く時の対処法

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 表示時にガタつく
3iOS 特有の問題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'
/>;

背景を暗くするオーバーレイを配置します。fixedinset-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.TitleDialog.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.cssapp.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])

useEffectisOpen の変化を監視し、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>
  );
}

実装パターンの比較

以下の表で、各実装パターンの特徴を比較します。

#パターンメリットデメリット推奨度
1overflow のみシンプルで実装が簡単iOS で効かない場合がある★★★
2position: fixediOS でも確実に動作スクロール位置の復元が必要★★★★★
3カスタムフック化再利用可能で保守性が高い初期実装の工数がやや多い★★★★★
4CSS クラス制御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 でスクロール固定が効かない問題は、環境や実装方法によって発生する一般的なトラブルです。この記事では、以下の対処法を段階的にご紹介しました。

主な解決策のまとめ:

  1. 基本実装の確認 - Dialog コンポーネントの構造を正しく実装する
  2. CSS による制御 - overflow: hidden で基本的なスクロール固定を実現
  3. iOS 対策 - position: fixed とスクロール位置の保存・復元で確実に制御
  4. ガタつき対策 - スクロールバー幅を計算してパディングで調整
  5. 再利用可能な実装 - カスタムフックで複数の Dialog に対応

特に iOS Safari での動作を考慮する場合は、position: fixed を使った方法が最も確実です。カスタムフックとして実装することで、プロジェクト全体で再利用でき、保守性も向上します。

スクロール制御は、ユーザー体験を大きく左右する重要な要素ですので、環境に応じた適切な実装を選択しましょう。

この記事の内容を参考に、ぜひ快適なダイアログ体験を実現してください。

関連リンク