Reactで、ページの先頭へ戻るボタンを作る方法

​​React​Playwright,TypeScript,アクセシビリティ

ページの先頭へ戻るボタンは、window.scrollY を見て表示を切り替え、押されたら window.scrollTo({ top: 0 }) を呼べば作れます。ただ、それだけでは、動きを減らす設定にしている人にも、なめらかなスクロールの動きを見せてしまいます。キーボードで押した人のフォーカスも、消えたボタンと一緒に行き場をなくします。ここでは、この2つにも対応したコンポーネントを作ります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して、スクロールの位置とフォーカスの移り先を読み取りました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでは試していません。

ホイールで下へスクロールすると右下に「先頭へ」ボタンが出て、押すとページの先頭までなめらかに戻り、ボタンが消える
下へスクロールしてボタンが出たところで押し、先頭へ戻る動き(上の黒い帯、マウスの絵、ピンクの表示は GIF 用に足したもの)

スクロールした量でボタンを出し入れする

まず、ページが決めた位置より下までスクロールされているかを返すフックを作ります。

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

// ページを threshold(px)より下までスクロールしたら true を返す
export function useScrolledPast(threshold: number): boolean {
  const [past, setPast] = useState(false);

  useEffect(() => {
    const update = () => setPast(window.scrollY > threshold);
    update(); // 再読み込みなどで、途中の位置から始まったときのため
    window.addEventListener('scroll', update);
    return () => window.removeEventListener('scroll', update);
  }, [threshold]);

  return past;
}

state に入れるのは、スクロールの位置そのものではなく、「境目を越えたかどうか」の true か false だけです。useState のリファレンスにあるとおり、新しい値が今の値と同じなら、React は再レンダリングを省きます。境目を越えないかぎり値は変わらないので、スクロールしている間の再レンダリングはほとんど起きません。

検証環境で、高さ 6740px のページを 0px から 3000px まで 10px ずつスクロールし、0px まで戻しました。scroll イベントは600回起きましたが、Profiler で数えたボタンのコミットは4回でした。どれも、境目の 400px を越えた前後(scrollY が 410px・400px・390px のとき)です。ボタンが出たのは 410px のときで、400px ちょうどでは出ません(> で比べているため)。

なめらかに戻り、動きを減らす設定では一瞬で戻る

ボタンのコンポーネントです。表示しないときは null を返して、DOM から外します。

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

type Props = {
  focusTargetId: string; // 戻った後にフォーカスを移す要素の id
  threshold?: number; // ここより下までスクロールしたらボタンを出す(px)
};

export function BackToTop({ focusTargetId, threshold = 400 }: Props) {
  const visible = useScrolledPast(threshold);
  if (!visible) return null;

  const handleClick = () => {
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    window.scrollTo({ top: 0, behavior: reduce ? 'instant' : 'smooth' });
    // ボタンは消えるので、フォーカスをページの先頭の要素へ移す
    document.getElementById(focusTargetId)?.focus({ preventScroll: true });
  };

  return (
    <button type="button" className="back-to-top" onClick={handleClick}>
      <span aria-hidden="true">↑</span> 先頭へ
    </button>
  );
}
css
.back-to-top {
  position: fixed;
  right: 16px;
  bottom: 16px;
  padding: 10px 16px;
  border: none;
  border-radius: 999px;
  background: #067a98;
  color: #fff;
  font-size: 16px;
  cursor: pointer;
}

MDN の scrollTo の説明によると、behavior に smooth を渡すとなめらかに、instant を渡すと一度に移動します。省略したときの auto は、CSS の scroll-behavior の値に従います。

検証環境で、html { scroll-behavior: smooth; } を足したページを、動きを減らす設定(Playwright の reducedMotion: 'reduce')で開いて試しました。behavior を渡さずに window.scrollTo({ top: 0 }) を呼ぶと、CSS に従ってなめらかにスクロールし、3000px の位置から先頭に着くまでに 915ms かかりました。同じページで BackToTop を押すと、次の描画のときには scrollY が 0 になっていました。検証した Chromium 153 では、auto ではなく instant を渡しておけば、サイトの CSS に scroll-behavior: smooth があっても一度に移動します。

検証環境で 3000px の位置からボタンを押すと、先頭に着くまでに 949ms かかりました。途中の位置は次のとおりです(押してからの時間と scrollY)。

