【JavaScript】スムーススクロールを実装する

javascript-icon JavaScript

こんにちは、かつコーチです。

前回は、フォームのリアルタイムバリデーションについて解説しました。

今回は、ページ内リンクをクリックしたときに「ガクッ」と一瞬で移動するのではなく、「スーッ」となめらかに移動する、あの動きを実装していきます。

「ページ内リンクを押すと一瞬でジャンプしてしまって、ユーザーが今どこにいるか分かりづらい」という悩みを解決する定番のテクニックです。

CSSだけでできる方法とJavaScriptで実装する方法、両方を比べながら見ていきましょう。

スムーススクロールとは?

ページ内リンクの「一瞬移動」問題

HTMLでは、<a href="#section2">のようにIDを指定したリンクをクリックすると、その要素の位置まで画面が移動します。

ただし、何も指定しなければ一瞬で移動先までジャンプしてしまい、ユーザーは「今どこに移動したのか」を把握しにくくなります。

スムーススクロールは、この移動をアニメーションのようになめらかに行う演出のことです。

移動元から移動先までの流れが視覚的につながるため、ユーザーはページのどのあたりに移動したのかを把握しやすくなります。

CSSだけで実現する方法とJavaScriptで実現する方法の違い

スムーススクロールには、大きく2つの実現方法があります。

方法実装の手軽さ制御の自由度
CSSのscroll-behavior: smooth1行で実装できる速度やイージングを細かく調整できない
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()と組み合わせる

次に読むべき記事

次回は、ドロップダウンメニューの実装を解説します。

→ 次の記事:ドロップダウンメニューを実装する

タイトルとURLをコピーしました