固定ヘッダーでアンカーリンクの位置がずれる問題の直し方|scroll-margin-topが効かない理由・可変ヘッダー・別ページ着地まで

固定ヘッダーでアンカーリンクの位置がずれる問題の直し方|scroll-margin-topが効かない理由・可変ヘッダー・別ページ着地まで

結論:アンカーのズレは「対象要素に scroll-margin-top」で直る。位置調整の小細工は入れない

  • 飛び先の要素に scroll-margin-top: (ヘッダー高さ+余白)を付けるのが正解。padding や透明ブロックでの位置ずらしは他のレイアウトを壊すので使わない
  • まとめて効かせたいなら html に scroll-padding-top。要素側が scroll-margin、スクロールする側が scroll-padding と覚える
  • 効かないときに見る3か所=body に書いていないか/内側にスクロールコンテナがないか/セレクタが実際の着地要素に当たっているか
  • scroll-behavior: smooth は html に書く。body に書いても効かない
  • ヘッダー高さが可変なら固定px指定をやめ、実測値を CSS 変数へ流し込んで scroll-margin-top に使う

「目次から見出しをクリックしたら、その見出しが固定ヘッダーの裏に隠れて本文の途中から始まる」——追従ヘッダーを実装したサイトで必ず出るのがこのズレです。結論から言うと、対処は飛び先の要素に scroll-margin-top を1行足すだけで終わります。この記事では、なぜズレるのかという仕組みの話から、scroll-padding-top との使い分け、効かないときに見る場所、高さが変わるヘッダーへの追従、別ページから #hash で着地したときのズレ、そしてWordPressで「自分の画面でだけズレて見える」あるあるまで、実案件で確認している順番のまま整理します。

なぜ固定ヘッダーだとアンカーリンクがずれるのか

ブラウザは #section-2 のようなページ内リンクを踏んだとき、その要素の上端をビューポートの上端にぴったり合わせるという動きをします。ビューポートの上端というのは、ヘッダーの下ではなく画面の一番上です。

position: fixed(や sticky)のヘッダーは通常の文書の流れから外れて画面上部に浮いているので、ブラウザから見れば「そこには何も無い」扱いになります。結果、見出しは画面の一番上に正しく運ばれ、その上にヘッダーが覆いかぶさる——つまりズレているのではなく、仕様どおりの位置に置かれた上で隠れているのが実態です。ヘッダーの高さが64pxなら、ちょうど64px分だけ見出しが食われます。

やってはいけない直し方

この問題を、見出しに padding-top: 80px; margin-top: -80px; を当てて力技で解決している実装をよく見かけます。動きはしますが、以下の副作用を必ず抱えます。

  • 見出しの当たり判定が上に80px広がり、その上にある要素のリンクやボタンが押せなくなる
  • 見出しに背景色やボーダーを付けた瞬間に、余白ごと色が乗って見た目が崩れる
  • gap や margin ベースの余白設計と喧嘩して、セクション間の間隔が場所ごとにずれる

透明な空ブロックを見出しの直前に置く方法も同様に、DOMが増えるうえ「なぜこれがあるのか」が後任に伝わりません。スクロール位置の調整はスクロール専用のプロパティでやるのが正解で、そのために用意されているのが次の scroll-margin-top です。

正解は scroll-margin-top を「飛び先の要素」に付ける

scroll-margin-top は「この要素へスクロールして止まるとき、上に何px余白を空けて止まるか」という指定です。レイアウトには一切影響せず、スクロールの停止位置だけを動かします。

/* ヘッダー64px + 見出し上の余白16px = 80px */
:target,
h2[id],
h3[id] {
  scroll-margin-top: 80px;
}

実務では、見出しごとに書くのではなくidを持つ要素に一括で当てるのが管理しやすいです。目次プラグインが見出しへ自動でidを振るWordPressサイトでは、この書き方だけで全記事のズレが同時に直ります。

scroll-padding-top との使い分け

混同しやすいのですが、役割は明確に分かれています。

  • scroll-margin-top=飛ばされる側(着地する要素)に付ける。要素ごとに停止位置を変えられる
  • scroll-padding-top=スクロールする側(スクロールコンテナ)に付ける。中の要素すべてに一括で効く
/* ページ全体のスクロールコンテナは html(body ではない) */
html {
  scroll-padding-top: 80px;
}

