1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

モーダルの 背景部分 なぜ動く

1
Last updated at Posted at 2026-02-26

環境

  • React
  • Sass

※結果的にHTMLとCSSの問題だったので、環境は試行錯誤時に言及がある程度です。

現象

htmlの<dialog>でモーダルを開いたときに、開いた瞬間に背景(本体)の表示位置がページトップに戻る現象が発生。

結論

/* css */
dialog {
  position: fixed;
}

これだけ。

もうちょっと詳細

結論のCSSを適用しているHTML側の構造は以下の通り。

<!-- html -->
<main>
  <div class="contents">本体</div>
  <button>開く</button>
</main>
<dialog>
  <button>閉じる</button>
  <div class="modal"></div>
</dialog>

発生原因

AIと協業で解決したので、ついでに原因を確認してみました。

  • relative: 「背景のレイアウトの一部」として扱われる⇒背景とレイアウト計算を共有していた可能性があり、結果一旦トップに戻される
  • fixed: 背景とは別の異次元(レイヤー)」になる⇒背景を一切いじらず、スクロール位置も守れる

もう少し詳しい解説

AIの説明から転記。

例:行列のできるラーメン屋さん(背景のリスト)」 に並んでいる

relative が良くない理由
  • relative「列に割り込む」 状態
    position: relative は、どれだけ浮いて見えても、ブラウザから見ると 「列の中に自分の居場所(隙間)を確保している」 状態

  • モーダルが開く瞬間のパニック!
    モーダルが開いて背景が overflow: hidden になった瞬間、ブラウザは「えっ、列の並び順が変わる!?今のスクロール位置、どこだか分からなくなった!」ってパニックを起こして、とりあえず 一番上(0地点)まで戻る

fixed が決定打だった理由
  • fixed は「空を飛ぶ」状態
    position: fixed にすると、ブラウザに対して 「自分(<dialog>)は列には並んでないです。空中に浮いてるから、下の列のことは気にしないです」 宣言を行うことになる
  • ブラウザが安心する
    モーダルが「空を飛んでいる」から、「下のラーメン屋さんの行列(背景リスト)」に全く影響を与えない。だから、ブラウザさんも「あ、下の列はそのままでOKね」と安心して、スクロール位置をキープしてくれるようになった

所感

position: fixed;なんて当然じゃん」と思われるかと思いますが、当初は閉じるボタンの位置を固定するのにposition: relative;を設定してました。これが敗因。
動くのは背景の方だったので、そっちを固定しよう固定しようと試行錯誤しましたが、結果的には<dialog>だったというオチ。

とはいえ、この過程で不要なリレンダリング処理が結構見つかってコードを整備できたので怪我の功名な個所もありました。
ポートフォリオで作ってるReactとはいえ、「これってこう書いてもいいんだ」と知見が溜まったのは良かったです。

なおモーダルオープン時に背景がうっかりスクロールされないようにするには、CSSの上流部分に

body:has(dialog[open]) {
  overflow: hidden;
}

を設定したおいたら「表示部分以外は隠れてる=スクロール不可」になりました。

2026/2/27時点、body:has(dialog[open])は比較的新しいCSSセレクタです。
対応状況はブラウザのバージョンに依存するため、実務利用時は要確認。
overflow: hiddenをjsで行うのが無難っちゃ無難です。

蛇足

ここから先はことの経緯と上記の結論にたどり着くまでの過程です。

試行錯誤の過程

現象発生時

技術記事を探してもドンピシャな記事がなかなか見つからず、AIと協業しても数時間ハマったので当記事を書いています。

関連すると想定されるコードを投げて「どうしたら想定通りの挙動になるか?」を一度詰めました。(当時は無課金Geminiユーザー)
それで一旦は解消したものの、プロジェクト全体のコードをテコ入れした際に再発(モグラ叩きか?)

再発したため、その時は導入していたAntigravity(Gemini 3 Flash)で、プロジェクト全体を走査して関係のありそうな箇所を片っ端から確認・修正の試行錯誤を行いました。
(もっとコーディングに向いてるモデルもありますが、無課金ユーザーのため現状前述のモデルを使ってます)

以下、効果はなかったものの実験してみた内容を参考までに列挙していきます。

