Web production note

 【更新日 :

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

Category:
JavaScript

JavaScriptでrequestAnimationFrameを用いたスムーススクロールを実装したサンプルです。
イージングのカスタマイズにも対応しています。

2024.04.16 更新
※今回追加した機能は「スムーススクロールの実装例 | TAKLOG」を参考にさせていただきました。
(より柔軟に多機能な実装を目指す場合は、上記参考サイト様の実装例を参照することをオススメします。)

  • 遷移先にフォーカスを移動させる機能を追加。
  • アニメーション(視差効果)を減らす設定が有効な場合はアニメーションを切る機能を追加。

2024.02.15 更新

  • クリックしたアンカーリンクをURLの履歴に追加する機能を追加。

2024.01.19 更新

  • 遷移先が存在しない場合に起こる不具合の修正。
  • scroll-margin-top、scroll-padding-topによる位置変更に対応。
  • イージング設定をjQueryベースのものからよりメジャーな「https://easings.net/ja」に変更。
  • href="#top"href="#" の場合、ページの最上部へ戻ることができるHTML標準の仕様に対応。

codepen

See the Pen Smooth Scroll JavaScript (Easing support) by web_nak (@web_walking_nak) on CodePen.

HTML

HTMLの仕様通りにアンカーリンクを設定してください。

<p><a href="#img">画像まで移動</a></p>
<p id="img"><img src="https://picsum.photos/id/1003/500/600" alt="" width="500" height="600"></p>

JavaScript

イージング設定やスクロール速度はサイトに合わせて編集してください。

//スムーススクロール関数
const smoothScroll = () => {
	//スクロールリンク取得
	const targets = document.querySelectorAll('a[href^="#"]');
	if(targets.length === 0) {
		return;
	}

	//スクロール速度
	const ANIME_SPEED = 600;
	//スクロール移動の実行中にbodyへ付与するclass
	const SCROLL_BUSY_CLASS = 'is-scroll-busy';

	const bodyClassList = document.body.classList;

	/*
		スクロールアニメーションイージング設定
		https://easings.net/ja
	*/
	const easing = (x) => {
		//easeInOutQuad
    return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
  }

	//スクロール停止要素取得
	const getScrollStopTarget = (target) => {
		//hrefから遷移先を取得
		const href = target.getAttribute('href');
		let scrollStopTarget = null;
		let selector = href;
		//#top(HTMLの仕様でページの最上部へ戻るアンカーリンク)が手動設定されているかチェック
		//https://html.spec.whatwg.org/multipage/browsing-the-web.html#scroll-to-the-fragment-identifier
		if(href === '#top') {
			scrollStopTarget = document.querySelector(href);
		}
		//#topが手動設定されていない場合はhrefから遷移先を取得
		if(!scrollStopTarget) {
			//#や#topの場合はページの最上部へ戻るbodyを遷移先に設定
			selector = (href === '#' || href === '#top') || href === '' ? 'body' : href;
			scrollStopTarget = document.querySelector(selector);
		}
		return scrollStopTarget;
	}

	//アニメーション実行中class削除
	const resetBodyBusyClass = () => {
		bodyClassList.remove(SCROLL_BUSY_CLASS);
	}

	//スクロール停止位置取得
	const getScrollStopPosition = (scrollStopTarget) => {
		//スクロール位置の調整
		//scroll-margin-top、scroll-padding-topを取得。相対値による小数点は四捨五入
		const scrollStopTargetStyles = getComputedStyle(scrollStopTarget);
		const scrollPaddingTop = scrollStopTargetStyles.scrollPaddingTop;
		const shiftPosition = Math.round(parseFloat(scrollStopTargetStyles.scrollMarginTop)) + Math.round(parseFloat(scrollPaddingTop === 'auto' ? 0 : scrollPaddingTop));
		//移動先取得
		return Math.round(scrollStopTarget.getBoundingClientRect().top) - shiftPosition;
	}

	//フォーカス移動
	const changeFocus = (scrollStopTarget) => {
		scrollStopTarget.focus();
		//ターゲット要素がフォーカス可能な要素以外の場合
		if (document.activeElement !== scrollStopTarget) {
			scrollStopTarget.setAttribute('tabindex', '-1');
			scrollStopTarget.focus();
			//フォーカスアウトラインを非表示
			scrollStopTarget.style.outline = 'none';
		}
	}

	//スクロール終了処理
	const endFunc = (scrollStopTarget) => {
		//アニメーション実行中class削除
		resetBodyBusyClass();
		//フォーカス移動
		changeFocus(scrollStopTarget);
	}

	//スクロールアニメーション
	const raf = requestAnimationFrame;
	const scrollAnime = (scrollStopTarget,scrollStopPosition) => {
		//現在のスクロール量
		const scrollTop = window.scrollY;

		//アニメーション開始時間
		let startTime;

		//アニメーション関数
		const startAnime = (timestamp) => {

			//イベント発生後の経過時間
			// startTime未定義時のみtimestampを代入することで一度だけstartTimeに数値を格納する
			if (startTime === undefined) {
				startTime = timestamp;
			}

			// 経過時間を監視
			const elapsedTime = timestamp - startTime;

			//アニメーション終了処理
			if (elapsedTime > ANIME_SPEED) {
				endFunc(scrollStopTarget);

				//処理を終了
				return;
			}

			//進捗度を算出してイージングを適用
			//elapsedTimeを0からANIME_SPEEDの値で正規化(0〜1の値に変換)
			const elapsedTimeNorm = elapsedTime / ANIME_SPEED;

			//位置設定
			const scrollY = scrollTop + scrollStopPosition * easing(elapsedTimeNorm);

			//スクロール処理
			window.scrollTo(
				0,
				scrollY
			);
			//アニメーション関数再実行
			raf(startAnime);
		}

		//初回アニメーション呼び出し
		raf(startAnime);
	}

	//クリックイベント設定
	targets.forEach((target) => {
		target.addEventListener('click', event => {
			event.preventDefault();

			//スクロール停止要素取得
			const scrollStopTarget = getScrollStopTarget(target);

			//遷移先が存在しない場合は処理しない
			if (!scrollStopTarget) {
				resetBodyBusyClass();
				return;
			}

			//スクロールイベント重複防止
			if (bodyClassList.contains(SCROLL_BUSY_CLASS)) {
				return;
			}
			bodyClassList.add(SCROLL_BUSY_CLASS);

			//スクロール停止位置取得
			const scrollStopPosition = getScrollStopPosition(scrollStopTarget);

			//視差効果を減らす設定がある場合はアニメーションしない
			if(matchMedia('(prefers-reduced-motion: reduce)').matches) {
				window.scrollTo(
					0,
					scrollStopPosition
				);
				endFunc(scrollStopTarget);

			} else {
				scrollAnime(scrollStopTarget,scrollStopPosition);
			}

		});
	});
}