サイト全体で同じヘッダー高さなら html に scroll-padding-top を1行書くのが最短です。特定の見出しだけ停止位置を変えたい(例:セクション見出しの上にラベルが乗っている)ケースでは、その要素にだけ scroll-margin-top を足して上書きします。両方書いた場合は要素側の scroll-margin が加算される形になるため、基本は片方に寄せるのが事故が少ないです。

効かないときに見る3か所

「書いたのに効かない」という相談は、ほぼこの3つのどれかです。

  1. body に scroll-padding-top を書いている。ページ全体のスクロールを担当しているのは多くの場合 html(ルート要素)で、body に書いても無視されます。html に移すだけで直ります
  2. 内側にスクロールコンテナがある。レイアウトの都合でラッパー要素に height: 100%; overflow: auto; を当てている作りだと、スクロールしているのはそのラッパーです。scroll-padding-top はそのラッパーに付ける必要があります
  3. セレクタが実際の着地要素に当たっていない。目次プラグインが見出しの外側に <div id="toc-1"> のようなラッパーを吐いている場合、idを持つのは h2 ではなくその div です。着地しているのがどの要素かは、ジャンプ後にコンソールで確認できます
// 実際に着地している要素を特定する
window.addEventListener('hashchange', () => {
  const el = document.querySelector(location.hash);
  console.log('target:', el?.tagName, el?.id, el?.className);
}, false);

scroll-behavior: smooth は html に書く

ぬるっと移動させる scroll-behavior: smooth も、body に書いて「効かない」となる典型です。スクロールコンテナは html なので、html 側に書きます。

html {
  scroll-behavior: smooth;
  scroll-padding-top: 80px;
}

/* 動きを減らす設定をしている環境では瞬間移動に戻す */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

あわせて注意したいのがJSのスムーススクロールとの二重実装です。jQuery時代の animate({scrollTop: ...}) や自前の scrollTo({behavior:'smooth'}) が残ったまま CSS 側にも smooth を書くと、移動が二重に走って最後にカクッと跳ねます。CSSで足りるならJS側を消すのが正解で、JSで細かく制御したい場合はCSSの smooth を外してください。JSで自前計算する場合も、停止位置はヘッダー高さを引いた値を明示的に渡す必要があります。

// JSで制御する場合(CSSのsmoothは外す)
const y = el.getBoundingClientRect().top + window.scrollY - headerHeight - 16;
window.scrollTo({ top: y, behavior: 'smooth' });

ヘッダーの高さが変わるサイトは固定pxをやめる

実案件でいちばん厄介なのがここです。ヘッダーの高さは、意外なほど一定ではありません。

  • スマホでロゴとナビが2段に折り返して高くなる
  • スクロールすると縮む(80px→56pxなど)演出が入っている
  • お知らせバーやクッキー同意バーが上に増える日がある

この状態で scroll-padding-top: 80px と固定していると、条件によって20〜30px単位でズレます。ヘッダーの実測高さをCSS変数へ流し込み、それを参照するのが確実です。

const header = document.querySelector('.l-header');
const setHeaderHeight = () => {
  const h = header.getBoundingClientRect().height;
  document.documentElement.style.setProperty('--header-h', h + 'px');
};
setHeaderHeight();
// 折り返しや縮小で高さが変わったら追従する
new ResizeObserver(setHeaderHeight).observe(header);
html {
  /* JSが動く前のフォールバック値を必ず置く */
  --header-h: 64px;
  scroll-padding-top: calc(var(--header-h) + 16px);
}

スクロールで縮むヘッダーの場合、基準にすべきは「縮んだあとの高さ」です。ページ最上部から目次をクリックした瞬間はヘッダーがまだ大きく、移動の途中で縮むため、大きい方の高さで計算すると着地後に余白が空きすぎます。ResizeObserver で追従させておけば、着地時点の実寸で計算されるのでこの矛盾が起きません。フォールバック値の記述を忘れるとJSの読み込み前にジャンプした読者だけがズレるので、CSS側の初期値は必ず書いておきます。

別ページから #hash で着地したときだけズレる

ページ内リンクは合っているのに、他ページから /price/#plan のようなURLで飛んできたときだけ位置が下にずれる——これはヘッダーではなく読み込み順が原因です。

ブラウザはHTMLを読み終えた時点でハッシュ位置へジャンプしますが、その後に画像やWebフォントが読み込まれて上のコンテンツの高さが伸びると、着地点がその分だけ下へ押し出されます。対処は表示速度の話とまったく同じで、

  • img に width / height 属性、または aspect-ratio で先に枠を確保する
  • Webフォントに font-display: swap と size-adjust を当て、切り替わり時の行数変化を抑える
  • 遅延読み込みしている画像がファーストビュー内にあれば、そこだけ除外する

