こんにちは、かつコーチです。
前回は、フォームのリアルタイムバリデーションについて解説しました。
今回は、ページ内リンクをクリックしたときに「ガクッ」と一瞬で移動するのではなく、「スーッ」となめらかに移動する、あの動きを実装していきます。
「ページ内リンクを押すと一瞬でジャンプしてしまって、ユーザーが今どこにいるか分かりづらい」という悩みを解決する定番のテクニックです。
CSSだけでできる方法とJavaScriptで実装する方法、両方を比べながら見ていきましょう。
スムーススクロールとは?
ページ内リンクの「一瞬移動」問題
HTMLでは、<a href="#section2">のようにIDを指定したリンクをクリックすると、その要素の位置まで画面が移動します。
ただし、何も指定しなければ一瞬で移動先までジャンプしてしまい、ユーザーは「今どこに移動したのか」を把握しにくくなります。
スムーススクロールは、この移動をアニメーションのようになめらかに行う演出のことです。
移動元から移動先までの流れが視覚的につながるため、ユーザーはページのどのあたりに移動したのかを把握しやすくなります。
CSSだけで実現する方法とJavaScriptで実現する方法の違い
スムーススクロールには、大きく2つの実現方法があります。
| 方法 | 実装の手軽さ | 制御の自由度 |
|---|---|---|
CSSのscroll-behavior: smooth | 1行で実装できる | 速度やイージングを細かく調整できない |
| JavaScriptで実装 | やや手間がかかる | 速度・イージング・完了後の処理まで自由に制御できる |
まずはCSSだけの方法から見て、その限界を確認したうえでJavaScriptに進んでいきます。
CSSだけで実装する方法
scroll-behaviorプロパティ
もっとも手軽な方法は、CSSのscroll-behaviorプロパティを使う方法です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>CSSだけのスムーススクロール</title>
<style>
html {
scroll-behavior: smooth;
}
section {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
nav {
position: fixed;
top: 0;
background: white;
padding: 10px;
}
</style>
</head>
<body>
<nav>
<a href="#section1">セクション1へ</a>
<a href="#section2">セクション2へ</a>
</nav>
<section id="section1">セクション1</section>
<section id="section2">セクション2</section>
</body>
</html>
html要素にscroll-behavior: smoothを指定するだけで、ページ内のアンカーリンクがすべてなめらかにスクロールするようになります。
JavaScriptを1行も書かずに実現できるのは大きなメリットです。
CSSだけの方法の限界
一方で、scroll-behavior: smoothには次のような制約があります。
- スクロール速度やイージング(動きの緩急)を細かく調整できない
- 「固定ヘッダーの高さ分だけ手前で止める」といった位置調整ができない
- スクロール完了後に何か処理(フォーカス移動など)を挟みたい場合に対応できない
シンプルなサイトであればCSSだけで十分ですが、固定ヘッダーがあるサイトや、細かい挙動を制御したい場合はJavaScriptでの実装が必要になります。
JavaScriptで実装する方法
手順1:基本のscrollIntoViewを使う
JavaScriptでスムーススクロールを実装する場合、もっとも簡単なのはscrollIntoView()メソッドを使う方法です。
<nav>
<a href="#section1" class="nav-link">セクション1へ</a>
<a href="#section2" class="nav-link">セクション2へ</a>
</nav>
<section id="section1">セクション1</section>
<section id="section2">セクション2</section>
section {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
nav {
position: fixed;
top: 0;
left: 0;
width: 100%;
background: white;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
}
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault(); // デフォルトの一瞬ジャンプを止める
const targetId = link.getAttribute('href'); // 例: "#section1"
const targetEl = document.querySelector(targetId);
targetEl.scrollIntoView({
behavior: 'smooth', // なめらかにスクロール
block: 'start', // 要素の上端に合わせる
});
});
});
e.preventDefault()でリンクのデフォルト動作(一瞬ジャンプ)を止め、代わりにscrollIntoView({ behavior: 'smooth' })でなめらかに移動させています。
手順2:固定ヘッダーの高さ分ずらす
サイトに固定ヘッダーがある場合、scrollIntoView()だけだと見出しがヘッダーの下に隠れてしまうという問題が起きます。
これはwindow.scrollTo()を使って、自分でずらす分の計算をすることで解決します。
const headerHeight = document.querySelector('nav').offsetHeight;
navLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetId = link.getAttribute('href');
const targetEl = document.querySelector(targetId);
// 移動先の座標から、ヘッダーの高さ分を引く
const targetPosition = targetEl.getBoundingClientRect().top
+ window.pageYOffset
- headerHeight;
window.scrollTo({
top: targetPosition,
behavior: 'smooth',
});
});
});
getBoundingClientRect().topで「現在の画面上端から見た要素の位置」を取得し、window.pageYOffset(現在のスクロール量)を足すことで「ページ全体で見たときの要素の絶対位置」を計算しています。
そこから固定ヘッダーの高さを引くことで、「見出しがヘッダーに隠れない位置」まで正確にスクロールできます。
つまずきポイント:固定ヘッダーに見出しが隠れる
実際に見出しがヘッダーの下に埋もれた話
私が最初に固定ヘッダー付きのサイトでスムーススクロールを実装したとき、scrollIntoView()だけをそのまま使っていました。
❌ Before:scrollIntoViewだけで固定ヘッダーを考慮しない
navLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetEl = document.querySelector(link.getAttribute('href'));
targetEl.scrollIntoView({ behavior: 'smooth' });
});
});
このコードは固定ヘッダーがないページでは問題ないのですが、固定ヘッダーがあるページだと、移動後にセクションの見出しがちょうどヘッダーの真下に隠れてしまいます。
見た目上は「スクロールはできているのに、目的の見出しが見えない」という中途半端な状態になり、ユーザーからすると「あれ、リンクが壊れてる?」と誤解されかねません。
私はこの現象に気づかず、しばらく「なぜかスクロール位置が微妙にずれる」とだけ思っていて、原因が固定ヘッダーの高さだと気づくまでに時間がかかりました。
✅ After:CSSのscroll-margin-topで解決する
実は、この問題はscroll-margin-topというCSSプロパティを使うと、JavaScript側を変更せずに解決できます。
section {
scroll-margin-top: 70px; /* 固定ヘッダーの高さ分を確保する */
}
navLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetEl = document.querySelector(link.getAttribute('href'));
targetEl.scrollIntoView({ behavior: 'smooth' });
});
});
scroll-margin-topを移動先の要素に指定しておくと、scrollIntoView()やscroll-behavior: smoothでスクロールしたときに、その分だけ余白を空けて停止してくれます。
JavaScriptで座標計算をする方法(window.scrollTo())と、CSSのscroll-margin-topに任せる方法、どちらも正解ですが、固定ヘッダーの高さが変わらないサイトであればscroll-margin-topのほうがシンプルで保守しやすいです。
応用:URLのハッシュを変更せずにスクロールする
history.pushStateとの組み合わせ
<a href="#section1">をそのままクリックすると、URLの末尾に#section1が付与されます。
これはブックマークや共有には便利ですが、「見た目上URLを変えたくない」という要件のときは、history.pushState()を使わずにJavaScript側で移動処理だけ行う方法もあります。
navLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault(); // URLへのハッシュ付与そのものを止める
const targetEl = document.querySelector(link.getAttribute('href'));
targetEl.scrollIntoView({ behavior: 'smooth' });
// 必要な場合のみ、履歴を汚さずにURLを更新する
history.pushState(null, '', link.getAttribute('href'));
});
});
e.preventDefault()でブラウザ標準のジャンプとハッシュ付与を止めたうえで、必要であればhistory.pushState()で明示的にURLだけ更新する、という構成にすると、URLの扱いを自分でコントロールできます。
まとめ
この記事のポイント
- CSSの
scroll-behavior: smoothは1行で実装できるが、細かい制御はできない - JavaScriptでは
scrollIntoView({ behavior: 'smooth' })またはwindow.scrollTo()でスムーススクロールを実装する - 固定ヘッダーがあるサイトでは、見出しがヘッダーに隠れないよう位置調整が必要になる
- 位置調整は
scroll-margin-top(CSS)か、座標計算(JavaScript)のどちらかで解決できる - URLのハッシュ付与を制御したい場合は
history.pushState()と組み合わせる
次に読むべき記事
次回は、ドロップダウンメニューの実装を解説します。
→ 次の記事:ドロップダウンメニューを実装する