効果がなかった対処

  1. 背景部分がアンマウントされない工夫
    Reactで記述していたので、モーダル関連の情報を読み込み中に、一旦ローディング画面を出してました。
    そのローディング画面を出す際に一瞬背景部分もアンマウント(消える)⇒マウント(再表示)が発生しているのでは……?疑惑が浮上。
    結論としてはログも仕込んで確認した上で、背景の表示には一切影響なし。

  2. モーダルオープン時の自動フォーカス設定
    モーダル(<dialog>)を開くときに記述するjs、dialogRef.current.showModal();が発火した際に、フォーカスが「<dialog>内に最初に出てくるbutton」に自動で移動する仕様。
    この仕様の影響では疑惑が持ち上がり、dialogRef.current.focus({ preventScroll: true });を組み込む提案がAIから出ました。
    が、既に組み込んでいたので背景が飛んじゃう対策にはなりえず。
    勝手に閉じるボタンにフォーカスが移動するのは避けたかったので、そちらの意図では組み込んだままのコードですが、背景移動の対策としては効果なしでした。

  3. モーダルオープン時の背景固定

// モーダル画面のtsxファイル

useEffect(() => {
  if (dialogRef.current && !dialogRef.current.open) {
    // 扉を開ける魔法
    dialogRef.current.showModal();
    dialogRef.current.focus({ preventScroll: true });

    // jsで背景のスタイルをhiddenにする
    document.body.style.overflow = 'hidden';
  }

  // クリーンアップ(モーダルが消える時に自動でhiddenを解除)
  return () => {
    document.body.style.overflow = 'auto';
  };
}, []); // 最初の1回だけ

こちらも効果なし。これで効くなら、前述の

body:has(dialog[open]) {
  overflow: hidden;
}

でケリが着いてますね。

4. bodyの高さ

この時点で土台部分のCSSに以下の設定をしていました。(抜粋)

body {
  height: 100%;
}

/* モーダルが開いている時、bodyをスクロール不可 */
body:has(dialog[open]) {
  overflow: hidden;
}

これ対し、AIが提起してきた疑惑箇所は以下の通りでした。(転記)

  1. モーダルが開いて bodyoverflow: hidden が付与される(設定済み箇所)

  2. bodyheight: 100%(画面の高さピッタリ)に固定される

  3. この時、ブラウザが
    「あ!body の高さが画面サイズになっちゃった。下にスクロールしてた分は、もう存在しない場所だから、スクロール位置を 0 に戻しておくね!」
    と「親切心」で勝手に位置をリセット

  4. 対策としてheight: 100%;からmin-height: 100vh;に変更し、「固定100%」から「最低値100%」に変更する

ダメでした(効果なし)

5. jsでの位置固定

モーダルが開くときに「変数isLocktrueに変更し、その瞬間の背景位置を取得し動かないようにする。モーダルクローズ時にロックを解除して再度自由にスクロールする」という原理。
テコ入れする前はこれで問題は一旦解消してました(そしてテコ入れで再発生)

再発生したので結果的に効果はなかったですが、誰かの何かの参考になればいいなと思い概要を記載しておきます。
「★」を付けている箇所が関連コードです。

/* tsx */

// 設定

// 背景スクロール固定用ステート
// 初期値をtrueにすることでマウント時の同期setStateを回避
const [isLocked, setIsLocked] = useState(true); // ★

/**
 * 背景スクロールロックの適用
 * useScrollLock(isLocked) により、isLockedがtrueの間bodyをfixedにする。
 * 解除時にwindow.scrollToで元の位置に戻る前提の実装。
 */
const scrollPosRef = useRef(0); // ★
useScrollLock(isLocked, scrollPosRef); // ★

/**
 * モーダルを閉じる共通処理
 * setIsOpen(false) を呼ぶ⇒ useScrollLock 内部の解除ロジック(位置復元)を発火
 */
const handleClose = () => {
  if (dialogRef.current?.open) {
    dialogRef.current.close();
  }
  setIsLocked(false); // ★useScrollLock内のscrollToが発火し元の位置に戻る

  // モーダルが消える瞬間のガタつきを抑える
  requestAnimationFrame(() => {
    onClose();
  });
};

