Reactで、スクロールすると上に固定されるヘッダーを作る方法

​​React​CSS,IntersectionObserver,Playwright,TypeScript,カスタムフック

スクロールしても画面の上に残るヘッダーは、CSS でヘッダーに position: sticky と top: 0 を付けるだけで作れます。固定そのものに JavaScript は要りません。React で書くのは、「ヘッダーが上に着いたら影を付ける」のように、固定されたかどうかで見た目を変えたいときの処理です。

お知らせの帯の下にヘッダーを置いたページを作り、帯が隠れたところでヘッダーが上に止まること、影が付いたり消えたりすること、固定されない書き方との違いを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して、スクロールの位置ごとにヘッダーの位置を測りました。Firefox と Safari、実機のスマートフォンでのタッチのスクロールは試していません。

ホイールで下へスクロールすると、お知らせの帯が隠れたところでヘッダーが画面の上に止まって影が付き、さらに下へ進んでもヘッダーは上に残り、いちばん上へ戻すと影が消える
下へ2回、上へ1回スクロールしたときのヘッダー。isStuck の表示は、値が変わった瞬間だけピンクになる(カーソル、ホイールの表示、操作の回数、isStuck の表示は GIF 用に重ねたもの)

position: sticky と top: 0 で、ヘッダーを上に止める

検証に使ったページは、高さ 80px のお知らせの帯、ヘッダー、本文の段落を縦に並べたものです。

tsx
import { StickyHeader } from './StickyHeader';

const paragraphs = Array.from({ length: 30 }, (_, i) => `本文の段落 ${i + 1}`);

export function App() {
  return (
    <div className="layout">
      <div className="banner">お知らせの帯(スクロールすると見えなくなる)</div>
      <StickyHeader />
      <main>
        {paragraphs.map((text) => (
          <p key={text}>{text}</p>
        ))}
      </main>
    </div>
  );
}

ヘッダーの CSS は次のとおりです。固定に関わるのは、最初の position: sticky と top: 0 の2行です。ヘッダーの後ろを本文が通っていくので、背景色を付けておきます。.is-stuck の影は、次の節で使います。

css
.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 16px;
  background: #fff;
  border-bottom: 1px solid #d5e3e8;
  transition: box-shadow 0.2s;
}
.site-header.is-stuck {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
}

検証環境で、ページをスクロールした量(scrollY)ごとに、ヘッダーの上端が画面の上から何 px のところにあるかを測りました。

スクロールした量 ヘッダーの上端
0px 80px
40px 40px
80px 0px
100px 0px
600px 0px
1000px 0px

帯が見えている間は、ヘッダーは帯と一緒に上がっていきます。帯の高さと同じ 80px スクロールしたところでヘッダーが画面の上に着き、そこから先はどれだけスクロールしても 0px のままでした。MDN の position の説明にあるとおり、sticky の要素は、top などで決めた位置に来るまでは普通の要素と同じように動き、そこに来ると止まります。

上に着いたら影を付ける

この記事では、ヘッダーが上に止まったかどうかを JavaScript で受け取り、React の state にします。ヘッダーの直前に高さ 1px の空の要素(以下、sentinel)を置き、それが画面の外に出たら「ヘッダーが上に着いた」とみなします。画面に入っているかどうかは、IntersectionObserver で受け取ります。

ts
import { useEffect, useRef, useState } from 'react';

// ヘッダーの直前に置いた要素(sentinel)が画面の外に出たら、ヘッダーが上に貼り付いたとみなす
export function useIsStuck<T extends HTMLElement>() {
  const sentinelRef = useRef<T>(null);
  const [isStuck, setIsStuck] = useState(false);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;
    const observer = new IntersectionObserver(([entry]) => {
      setIsStuck(!entry.isIntersecting);
    });
    observer.observe(sentinel);
    return () => observer.disconnect();
  }, []);

  return [sentinelRef, isStuck] as const;
}

root を渡さない IntersectionObserver は、要素が画面(ブラウザのビューポート)に入っているかどうかを見ます。コールバックは、監視を始めた直後に1回呼ばれます。そのあとは、sentinel が画面から出ると isIntersecting が false、戻ってくると true になって呼ばれます。検証環境で、ページを開いたあと、0px から 1000px まで 10px ずつスクロールして 0px まで戻したところ、scroll イベントは200回起きましたが、その間にコールバックが呼ばれたのは、出たときと戻ったときの2回だけでした。

useEffect から返している observer.disconnect() は、監視を止める後片付けの処理です。React の公式リファレンスにある useEffect の説明によると、後片付けは、コンポーネントが画面から消えるときに呼ばれます。開発中の Strict Mode では、最初のセットアップの前に、セットアップと後片付けがもう一度ずつ呼ばれます。

フックを使うヘッダーの部品は次のとおりです。

tsx
import { useIsStuck } from './useIsStuck';

export function StickyHeader() {
  const [sentinelRef, isStuck] = useIsStuck<HTMLDivElement>();

  return (
    <>
      <div ref={sentinelRef} className="sentinel" aria-hidden="true" />
      <header className={isStuck ? 'site-header is-stuck' : 'site-header'}>
        <span className="logo">それなら猫の手で</span>
        <nav>
          <a href="#about">概要</a>
          <a href="#posts">記事</a>
        </nav>
      </header>
    </>
  );
}

