ページ内リンクのターゲットが固定ヘッダーに重なるのを CSS で対処する

ページ内にアンカーリンクを貼ってジャンプすると、リンク先のターゲット (#~) がブラウザのビューポートの最上部になるところまでスクロールされます。
その時、サイトのヘッダーを最上部に固定しているとその高さ分見えなくなってしまうというありがちな問題に CSS だけで対処する方法です。

コード例

以下のようなコードのとき、<a href=#hoge>hoge</a> をクリックしてジャンプすると、 <h3 id="hoge">hoge<h3> は‘<header>の下に隠れて見えなくなってしまいます。

style.css

header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 64px;
}

index.html

<header>
  Site Title
</header>
...
<a href="#hoge">hoge</a>

...

<h3 id="hoge">hoge</h3>

対処法

ターゲットになる要素の疑似クラスと疑似要素を使って余白を提供するのが (個人的には) 一番良さそうです。

:target 疑似クラス

通常アンカーリンクで遷移すると URL が書き換えられます。
ページ内リンクをクリックしたときには、https://www.***.com/index.html#hogeのように URL の末尾にジャンプ先の id (hoge)がつき、その id の要素は :target 疑似クラスで取得できます。

::before 疑似要素

今回は :target 疑似クラスを持つ要素の ::before 疑似要素を使って余白を提供したいと思います。
個人的な好みですが、固定ヘッダー分ターゲット要素の上部に余白を取りたいので ::before 疑似要素が合っているかなと思いました。

コード

:target::before {
  content: "";
  display: block;
  height: 64px; /* ずらしたい高さ */
  margin-top: -64px; /* heightに対するネガティブマージン */
  visibility: hidden,
}

Web アプリなどで history が書き換わらない場合

URL に #~ が付かないと :target 疑似クラスが使えないので上の方法が使えません。

(場合によりますが)これまで取り組んだ中では属性セレクターで id がある要素を絞り込むのが手軽でした。

h3[id]::before {
  content: "";
  display: block;
  height: 64px;
  margin-top: -64px;
  visibility: hidden,
}

同様の問題でよく見るもの

CSS で対処する別のパターン

元の要素に <header> の高さ (ずらしたい高さ) 分の padding とそれを打ち消すためのネガティブな margin を指定する方法。

h3#hoge {
  padding-top: 64px;
  margin-top: -64px;
}

この方法でももちろん固定ヘッダーに重なることなくページ内リンクでジャンプできます。
ただし、この方法を取ってしまうと border などをつけるときに padding が影響してしまうので、疑似要素を使ったほうがいいと思いました。

JavaScript

改めて検索すると JavaScript でヘッダー分ずらすなどの方法が結構出てきます。
しかし scroll はグローバルイベントですし、addEventListenerしてしまうと passive: true で無い場合にはスクロールをブロックしてしまうのでやりたくありませんでした。

また、Web アプリなどでスクロールにイベントリスナーを登録するといろいろ副作用があるので極力使わないようにしたほうが良いと思いました。

(例)

  • Angular の場合

    グローバルイベントが NgZone に監視されているので、スクロールの度 chengeDetection に引っかかり ngFor などが再描画される。

    zone-flagsで監視対象外にするか、対象コンポーネントの changeDetectionChangeDetectionStrategy.onPush にして手動で更新するなどの対応が必要となる。

  • SSR/SSG を行う場合

    サーバーサイドには Window オブジェクトが無いため、サーバーで実行されるコードに scroll のリスナーが含まれているとエラーとなる。

    例えば React なら componentDidMountuseEffect() (React.FC の場合) などサーバーで実行されない部分に記述するなどの対応が必要。

    どうしてもサーバーサイドで通過する場合はには if (typeof window !== 'undefined') で無視するなど。

あとがき

疑似クラスはめちゃくちゃ便利。

unimoku

Web サイトの制作、運営とアプリケーションのフロントエンド開発などをやっています。主に使うのはTypeScript、JavaScript、PHP、C/C++。特にTypeScriptが好きです。