// useImperativeHandle で 親が子の内部メソッドを呼び出せる
useImperativeHandle(ref, () => ({
  // モーダルを開く
  showModal: () => {
    // スクロール位置を確定させてからロックをかける
    setIsLocked(true); // ★
  },
  // モーダルを閉じる
  closeModal: () => {
    handleClose();
  },
}));

/**
 * モーダル起動制御
 * preventScroll: true を使用して初回トップ戻りを防止
 */
useEffect(() => {
  // ★条件式のisLocked
  if (isLocked && dialogRef.current && !dialogRef.current.open) {
    dialogRef.current.showModal();
    // フォーカスによる自動スクロール移動を防止
    dialogRef.current.focus({ preventScroll: true });
  }
}, []);

// ★モーダル背景のスクロールをロックするカスタムフック
/*** @name fetchDetails
 *   @function
 *   @param isLocked:boolean 表示対象基礎データ
 *   @return void
 * ※AIに書き出してもらった関数
 */
export function useScrollLock(isLocked: boolean, scrollPosRef: RefObject<number>): void {
  useLayoutEffect(() => {
    if (typeof document === 'undefined') return;
    const { body, documentElement } = document;

    if (isLocked) {
      // 1. スタイル適用前に現在の位置を確定
      const scrollY = window.pageYOffset || documentElement.scrollTop;
      (scrollPosRef as React.RefObject<number>).current = scrollY;

      const scrollBarWidth = window.innerWidth - documentElement.clientWidth;

      // 2. bodyを固定
      body.style.top = `-${scrollY}px`;
      body.style.position = 'fixed';
      body.style.left = '0';
      body.style.width = '100%';
      body.style.paddingRight = `${scrollBarWidth}px`;
      body.style.overflowY = 'hidden';
    } else {
      // 3. 解除
      const scrollY = scrollPosRef.current;

      body.style.position = '';
      body.style.top = '';
      body.style.left = '';
      body.style.width = '';
      body.style.paddingRight = '';
      body.style.overflowY = '';

      // 4. 保存していた位置へ復元
      if (scrollY !== undefined && scrollY > 0) {
        window.scrollTo(0, scrollY);
      }
    }

    return () => {
      body.style.position = '';
      body.style.top = '';
      body.style.left = '';
      body.style.width = '';
      body.style.paddingRight = '';
      body.style.overflowY = '';
    };
  }, [isLocked]);
}

更に蛇足

投稿前に、この記事を今度はchatGPTにチェックさせてみたところ、

<dialog> はデフォルトで position: absolute; 相当の扱いになるブラウザがある

と指摘を受けたのでもうちょい深堀しました。
ざっと探した感じ、こちらの記事が探している情報に一番近かったです。

ついでにAntigravity(Gemini 3 Flash)にも訊いてみたところ、今回の現象と絡めて説明してくれたので、そちらも転載します。

absolute:初期値

  • absolute は「書類(Page)」に付随
  • absolute(絶対配置)は、一見浮いているように見えるけど、実は 「書類全体の高さや幅」 の影響を受ける
    • モーダルが開いて背景を overflow: hidden にした時、ブラウザが「書類のサイズが変わった」と判断することがある
    • absolute な要素も一緒に「再計算」に巻き込まれてしまう(React系列)
    • ⇒結果として背景のスクロール位置を巻き込んでリセットしてしまうことがある

fixed:今回の最適解

  • fixed は「画面(Screen)=ユーザーが見ている画面の枠」に付随
  • 書類(page)がどうなろうと 「ユーザーが見ている画面の枠」 だけを基準にする
    • 背景の書類(page)がリサイズされようが、スクロール不可になろうが、fixed は「自分は画面(screen)に張り付いてるから関係ない」と完全に無視
    • 「背景レイアウトとの完全な切り離し」 が、スクロール位置を守る

つまり

  • 元々設定してた relative は「行列の中にいる」状態、デフォルトの absolute は「行列のすぐ横で浮いてる(けど紐で繋がってる)」状態
  • relativeabsoluteはページレイアウトと何らかの形で結びついているため、再計算時に影響を受ける可能性有
    ⇒fixed(紐を切って完全に空中浮遊) に変えると、背景の行列の影響を受けない!

参考にした記事

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?