【完全版】スムーススクロールの実装は全てこれでいい【LazyLoad対応】

【完全版】スムーススクロールの実装は全てこれでいい【LazyLoad対応】
追記:2024年1月1日

コードを見直し細かい箇所をリファクタリングしました。主要ブラウザ全てで動作確認済みです。

JavaScriptを使った、全部入りのスムーススクロールの実装方法をご紹介します。

今回実装する内容は以下の通り。

対策済み

  • 固定ヘッダーの重なり
  • Lazy Load(遅延読み込み)による位置ずれ

全ページ対応

  • ページトップへ
  • ページ内のアンカーへ
  • 別ページ遷移後にアンカーへ

イージング(動き)にeaseOutExpoを採用し、より操作感の強いスムーススクロールにしています。

目次

ベースとなるコード

以下は、カスタマイズ前のベースとなるコードです。

// 固定ヘッダー(固定しない場合は = 0)
const headerHeight = document.querySelector('header').offsetHeight + 20;

// ページ内のスムーススクロール
for (const link of document.querySelectorAll('a[href*="#"]')) {
  link.addEventListener('click', (e) => {
    const hash = e.currentTarget.hash;
    const target = document.getElementById(hash.slice(1));

    // ページトップへ("#"と"#top")
    if (!hash || hash === '#top') {
      e.preventDefault();
      window.scrollTo({
        top: 1, // iOSのChromeで固定ヘッダーが動くバグがあるため0ではなく1に
        behavior: 'smooth',
      });

    // アンカーへ
    } else if (target) {
      e.preventDefault();
      const position = target.getBoundingClientRect().top + window.scrollY - headerHeight;
      window.scrollTo({
        top: position,
        behavior: "smooth",
      });

      // URLにハッシュを含める
      history.pushState(null, '', hash);
    }
  });
};

// 別ページ遷移後にスムーススクロール
const urlHash = window.location.hash;
if (urlHash) {
  const target = document.getElementById(urlHash.slice(1));
  if (target) {
    // ページトップから開始(ブラウザ差異を考慮して併用)
    history.replaceState(null, '', window.location.pathname);
    window.scrollTo(0, 0);

    window.addEventListener("load", () => {
      const position = target.getBoundingClientRect().top + window.scrollY - headerHeight;
      window.scrollTo({
        top: position,
        behavior: "smooth",
      });

      // ハッシュを再設定
      history.replaceState(null, '', window.location.pathname + urlHash);
    });
  }
}

こだわりがなければこちらで実装しても良いのですが、イージング関数遅延読み込みによる位置ずれ対策も施しておきたいと思います。

完全版スムーススクロールのコード

以下が、完全版となるスムーススクロールのコードです。

// 固定ヘッダー(固定しない場合は = 0)
const headerHeight = document.querySelector('header').offsetHeight + 20;

// イージング関数(easeOutExpo)
function scrollToPos(position) {
  const startPos = window.scrollY;
  const distance = Math.min(position - startPos, document.documentElement.scrollHeight - window.innerHeight - startPos);
  const duration = 800; // スクロールにかかる時間(ミリ秒)

  let startTime;

  function easeOutExpo(t, b, c, d) {
    return c * (-Math.pow(2, -10 * t / d) + 1) * 1024 / 1023 + b;
  }

  function animation(currentTime) {
    if (startTime === undefined) {
      startTime = currentTime;
    }
    const timeElapsed = currentTime - startTime;
    const scrollPos = easeOutExpo(timeElapsed, startPos, distance, duration);
    window.scrollTo(0, scrollPos);
    if (timeElapsed < duration) {
      requestAnimationFrame(animation);
    } else {
      window.scrollTo(0, position);
    }
  }

  requestAnimationFrame(animation);
}

// 遅延読み込み解除
function removeLazyLoad() {
  const targets = document.querySelectorAll('[data-src]');
  for (const target of targets) {
    target.setAttribute('src', target.getAttribute('data-src'));
    target.addEventListener('load', () => {
      target.removeAttribute('data-src');
    });
  }
}

// ページ内のスムーススクロール
for (const link of document.querySelectorAll('a[href*="#"]')) {
  link.addEventListener('click', (e) => {
    const hash = e.currentTarget.hash;
    const target = document.getElementById(hash.slice(1));

    // ページトップへ("#"と"#top")
    if (!hash || hash === '#top') {
      e.preventDefault();
      scrollToPos(1); // iOSのChromeで固定ヘッダーが動くバグがあるため0ではなく1に

    // アンカーへ
    } else if (target) {
      e.preventDefault();
      removeLazyLoad();
      const position = target.getBoundingClientRect().top + window.scrollY - headerHeight;
      scrollToPos(position);

      // URLにハッシュを含める
      history.pushState(null, '', hash);
    }
  });
};