sentinel には、高さ 1px と、同じだけの負の margin-bottom を付けます。これで、sentinel を置いてもヘッダーの位置は 1px も下がりません。

css
.sentinel {
  height: 1px;
  margin-bottom: -1px;
}

検証環境で 1px ずつスクロールして確かめたところ、is-stuck が付いたのは 82px のときでした。ヘッダーが上に着くのは 80px なので、影が付くのは止まってから 2px あとです。上へ戻したときは、81px で is-stuck が外れました。

このフックは、「sentinel が画面の外にある」ことを「画面より上に出た」とみなしています。そのため、ページを開いた時点で sentinel が画面の中にある(ヘッダーが最初の画面に収まっている)ことが前提です。帯の高さを 600px にして、画面(高さ 400px)より下にヘッダーがある状態で開いたところ、ヘッダーはまだ止まっていない(上端が 600px)のに、is-stuck が付いていました。監視を始めた直後の1回目の呼び出しで、画面より下にある sentinel の isIntersecting が false になったためです。300px までスクロールして sentinel が画面に入ると外れ、帯が隠れたあとはまた付きました。610px から 0px へ一度に戻したときは、ヘッダーが止まっていない位置に戻っても、付いたままでした。ヘッダーがページの途中にあるときは、このフックをそのままでは使えません。

ヘッダーが固定されないときに確かめる3つのこと

position: sticky を付けたのに止まらない、という書き方を3つ作り、上と同じようにヘッダーの上端の位置を測りました。どれも position の計算された値は sticky のままですが、ヘッダーは帯と一緒に画面の外へ出ていきました。

書き方 100px スクロールしたときの上端 600px スクロールしたときの上端
正しく固定される書き方 0px 0px
top を付けない -20px -520px
親の要素に overflow-x: hidden を付ける -20px -520px
帯とヘッダーだけを囲む要素の中に置く -20px -520px
親の要素に overflow-x: clip を付ける 0px 0px

1つ目は、top(または bottom などの位置)を書き忘れた場合です。MDN の position の説明には、その方向の位置がどれも auto のままだと、sticky は relative と同じ動きになると書かれています。

2つ目は、ページ全体を囲む要素(ここでは .layout)に、横にはみ出した部分を隠すつもりで overflow-x: hidden を付けた場合です。検証環境で計算された値を見ると、overflow-y は書いていないのに auto になっていました。MDN の overflow の説明によると、片方の向きを visible のままにしても、もう片方が visible か clip 以外なら、visible の向きは auto として扱われます。sticky の要素は、いちばん近い「スクロールの仕組みを持つ祖先」(overflow が hidden、scroll、auto などの要素)に対して止まります。ここでは .layout がそれにあたります。.layout の高さは中身と同じ 1607px で、.layout 自体はスクロールしません。スクロールしているのはページ全体なので、ヘッダーは止まりませんでした。

横のはみ出しを隠したいときは、hidden の代わりに overflow-x: clip を使います。clip は、はみ出した部分を切り取りますが、要素をスクロールの仕組みを持つ要素にはしません。検証環境でも、overflow-y は visible のままで、ヘッダーは上に止まりました。

overflow-x: hidden は、ページ全体を囲む要素や、レイアウト用のコンポーネントに付けられていることがあります。ヘッダーが止まらないときは、ヘッダーから上にたどった要素に overflow が付いていないかを、ブラウザの開発者ツールで見直します。

3つ目は、帯とヘッダーだけを div で囲み、その中にヘッダーを置いた場合です。sticky の要素が止まっていられるのは、親の要素の範囲の中だけです。親の高さは、帯とヘッダーを足した 137px しかないので、帯が隠れたあとに止まる余地がなく、ヘッダーは親と一緒に上へ出ていきました。ヘッダーは、ページの終わりまで続く要素(ここでは .layout)の直下に置きます。

position: fixed で固定する場合との違い

position: fixed でもヘッダーは画面の上に残りますが、置いた場所に関係なく、最初から画面の上に貼り付きます。検証環境で、ヘッダーだけを position: fixed(top: 0、left: 0、right: 0)に変えたところ、スクロールする前から、ヘッダー(上端 0px、下端 57px)がお知らせの帯(上端 0px、下端 80px)に重なりました。画面の上から 20px の位置にあったのは、帯ではなくヘッダーのロゴでした。

MDN の position の説明にあるとおり、fixed の要素はページの流れから外れ、その分の場所が空けられません。検証環境でも、本文の1つ目の段落は、sticky のときの 137px から、帯のすぐ下の 80px に上がっていました。ページ全体でスクロールできる量も、sticky の 1207px から、ヘッダーの高さ(57px)の分だけ減って 1150px になりました。

帯の下にヘッダーを置き、帯が隠れてから止めたい場合は、ヘッダーの分の場所を空けたまま止まる sticky が合います。

​Posted by 86