text
0ms:3000 115ms:2958 199ms:2672 282ms:1711 365ms:964 450ms:583 532ms:352 615ms:203 699ms:106 782ms:45 865ms:12 949ms:0

先頭に着いた後は、ボタンが DOM からなくなっていました。ボタンは、押してすぐに消えるわけではありません。scrollY が 400px 以下になったところで消えます。GIF でも、上の帯の「ボタン なし」がピンクになるのは、戻る途中の位置です。

Chromium は、動きを減らす設定でも smooth を止めなかった

prefers-reduced-motion は、利用者が端末で、動きを減らす設定にしているかどうかを調べるメディア特性です。handleClick で matchMedia を使って調べているのは、検証した Chromium が、なめらかなスクロールを自動では止めなかったからです。

Playwright で reducedMotion: 'reduce' を指定してページを開くと、matchMedia('(prefers-reduced-motion: reduce)').matches は true になりました。この状態で BackToTop を押すと、次の描画のときには scrollY が 0 になっていました。一方、同じ状態で window.scrollTo({ top: 0, behavior: 'smooth' }) を直接呼ぶと、先頭に着くまでに 915ms かかりました。少なくとも検証した Chromium 153 では、smooth を渡せば、動きを減らす設定でもなめらかにスクロールします。

text
reduce:記録 [[0,3000],[49,0]]
reduce のまま smooth を直接指定:先頭に着くまで 915 ms、記録 56 回、最初 [[0,3000],[15,3000],[32,2998]]

押した後のフォーカスを、ページの先頭へ移す

キーボードで操作している人がボタンを押すと、ページは先頭へ戻り、ボタンは消えます。このとき、フォーカスをどこにも移さないと、次に Tab キーや Shift+Tab キーを押したときに、思わぬ場所へフォーカスが飛びます。先頭の見出しに tabIndex={-1} を付け、BackToTop に focusTargetId でその id を渡します。

tsx
<h1 id="page-title" tabIndex={-1}>猫の手ブログ</h1>
<nav><a href="#a">新着</a> <a href="#b">カテゴリー</a></nav>
{/* …記事の一覧… */}
<BackToTop focusTargetId="page-title" />

MDN の tabindex の説明によると、tabindex="-1" の要素は Tab キーでは移れませんが、スクリプトからフォーカスを当てられます。見出しを Tab の順番に加えずに、フォーカスの置き場所にだけ使えます。

検証用のページは、見出しの下に「新着」「カテゴリー」のリンクがあり、その下に30件の記事と「続きを読む」のリンクが並んでいます。ボタンはいちばん最後です。ボタンにフォーカスを当てて Enter キーで押し、先頭に着いてから Tab キーと Shift+Tab キーを押しました。フォーカスを移さない版(focus の行がないだけで、ほかは同じ)と比べた結果です。

先頭に着いた後の操作 BackToTop フォーカスを移さない版
何もしない 見出し(h1)にフォーカス body
Tab を1回 「新着」へ移る ページの中のどこにも移らない(もう1回押すと「新着」)
Shift+Tab を1回 ページの中のどこにも移らない ページの最後の「続きを読む 30」へ移り、scrollY が 6140px になる

フォーカスを移さない版では、Shift+Tab を押すと、消えたボタンの直前にあった最後のリンクにフォーカスが移りました。先頭へ戻ったはずのページが、一番下まで動きます。Tab キーの移動の起点が、消えたボタンのあった場所に残っているような動きです。BackToTop では、Tab キーで見出しの次にある先頭のリンクから順に進めます。

focus には { preventScroll: true } を渡して、ページを動かすのは scrollTo だけにしています。MDN の focus の説明によると、preventScroll を省くと、ブラウザはフォーカスを当てた要素が見える位置までスクロールします。true にすると、フォーカスのためのスクロールは起きません。

検証環境で、preventScroll を渡さない版(focus() に何も渡さないだけで、ほかは同じ)を、3000px の位置から押してみました。なめらかには戻らず、次の描画のときには scrollY が 0 になっていました。検証した Chromium 153 では、見出しにフォーカスが移るのは同じでも、なめらかに戻す動きは見られなくなりました。

ポイント

ボタンを消すときは、フォーカスの行き先を決めておきます。行き先は、戻った先にある要素(先頭の見出しなど)にします。

​Posted by 86