//スムーススクロール関数呼び出し
smoothScroll();

関数の呼び出し

任意の場所で以下を実行し関数を呼び出してください。

//スムーススクロール関数呼び出し
smoothScroll();

小数点まで正確に位置を割り出したい場合

小数点以下はMath.round()で四捨五入して整数値に丸めています。

何らかの理由で正確に算出したい場合は、移動先の位置を取得する関数getScrollStopPosition()内のMath.round()を削除してください。

before

//スクロール停止位置取得
const getScrollStopPosition = (scrollStopTarget) => {
  //scroll-margin-top、scroll-padding-topを取得。相対値による小数点は四捨五入
  const scrollStopTargetStyles = getComputedStyle(scrollStopTarget);
  const scrollPaddingTop = scrollStopTargetStyles.scrollPaddingTop;

  /*
      それぞれの値に Math.round() を設定して
      小数点以下を四捨五入している
  */
  const shiftPosition = Math.round(parseFloat(scrollStopTargetStyles.scrollMarginTop)) + Math.round(parseFloat(scrollPaddingTop === 'auto' ? 0 : scrollPaddingTop));
  return Math.round(scrollStopTarget.getBoundingClientRect().top) - shiftPosition;
}

after

//スクロール停止位置取得
const getScrollStopPosition = (scrollStopTarget) => {
  const scrollStopTargetStyles = getComputedStyle(scrollStopTarget);
  const scrollPaddingTop = scrollStopTargetStyles.scrollPaddingTop;

  /*
      小数点まで正確に位置を割り出したい場合は、
      3箇所設定している Math.round() を削除
  */
  const shiftPosition = parseFloat(scrollStopTargetStyles.scrollMarginTop) + parseFloat(scrollPaddingTop === 'auto' ? 0 : scrollPaddingTop);
  return scrollStopTarget.getBoundingClientRect().top - shiftPosition;
}

参考リンク