// 別ページ遷移後のスムーススクロール
const urlHash = window.location.hash;
if (urlHash) {
  const target = document.getElementById(urlHash.slice(1));
  if (target) {
    // ページトップから開始(ブラウザ差異を考慮して併用)
    history.replaceState(null, '', window.location.pathname);
    window.scrollTo(0, 0);

    removeLazyLoad();
    window.addEventListener("load", () => {
      const position = target.getBoundingClientRect().top + window.scrollY - headerHeight;
      scrollToPos(position);

      // ハッシュを再設定
      history.replaceState(null, '', window.location.pathname + urlHash);
    });
  }
}

こちらを実装したデモサイトのフッターメニューで動きを確認できます。

  • ユーザー名:demo
  • パスワード:demo

固定ヘッダーの解説

// 固定ヘッダー(固定しない場合は = 0)
const headerHeight = document.querySelector('header').offsetHeight + 20;

固定ヘッダーの重なり対策として、ヘッダーの指定と、余白を設けたい場合は任意の数値(px単位)を追加します。

ヘッダーを固定しない場合は、headerHeight = 0にします。

イージング関数の解説

// イージング関数(easeOutExpo)
function scrollToPos(position) {
  const startPos = window.scrollY;
  const distance = Math.min(position - startPos, document.documentElement.scrollHeight - window.innerHeight - startPos);
  const duration = 800; // スクロールにかかる時間(ミリ秒)

  let startTime;

  function easeOutExpo(t, b, c, d) {
    return c * (-Math.pow(2, -10 * t / d) + 1) * 1024 / 1023 + b;
  }

  function animation(currentTime) {
    if (startTime === undefined) {
      startTime = currentTime;
    }
    const timeElapsed = currentTime - startTime;
    const scrollPos = easeOutExpo(timeElapsed, startPos, distance, duration);
    window.scrollTo(0, scrollPos);
    if (timeElapsed < duration) {
      requestAnimationFrame(animation);
    } else {
      window.scrollTo(0, position);
    }
  }

  requestAnimationFrame(animation);
}

イージング関数は自作が難しいので、ChatGPTに頼りながら作成しました。

easeOutExpoは、イーズアウト( 徐々に減速する)が最も強い動きになります。

このイージングを指定した理由は、何か指で弾いた時など、弾かれた瞬間が一番速く、摩擦で徐々に減速するといった物理法則に則った動きに最も近いからです。

このイージングを使用することで、動きにメリハリがつき、自己帰属感(操作の感覚)が増します。

イージングを変更したい場合は基本的にAIに頼るかググるしかありませんが、easeOutQuartへの変更例を掲載しておきます。

function easeOutExpo(t, b, c, d) {
  return c * (-Math.pow(2, -10 * t / d) + 1) * 1024 / 1023 + b;
}
↓
function easeOutQuart(t, b, c, d) {
  return -c * ((t = t / d - 1) * t * t * t - 1) + b;
}

