以前公開していた記事の内容が一部というか誤解を与える内容だったため、書き直しています。
以前の内容ですとJSを書いてすぐ実装可能と紹介していましたが、様々な落とし穴もあるためです。
ということで「ページトップに戻る(Back to Top)」の実装を、まとめ直してお届けします。
クリックしたら上に戻る――それだけなら簡単ですが、アクセシビリティやユーザー設定、固定ヘッダーとの相性など、細かな“つまずき”が潜んでいます。
というわけで、2025年の基準で最小コードかつ実運用で気持ちよく動く形に整理しました。
コードはコピペOKです。肩の力は抜いていきましょう。
- はじめに:よくある実装が「惜しい」理由
- コツ1:CSSの scroll-behavior をサイトの既定値に
- コツ2:HTMLの下ごしらえ—アンカーとフォーカスの帰着地
- コツ3:JSは“最小限”で—ユーザー設定を尊重してスクロール
- コツ4:ボタンの表示/非表示は IntersectionObserver
- コツ5:スクロール“完了”検知は scrollend を様子見で
- コツ6:固定ヘッダーで見出しが隠れる? scroll-margin-top で解決
- コツ7:ページ全体だけでなく“スクロールコンテナ”にも対応
- ついでの小技:触り心地をワンランク上げる
- よくある反論と、その折り合い
- 実装ひな形(コピペ用:ページ全体版)
- というわけで:最短で「気持ちいい戻り方」をつくる手順
- 参考リンク
はじめに:よくある実装が「惜しい」理由
たとえば window.scrollTo({ top: 0 })。
動きます。けれど、そこで終わるといろいろ見落とします。
OSの「動きを減らす」設定を無視してスムーズスクロールしてしまうこと。
アンカーで戻ったとき、フォーカスの行き先が曖昧で支援技術ユーザーが迷子になること。
さらに、ボタンをいつ表示/非表示にするか。
固定ヘッダーで見出しが半分隠れる問題。
こうした“使用感のトゲ”を抜くと、読了率が地味に伸びます。
本記事はこのトゲ抜きを順番にやっていきます。
CSSで土台、HTMLで導線、JSは最小限。
これが一番ラクで、後片付けも簡単です。
コツ1:CSSの scroll-behavior をサイトの既定値に
まずはCSSで全体のスクロール感を決めます。
アンカーや scrollTo() に自然な滑らかさを与えるなら、scroll-behavior: smooth; をトップで指定します。
ただし、ユーザーが「動きを減らす」を選んでいる場合は即時スクロールへ。
ここは機械的にやっておくと、あとで全ページが助かります。
/* 既定はスムーズ */
html { scroll-behavior: smooth; }
/* ユーザーの設定を最優先 */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
ポイントは「まずCSS」。
JSで頑張るより、ここのひと言で9割片づきます。
コツ2:HTMLの下ごしらえ—アンカーとフォーカスの帰着地
「トップへ戻る」をリンクで作れるなら、それが最強です。
JSが切れていても働くし、フォーカス移動も決めやすいからです。
先頭にフォーカス可能な目印を置きます。id="top" と tabindex="-1" をつけたヘッダーなどが定番です。
“本文へスキップ”のリンクもついでに置きましょう。
ユーザーも検索エンジンもニッコリです。
<a class="skip-link" href="#main">本文へスキップ</a>
<header id="top" tabindex="-1">
<!-- ロゴやナビ -->
</header>
<main id="main" tabindex="-1">
<!-- 記事本文 -->
</main>
<a class="back-to-top" href="#top" aria-label="ページの先頭へ">▲ トップへ</a>
コツは意味のある href と分かるラベル。
見た目だけの空リンクはやめておきます。
コツ3:JSは“最小限”で—ユーザー設定を尊重してスクロール
CSSだけでも十分ですが、
「確実にトップへ」「戻ったら先頭にフォーカス」などが必要なら、一口サイズのJSを足します。
prefers-reduced-motion を見て、behavior を切り替えます。
戻ったら #top にフォーカス。ただし preventScroll: true を添えて余計なスクロールを起こさないのがコツです。
const backToTop = document.querySelector('.back-to-top');
backToTop?.addEventListener('click', (e) => {
// アンカー遷移を手動制御したい場合のみ
e.preventDefault();
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({
top: 0,
left: 0,
behavior: reduce ? 'auto' : 'smooth',
});
document.getElementById('top')?.focus({ preventScroll: true });
});
ここまでで基本の挙動は完成です。
まだ余裕があれば、次の「出し入れ」を整えます。
コツ4:ボタンの表示/非表示は IntersectionObserver
「ページ上端が見えている間はボタンを隠す。
ちょっと下がったら出す。」
これを scroll イベントで計算すると、地味にしんどいです。IntersectionObserver に上端の番人をしてもらうとラクで、パフォーマンスも安定します。
<button id="toTopBtn" type="button" hidden aria-label="ページの先頭へ">▲</button>
#toTopBtn {
position: fixed;
inset-block-end: 2rem;
inset-inline-end: 2rem;
padding: .75rem 1rem;
border-radius: 9999px;
}
[hidden] { display: none !important; }
const btn = document.getElementById('toTopBtn');
const sentinel = document.getElementById('top');
const io = new IntersectionObserver(([entry]) => {
btn.hidden = entry.isIntersecting; // 上端が見えている間は隠す
}, { root: null, threshold: 0 });
io.observe(sentinel);
btn.addEventListener('click', () => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, left: 0, behavior: reduce ? 'auto' : 'smooth' });
sentinel?.focus({ preventScroll: true });
});
説明書いらずの見せ方は、結局これが一番です。
コツ5:スクロール“完了”検知は scrollend を様子見で
「スクロールが止まった瞬間に何かしたい」。
そのための scrollend です。
ただ、現時点では対応が割れがちです。
重要処理にはまだ採用しすぎず、必要なら scroll + デバウンスでフォールバックを残しましょう。
新機能は使い、壊れ方には備える。
このバランスが本番では効きます。
コツ6:固定ヘッダーで見出しが隠れる? scroll-margin-top で解決
アンカーで見出しへ飛んだのに、
固定ヘッダーがタイトルを半分隠す……あるあるです。
目標側に“余白”を渡す scroll-margin-top(またはスクロールポート側の scroll-padding-top)で一発解消します。
ヘッダーの高さに合わせて設定するのがコツです。
/* 例:固定ヘッダーの高さが 72px */
:where(h2, h3, [id]) { scroll-margin-top: 72px; }
/* スクロールポート側で調整するなら */
html { scroll-padding-top: 72px; }
これで「飛んだのに見えない」問題から卒業できます。
コツ7:ページ全体だけでなく“スクロールコンテナ”にも対応
モーダル内やサイドパネルなど、独立したスクロール領域を持つUIは、window ではなく要素へスクロールします。
ここでは Element.scrollTo() や Element.scrollIntoView() を使います。
固定ヘッダー対策を組み合わせるなら、ターゲットへ scroll-margin-top を付けておくと相性が良いです。
<div class="panel" id="listPanel">
<!-- 長いリスト -->
</div>
<button id="panelTopBtn" type="button">パネルの先頭へ</button>
const panel = document.getElementById('listPanel');
document.getElementById('panelTopBtn')?.addEventListener('click', () => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
panel.scrollTo({ top: 0, left: 0, behavior: reduce ? 'auto' : 'smooth' });
});
ページ外にも“戻る場所”がある。
これを押さえておくと複雑なUIでも迷いません。
ついでの小技:触り心地をワンランク上げる
- ヒットエリアは十分に広く。タップミスが減ります。
- キーボードフォーカスは消さない。消すなら見やすい代替スタイルを。
overscroll-behaviorでモバイルSafariのビヨン対策。- 「本文の先頭へ戻る」はウィンドウを動かすより、本文の先頭要素へアンカーのほうが支援技術に優しいです。
細部の積み上げが、**“なんか気持ちいい”**を生みます。
よくある反論と、その折り合い
「そこまで作り込まなくても、スクロールはスクロールでしょ?」
気持ちは分かります。
実装コストもゼロではありません。
とはいえ、読者は**“読むのが得意な人”だけではないです。
動きを減らしたい人、キーボードで操作する人、画面が小さい人。
配慮は情報への最短ルート**を作ります。
とはいえ、納期も現実です。
そこでおすすめの段取りは次の三段構えです。
- CSSでできることを先に全部やる(
scroll-behaviorとprefers-reduced-motion)。 - JSは最小限(
scrollToとfocus({ preventScroll: true }))。 - 表示制御は IO、新機能はフォールバック付きで。
この順番なら、工数に対して体験の伸びが良いです。
保守もラクです。
実装ひな形(コピペ用:ページ全体版)
最小構成で「今っぽく」動くテンプレートです。
迷ったらこれをベースにしてください。
<!-- 先頭の目印とスキップリンク -->
<a class="skip-link" href="#main">本文へスキップ</a>
<header id="top" tabindex="-1">…</header>
<main id="main" tabindex="-1">
<p>長い本文……</p>
<!-- たくさんのセクション -->
</main>
<button id="toTopBtn" type="button" hidden aria-label="ページの先頭へ">▲</button>
/* スクロールの既定 */
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
/* 固定ヘッダー対策 */
:where(h2, h3, [id]) { scroll-margin-top: 72px; }
/* トップボタン */
#toTopBtn {
position: fixed;
inset-block-end: 2rem;
inset-inline-end: 2rem;
padding: .75rem 1rem;
border-radius: 9999px;
}
[hidden]{ display:none !important; }
/* スキップリンク */
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: 1rem; top: 1rem; }
// IntersectionObserver で表示/非表示
const btn = document.getElementById('toTopBtn');
const sentinel = document.getElementById('top');
const io = new IntersectionObserver(([entry]) => {
btn.hidden = entry.isIntersecting;
});
io.observe(sentinel);
// クリック時のスクロール&フォーカス
btn.addEventListener('click', () => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, left: 0, behavior: reduce ? 'auto' : 'smooth' });
sentinel?.focus({ preventScroll: true });
});
必要に応じて、scrollend を使う処理を足す前に対応状況の確認とフォールバックを忘れずにどうぞ。
というわけで:最短で「気持ちいい戻り方」をつくる手順
- CSSで既定を決める。
- HTMLで戻り先を用意する。
- JSは最小限にする。
- 表示/非表示は IntersectionObserver。
- 固定ヘッダー対策は scroll-margin / scroll-padding。
scrollendは使えたらラッキー設計で。
背伸びは不要です。
順番だけ整えて、淡々と積み上げていきましょう。
参考リンク
- MDN: Window.scroll()
https://developer.mozilla.org/en-US/docs/Web/API/Window/scroll - MDN: Window.scrollTo()
https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo - MDN: Element.scrollTo()
https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo - MDN: Element.scrollIntoView()
https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView - MDN: CSS
scroll-behavior
https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior - MDN:
@media (prefers-reduced-motion)
https://developer.mozilla.org/en-US/docs/Web/CSS/%40media/prefers-reduced-motion - MDN: Intersection Observer API
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API - MDN: Document / Element
scrollendイベント
https://developer.mozilla.org/en-US/docs/Web/API/Document/scrollend_event
https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollend_event - MDN:
scroll-margin-top/scroll-padding-top
https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-margin-top
https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-padding-top - The A11Y Project: 有効でアクセスしやすいリンクの作り方
https://www.a11yproject.com/posts/creating-valid-and-accessible-links/ - web.dev: prefers-reduced-motion(解説)
https://web.dev/articles/prefers-reduced-motion






