jQuery / スクロールしても上部に固定されるメニューを作る

文書のスクロール位置がメニューの元の位置に達したらposition:fixedのクラスを有効にし、上へ戻れば外します。通常の流れから抜けるため、元の場所に同じ高さを残して本文が急に上へ移らないようにします。

安定した基準と場所の確保

CSS position:fixedは通常フローから抜け、一般に画面基準で配置します。top:0は画面上端です。元の場所は文書座標なので比較する座標系をそろえます。

固定後のメニュー自体を元の場所として測り直しません。文書に残る#menu-slotの画面topとwindow.scrollYを加え、文書基準の境界を計算します。

固定すると親の幅を同じように継承しない場合があります。slotからleft・widthを設定し、メニューの境界込み高さを残します。例はmarginなし、border-boxで基準を合わせます。

スクロールとサイズ変更の完全な例

全コードをUTF-8のfixed-menu-demo.htmlとして保存し開きます。インターネットから公式jQueryファイル4.0.0を読みます。サーバーや導入は不要です。境界、本文位置、幅変更、アンカーをWindows Edge、3.7.1・4.0.0で確認しました。

ヘッダーは180pxなので初期境界は文書の180です。下部操作と状態表示は実習用で、実サイトへは必要な機能だけ使います。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>A menu fixed after its original position</title>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
  <style>
    :root { --menu-height: 60px; }
    html { scroll-behavior: auto; }
    body { margin: 0; font: 16px/1.5 sans-serif; }
    header { height: 180px; display: grid; place-items: center; background: #e7f2f2; }
    .wrap { width: 100%; max-width: 800px; padding: 0 20px; margin: 0 auto; box-sizing: border-box; }
    #menu { box-sizing: border-box; width: 100%; display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; border: 2px solid teal; background: #fff; }
    #menu a { padding: 4px; color: #075c55; }
    #menu.is-fixed { position: fixed; top: 0; z-index: 20; box-shadow: 0 3px 8px #0003; }
    main section { min-height: 650px; padding-top: 24px; }
    main h2 { margin: 0; scroll-margin-top: calc(var(--menu-height) + 16px); }
    #controls { position: fixed; right: 8px; bottom: 8px; z-index: 30; background: #fff; padding: 4px; }
    button { font: inherit; margin: 2px; }
    #result { position: fixed; left: 8px; bottom: 70px; background: #fff; white-space: pre-wrap; padding: 6px; z-index: 30; }
  </style>
</head>
<body>
  <header>Header before the menu</header>
  <div class="wrap"><div id="menu-slot">
    <nav id="menu" aria-label="Sections"><a href="#alpha">Alpha section</a><a href="#beta">Beta section</a><a href="#gamma">Gamma section</a></nav>
  </div></div>
  <main class="wrap">
    <section><h2 id="alpha">Alpha</h2><p>First section content.</p></section>
    <section><h2 id="beta">Beta</h2><p>Second section content.</p></section>
    <section><h2 id="gamma">Gamma</h2><p>Third section content.</p></section>
  </main>
  <div id="controls"><button type="button" id="down">Go to 300px</button><button type="button" id="reset">Back to 0px</button></div>
  <pre id="result" aria-live="polite"></pre>
  <script>
    $(function () {
      const $menu = $("#menu"), $slot = $("#menu-slot");
      let scheduled = false;
      function update() {
        scheduled = false;
        const rect = $slot[0].getBoundingClientRect();
        const threshold = rect.top + window.scrollY;
        const fixed = $(window).scrollTop() >= threshold;
        $menu.toggleClass("is-fixed", fixed).css({
          left: fixed ? rect.left + "px" : "",
          width: fixed ? rect.width + "px" : ""
        });
        const height = $menu.outerHeight();
        $slot.css("height", height + "px");
        document.documentElement.style.setProperty("--menu-height", height + "px");
        $("#result").text(`menu=${fixed ? "fixed" : "flow"}\nthreshold=${Math.round(threshold)}\nscrollY=${Math.round(window.scrollY)}`);
      }
      function schedule() {
        if (!scheduled) { scheduled = true; window.requestAnimationFrame(update); }
      }
      $(window).on("scroll.fixedMenuLesson resize.fixedMenuLesson load.fixedMenuLesson", schedule);
      $("#down").on("click", function () { window.scrollTo(0, 300); schedule(); });
      $("#reset").on("click", function () { window.scrollTo(0, 0); schedule(); });
      update();
    });
  </script>
</body>
</html>

先頭で開いた初期結果です。flowは通常配置です。

menu=flow
threshold=180
scrollY=0
  1. ゆっくり移動します。179pxでは通常配置、180pxから画面top=0へ固定します。
  2. Go to 300pxでmenu=fixed、threshold=180、scrollY=300です。本文がメニュー高分だけ上へ飛ばないか見ます。
  3. 固定中に幅を広げて戻します。本文コンテナーの左・幅に合い、リンク折返し時は確保高も変わります。
  4. Beta sectionは実際の#betaへ移動し、見出しが下に見えます。リンクはフォーカス可能で基本のキーボード操作も使えます。
  5. Back to 0pxは通常配置へ戻し固定用left・widthを消します。既存URLハッシュを消す操作ではありません。

更新時点とアンカーの余白

$(window).scrollTop()とslot境界から真偽値を作り、.toggleClass("is-fixed", fixed)で明示設定します。一引数toggleを毎scrollで呼ぶとイベント回数で交互に変わってしまいます。

scroll・resize・loadではrequestAnimationFrame()で次描画前にupdate()を予約します。scheduledで重複予約を防ぎます。一定ミリ秒や特定フレーム速度の保証ではなく、処理自体を軽くします。

幅を決めてから高さを測り折返しを反映します。初回update()は復元済みスクロールにも対応します。後から上部内容やフォントで寸法が変わる場合、その完了時へschedule()をつなぎます。全DOM変更を自動監視しません。

--menu-heightを目的見出しのscroll-margin-topへ16px加えて使い、標準アンカーが隠れないようにします。他の固定ヘッダーがあればtopと目的地余白の両方で高さを考慮します。実hrefを維持し不要な既定動作の阻止をしません。

CSS stickyと実画面の確認

単純な上端追従ならposition:stickyとtopでJavaScriptなしでもできます。文書空間を維持し、スクロールコンテナーと包含ブロックの制限を受けます。短い親やoverflow祖先を確認し、fixedと同一と考えません。

例は文書全体用で内部パネルにはそのまま使いません。transformの祖先はfixed基準を変え得ます。全体position・overflowを無差別に上書きせず実構造を調べます。

高いz-indexだけでモーダルや別の重なり文脈まで解決しません。モバイル本文、フォーカス表示、拡大時のリンク操作を確認します。実習用オーバーレイは実サイトで除去か再配置します。

終了時はイベント名前空間だけでなく固定クラス、left・width、slot高、独自プロパティも機能の管理範囲内で戻します。アプリの反復初期化では処理の重複も防ぎます。