const scrollPos = easeOutExpo( // ...
↓
const scrollPos = easeOutQuart( // ...

スクロールの最大距離を制限

const distance = Math.min(position - startPos, document.documentElement.scrollHeight - window.innerHeight - startPos);

スクロールの最大距離を、ページの一番下までに制限するよう調整しました。

理由は、ウインドウの高さが1000pxだとして、一番下にあるアンカーの要素が600pxしかない場合、残りの400pxフッターを超えてスクロールしようとするので、ぶつかるような不自然な動きで止まってしまうためです。

誤差によるズレを修正

if (timeElapsed < duration) {
  requestAnimationFrame(animation);
} else {
  window.scrollTo(0, position); // スクロール位置がずれないように修正
}

距離によっては、数ピクセル誤差によってズレが生じてしまう場合があるので、上記箇所でピッタリと指定位置にスクロールするよう修正しています。

イージング関数の使い方

デフォルトでよく使われているコードを、以下のように置き換えることでイージングを使用することができます。

window.scrollTo({
  top: position,
  behavior: "smooth",
});

↓

scrollToPos(position);

遅延読み込み解除の解説

このコードは、Lazy LoadのJSライブラリを利用している際に有効ですので、JSライブラリを使っていない場合は削除しても結構です。

遅延読み込みした際に、コンテンツがうまく読み取れずスクロール先の位置がずれるという場合の対策として、強制的に読み込みを行っています。

// 遅延読み込み解除
function removeLazyLoad() {
  // data-srcを変数に格納
  const targets = document.querySelectorAll('[data-src]');
  // 全てのdata-srcを取得
  for (const target of targets) {
    // srcにdata-srcの値をコピー
    target.setAttribute('src', target.getAttribute('data-src'));
    // ページを読み込んで処理
    target.addEventListener('load', () => {
      // data-srcを削除
      target.removeAttribute('data-src');
    });
  }
}

// 実行するタイミングで以下を記述
removeLazyLoad();

LazyLoad対策の詳しい内容は、以下の記事にまとめています。

ページ内のスムーススクロールの解説

// ページ内のスムーススクロール
for (const link of document.querySelectorAll('a[href*="#"]')) {
  // ハッシュを含むaタグをクリックした時の処理
  link.addEventListener('click', (e) => {
    // ハッシュを取得
    const hash = e.currentTarget.hash;
    // スクロール先を取得
    const target = document.getElementById(hash.slice(1));

    // ページトップへ("#"と"#top")
    if (!hash || hash === '#top') {
      // デフォルトの動作をキャンセル
      e.preventDefault();
      // スクロール開始
      scrollToPos(1); // iOSのChromeで固定ヘッダーが動くバグがあるため0ではなく1に

    // アンカーへ
    } else if (target) {
      // デフォルトの動作をキャンセル
      e.preventDefault();
      // 遅延読み込み解除
      removeLazyLoad();
      // スクロール先の位置を計算(ヘッダーの高さを引く)
      const position = target.getBoundingClientRect().top + window.scrollY - headerHeight;
      // スクロール実行
      scrollToPos(position);

      // URLにハッシュを含める
      history.pushState(null, '', hash);
    }
  });
};

こちらは解説の通りシンプルです。

ただ、ページトップへのスクロールでは、最上部0pxにスクロールすると固定ヘッダーがずれるバグがあるため1px下にずらして対策しています。

別ページ遷移後のスムーススクロールの解説

// 別ページ遷移後のスムーススクロール
// ハッシュを取得
const urlHash = window.location.hash;
// ハッシュが存在する時
if (urlHash) {
  // スクロール先を取得
  const target = document.getElementById(urlHash.slice(1));
  // スクロール先が存在する時
  if (target) {
    // ページトップから開始(ブラウザ差異を考慮して併用)
    history.replaceState(null, '', window.location.pathname); // ハッシュを削除
    window.scrollTo(0, 0); // ページトップへジャンプ

    // 遅延読み込み解除
    removeLazyLoad();
    // ページを読み込んで処理
    window.addEventListener("load", () => {
      // スクロール先の位置を計算(ヘッダーの高さを引く)
      const position = target.getBoundingClientRect().top + window.scrollY - headerHeight;
      // スクロール実行
      scrollToPos(position);

      // ハッシュを再設定
      history.replaceState(null, '', window.location.pathname + urlHash);
    });
  }
}

スクロール開始位置の指定

// ページトップから開始(ブラウザ差異を考慮して併用)
history.replaceState(null, '', window.location.pathname); // ハッシュを削除
window.scrollTo(0, 0); // ページトップへジャンプ

スクロールをページトップから開始させるための処理ですので、不要であれば削除してください。

上はURLからハッシュを削除して初期位置(トップ)へ遷移させる方法で、下は即座にページトップへジャンプさせる方法です。

ブラウザによってコードが効かない場合があったので、2種類のアプローチを併用しています。

スクロールさせるタイミングを変更する

正しい位置の取得やローディングアニメーションなどを考慮し、ウェブページの全てのリソース(画像、スタイルシート、スクリプトなど)が読み込まれた後にスクロールを実行しています。

スクロールをより早いタイミングで開始したい場合、以下のようにコードを変更できます。

// ページを読み込んで処理(やや遅い)
window.addEventListener("load", () => {
  // スクロール実行
});

↓

// 非同期処理(早い)
setTimeout(function () {
  // スクロール実行
}, 0);

遅延時間には 0 ミリ秒を指定していますが、必要に応じて任意の秒数を指定することもできます。

100m秒程度は遅らせた方が、正確な位置を取得しやすいかもしれません。

まとめ:実装は全部入りで解決

以上、完全版スムーススクロールのコードのご紹介でした。

必要に応じて削除、カスタマイズしてください。

出来るだけパフォーマンスが悪くならないようリファクタリングを行っていますが、もっと改良できる点があれば掲載します。

おすすめWEBスクール

WEB制作やWEBデザインを学びたいなら、SNSでも話題の「デイトラ」がおすすめ!
どのコースも10万円前後と業界最安値で、副業や転職に向けて十分なスキルを身につけることができます。

\ ここから飛べます! /

役に立ったら他の方にシェア

お気軽にコメントどうぞ

コメントする

目次