要するにレイアウトシフトを潰せばアンカーのズレも同時に消えます。画像側の具体的な指定は、当サイトの表示速度まわりの記事で扱っている内容がそのまま使えます。どうしても間に合わない場合の最終手段として、window.load 後にもう一度 scrollIntoView() を呼ぶ方法もありますが、読者から見ると画面が二度動くので常用はおすすめしません。

WordPressで「自分の画面でだけズレる」ときは管理バー

WordPressサイト特有の見落としがこれです。ログイン中は画面上部に管理バー(#wpadminbar)が表示され、その分だけページ全体が下へ押し下げられます。高さはPCで32px、幅の狭い画面では46pxです。

制作者は基本ログインしたまま確認するので、「自分の環境ではズレるのに、クライアントは大丈夫と言う」(またはその逆)という食い違いが起きます。lionmedia系の子テーマでも同様で、テーマ側は管理バー込みで固定ヘッダーの top を調整していても、scroll-padding-top までは面倒を見てくれません。

判断を間違えないために、アンカー位置の最終確認はシークレットウィンドウ(未ログイン状態)で行うのを手順に入れてください。ログイン時も厳密に合わせたい場合は、管理バーが出ているときだけ値を足します。

body.admin-bar {
  --header-h: calc(64px + 32px);
}
@media screen and (max-width: 782px) {
  body.admin-bar { --header-h: calc(64px + 46px); }
}

見落としがちな点:ジャンプしてもフォーカスは動かない

見た目のズレを直して終わりにしがちですが、もう一点あります。h2 や div のようにフォーカスを受け取れない要素へジャンプした場合、画面はスクロールしてもキーボードのフォーカスは元の位置に残ったままです。目次リンクをEnterで踏んだ読者が続けてTabを押すと、飛んだ先ではなくページ上部のナビへ移動してしまいます。

document.querySelectorAll('a[href^="#"]').forEach((a) => {
  a.addEventListener('click', () => {
    const el = document.querySelector(a.getAttribute('href'));
    if (!el) return;
    // フォーカスを受け取れるようにしてから移す(見た目の輪郭は出さない)
    el.setAttribute('tabindex', '-1');
    el.focus({ preventScroll: true });
  });
});

preventScroll: true を付けないと、CSSのスムーススクロールと focus() のスクロールが競合して一瞬で飛んでしまいます。ここは実装時に必ず踏むので、セットで覚えておくと早いです。

公開前チェックリスト

  • 目次から最初と最後の見出しへ飛び、見出しがヘッダーに隠れず、上に適度な余白があることを確認した
  • スマホ幅(375px)でヘッダーが2段になる場合も、同じ余白で止まることを確認した
  • 他ページから #hash 付きURLで直接アクセスし、画像読み込み後もズレないことを確認した
  • 未ログイン(シークレットウィンドウ)で確認した。管理バーの分だけズレていないか
  • スクロールが二重に走ってカクついていない(CSSとJSのスムーススクロールが併存していない)
  • 目次リンクをEnterで踏んだあとTabを押し、フォーカスが飛んだ先から続くことを確認した

追従ヘッダーは「置いた瞬間に周辺の挙動を全部巻き込む」パーツです。多言語サイトの作り方のように言語切替UIがヘッダーに乗るサイトでは高さが言語ごとに変わることもあり、固定px前提の実装はいずれ破綻します。最初から scroll-padding-top + CSS変数で組んでおくのが、結局いちばん手離れが良い作り方です。

なお、追従ヘッダー自体が効かない・途中で止まるという症状は原因が別なので、そちらは サーバー移行やテーマ環境を含めた切り分けが必要になります。表示速度まわりの前提条件についてはサーバー比較の記事もあわせてご覧ください。

多言語・ベトナム語サイトの制作でお困りではありませんか?

フォント・文字化け・WordPress多言語化・サーバー選定まで、多言語サイト制作の実績を持つ制作会社が一括対応します。お見積り・ご相談は無料です。

多言語サイト制作の詳細を見る →

すぐに相談したい方は お問い合わせフォームへ

関連記事(コーディング実務Tips)

あわせて読みたい

Web制作Tips(コーディング実務)カテゴリの最新記事