Reactで、ページの先頭へ戻るボタンを作る方法
ページの先頭へ戻るボタンは、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、スマートフォンの実機、スクリーンリーダーでは試していません。

スクロールした量でボタンを出し入れする
まず、ページが決めた位置より下までスクロールされているかを返すフックを作ります。
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 から外します。
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>
);
}
.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)。
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 を渡せば、動きを減らす設定でもなめらかにスクロールします。
reduce:記録 [[0,3000],[49,0]]
reduce のまま smooth を直接指定:先頭に着くまで 915 ms、記録 56 回、最初 [[0,3000],[15,3000],[32,2998]]
押した後のフォーカスを、ページの先頭へ移す
キーボードで操作している人がボタンを押すと、ページは先頭へ戻り、ボタンは消えます。このとき、フォーカスをどこにも移さないと、次に Tab キーや Shift+Tab キーを押したときに、思わぬ場所へフォーカスが飛びます。先頭の見出しに tabIndex={-1} を付け、BackToTop に focusTargetId でその id を渡します。
<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 では、見出しにフォーカスが移るのは同じでも、なめらかに戻す動きは見られなくなりました。
ボタンを消すときは、フォーカスの行き先を決めておきます。行き先は、戻った先にある要素(先頭の見出しなど)にします。





