Patterns

スムーススクロールの実装例

スムーススクロールの実装メモです。このブログの見出しのページリンクやトップへ戻るボタンで使われている実装と同じものになります。

そもそもスムーススクロールは必要か?という議論は置いておいて、現在ではCSSのみでスムーススクロールの実装はできますが、当ブログではそれを使用せずにJSで実装を行っています。

この記事は2024年4月時点の当ブログの実装です。現在の当ブログはJSのスムーススクロールを使っておらず、CSSのscroll-behavior: smoothを次の条件に絞って適用しています。

  • ページの読み込みが終わった後(外からのアンカーリンクで開いた時はスムーススクロールしない)
  • キーボード操作でフォーカスしている要素がない時
  • 視差効果(アニメーション)を減らす設定がされていない時

また、記事で触れているタブメニューやアコーディオンをa要素とhidden="until-found"で作る実装も、現在の当ブログでは使っていません。

スムーススクロールのコードと実装例

スムーススクロールの実装例

投稿日の翌日にリリース予定のFirefox 125によりポップオーバー APIが全モダンブラウザでサポートされるため、ハンバーガーメニューにはお試しでポップオーバー APIを使用しています。

JavaScriptの実装はGitHub Gistに纏めています。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。

initializeSmoothScroll

initializeSmoothScroll. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

CSSの scroll-behavior:smooth を使わない理由

前提として、スムーススクロールはJSを使わずともCSSでワンライナーで手軽に実装できます。

CSSのスムーススクロール
html {
	scroll-behavior: smooth;
}

html要素にscroll-behavior:smoothを指定するだけのコストの低さ、それでいて従来のアンカーリンクの機能を損ねないことから「スムーススクロールはCSSのみで十分」といった技術記事やポストが多く広まっています。

ただ、そういった技術記事やポストは「CSSのみで対応できる」「ワンライナーで実装できる」と言ったコストの低さばかりが先行していて、肝心のデメリットには触れていません。

実際、CSSのスムーススクロールには多くの問題点が孕んでいます。

全てのページ内リンクがスムーススクロールされる

CSSのscroll-behavior:smoothを使用すると、ページ内の全てのアンカーリンクがスムーススクロールの対象となります。そのため、限定的にスムーススクロールを無効化したいと言った場合は別途JSでの対応が必要となります。

僕のブログではJS無効環境のフォールバックとしてhidden="until-found"が使用されているタブメニューとアコーディオンのトリガーはa要素で実装を行っていますが、JS無効環境にて不要なスムーススクロールが発生してしまうためCSSのスムーススクロールとはミスマッチです。

外からのアンカーリンクでもスクロールされてしまう

外部のページやブックマーク、アドレスバーのコピペからアンカーリンクを使ってページ内の特定の位置にジャンプする場合にも即時のジャンプではなくスムーススクロールが発生してしまいます。

アドレスバーからアンカーリンクを表示した時の挙動

当ブログではセクションリンクを導入しておりますが、これを利用する場合に無駄なスクロールはユーザー体験を損ねるため邪魔になるでしょう。

また、某ファストフード店のリニューアル後のWebサイトのUI/UXが話題になっていましたが、まさにこの「外からのアンカーリンクでもスクロールされてしまう」挙動がユーザー体験を悪くしてしまうサンプルとして働いている印象です。

ポストを別枠で表示する

Tab操作もスムーススクロールされてしまう

scroll-behavior:smoothはTab操作時の移動でもスムーススクロールが発生します。そして厄介なことにTabを押しっぱなしor連打した場合はスムーススクロールがフォーカスの移動に追いつかず、あたかも詰まったかのような動きがされてしまいます。

また、後述する「ページ内検索もスムーススクロールできる」点がユーザー体験を損ねると話題になっていたのにも関わらず、似たような挙動をするTab操作でのスムーススクロールを許容するのは「ちょっと何言ってるか分からない」という感想なので僕は不要だと判断しました。

意図せぬscrollTo()メソッドのスムーススクロールがされる可能性がある

scrollTo()メソッドのbehaviorオプションがinstantに設定されていない場合はCSSのスムーススクロールが働いてしまうため、意図せぬスムーススクロールがされる可能性があります。

CSSのスムーススクロールが働く
element.scrollTo(0, 100) // `behavior`オプションの初期値は`auto`
CSSのスムーススクロールが働かない
element.scrollTo({ top: 100, behavior: 'instant' })

scrollTo()メソッド自体はiOS Safari 10.3から使用できましたが、behaviorオプションは遅れて14からのサポートとなります。そのため、対応範囲によってはbehaviorオプションを指定していなかったり、そもそもbehaviorオプションを指定しなくても動作に支障が無いから初期値のautoのまま実装しているといったケースが考えられます。そのため、即時スクロールが期待される箇所でもスムーススクロールが働いてしまうリスクがあることは覚えておいたほうがいいです。

例えば過去に僕が投稿した記事「モーダルを開いている時に背面コンテンツのスクロールを抑制する方法(新しいタブで開く)」では背面の固定を解除した後にscrollTo()メソッドを使用してスクロール位置を復元しています。記事のアップデートにより現在はbehaviorオプションのinstantが追加されていますが、記事のリリース時にはオプションを指定していないscrollTo()メソッドを使用していたためスムーススクロールが発生し、結果として動作に支障が出ます。

`scroll-behavior:smooth`を適用し、`scrollTo`のオプションを省略した時の挙動

自前で用意しているJSならともかく、サードパーティーのJSでオプションの無いscrollTo()が使用されていると修正するコストが掛かったり、それを取り除く必要が出るかもしれません。それならばCSSのスムーススクロールは諦めて、自前のスムーススクロールを用意したほうがコストは抑えられるでしょう。

【要検証】ページ内検索もスムーススクロールされてしまう?

scroll-behavior:smoothを使用したスムーススクロールはページ内検索時にも行われてしまうため、検索した語句を見つけるのに時間がかかりユーザビリティを下げてしまうと一時期話題になっていました。

scroll-behavior: smooth;によるページ内検索時のスクロールを除外するテクニック

ページにhtml {scroll-behavior: smooth;}を加えるだけで、簡単にページ内のスクロールのすべてがスムーズになります。一昔前はJavaScriptやjQueryのプラグインで実

coliss.com

これを対策するための手段として、html要素全体ではなくhtml:focus-withinにscroll-behavior:smoothを指定することでページ内検索時のスクロールを除外できると参考リンクでは紹介されています。

ただし、この方法だと遷移時にhtml要素内にフォーカスが存在しないとscroll-behavior:smoothが機能しないため、各見出しにtabindex="-1"を指定するコストが掛かったり、ややハック的な手段としてanimationプロパティで切り替えたり…と言った実装の追加が必要となります。

また、実装を行いページ内検索時のスムーススクロールを除外したところで他の問題点は解決できなかったり、そもそもこのテクニックを使用すると現在でもSafariでスムーススクロールが機能しなくなるといった別の問題も生じてしまうため、導入する価値があるかどうかは皆さんの判断にお任せしますと言ったところでしょうか。

ちなみにこの記事を投稿するにあたりテストを行ったところ、僕の環境ではscroll-behavior:smoothをhtmlに指定してもサイト内検索でスムーススクロールは発生しなかったので、要検証としています。

JSの実装の解説

JSのコードの解説は次のとおりです。

マークアップはヘッダーの画面固定配置が行われている場合は該当のヘッダーにdata-fixed-headerを、スムーススクロールを無効にしたいアンカーリンクにはdata-smooth-scroll="disabled"を付与してください。

<header data-fixed-header>...</header>

<a href="#foo">スムーススクロールが期待されるアンカーリンク</a>
<a href="#bar" data-smooth-scroll="disabled">スムーススクロールが不要なアンカーリンク</a>

画面固定配置のヘッダーのブロックサイズを取得する

画面固定配置のヘッダーが存在する場合は、そのヘッダーが固定配置されているかを確認し、ブロックサイズ(横書きの場合は縦幅、縦書きの場合は横幅)を取得します。

// 固定配置のヘッダーのブロックサイズを取得
const getHeaderBlockSize = (): string => {
  const header = document.querySelector('[data-fixed-header]') as HTMLElement;

  if (!header) return '0';

  const { position, blockSize } = window.getComputedStyle(header);
  const isFixed = position === 'fixed' || position === 'sticky';

  return isFixed ? blockSize : '0';
}

scrollIntoView()でスクロールを行う

スクロールにはscrollIntoView()メソッドを使用します。

const scrollToTarget = (element: HTMLElement): void => {
  const headerBlockSize = getHeaderBlockSize()
  // 固定配置のヘッダーのブロックサイズを`scrollMarginBlockStart`に設定
  element.style.scrollMarginBlockStart = headerBlockSize

  element.scrollIntoView({ inline: 'end' })
}

scrollIntoView()メソッドでは、固定ヘッダーのブロックサイズ分のオフセットを直接指定することができません。そのため、遷移先の要素にscroll-margin-block-startプロパティを設定することで、固定ヘッダーの高さ分のオフセットを確保します。

const scrollToTarget = (element: HTMLElement): void => {
  const headerBlockSize = getHeaderBlockSize()
  // 固定配置のヘッダーのブロックサイズを`scrollMarginBlockStart`に設定
  element.style.scrollMarginBlockStart = headerBlockSize

  element.scrollIntoView({ inline: 'end' })
}

スクロールはブロック方向(横書きの場合は縦方向、縦書きの場合は横方向)に行われるため、inlineオプションの設定は不要なのですが、ここではinline: 'end'を指定しています。

const scrollToTarget = (element: HTMLElement): void => {
  const headerBlockSize = getHeaderBlockSize()
  // 固定配置のヘッダーのブロックサイズを`scrollMarginBlockStart`に設定
  element.style.scrollMarginBlockStart = headerBlockSize

  element.scrollIntoView({ inline: 'end' })
}

ChromeやSafariではオプションを指定しない場合、ブロック方向の先頭(block: 'start')にスクロールされますが、Firefoxでは縦書きの場合にinline: 'end'を指定しないとブロック方向の先頭にスクロールが行われません。

恐らくはFirefoxの独自の解釈によるものだと思われ、釈然としない気持ちは残りますが一旦はこのまま実装することとします。

デバイスで視差効果(アニメーション)を減らす設定がされている場合にはスムーススクロールは行わない

スクロールする際はwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesでデバイスで視差効果(アニメーション)を減らす設定がされているかどうかを判定し、設定がされている場合は即時(instant)、そうではない場合はスムース(smooth)でスクロールを行うようにします。

const scrollToTarget = (element: HTMLElement): void => {
  const headerBlockSize = getHeaderBlockSize()
  // 固定配置のヘッダーのブロックサイズを`scrollMarginBlockStart`に設定
  element.style.scrollMarginBlockStart = headerBlockSize
  // ユーザーが視差効果を減らす設定をしているかどうかを判定
  const isPrefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  // 視差効果を減らす設定がされている場合は 'instant'、そうでない場合は 'smooth' にスクロール動作を設定
  const scrollBehavior = isPrefersReduced ? 'instant' : 'smooth'
  // 縦書きの場合は左スクロール、横書きの場合は上スクロールを実行
  element.scrollIntoView({ behavior: scrollBehavior, inline: 'end' })
}

一部のユーザーはWebサイトのアニメーション効果により前庭機能障害によるめまい、頭痛、吐き気などを引き起こす可能性があるため、アクセシビリティの観点からprefers-reduced-motionによるアニメーションの無効化は行っておいたほうが良いでしょう。

ユーザーのプリファレンスに応じて過度なアニメーションを無効にする「prefers-reduced-motion」 | Accessible & Usable

ウェブコンテンツにおけるアニメーションは、アクセシビリティの問題を引き起こすことがあります。CSS の「prefers-reduced-motion」を用いることで、ユーザーのプリファレンス設定に応じて過度なアニメーションを無効にすることができますが、これを免罪符にしないようにしたいものです。

accessible-usable.net

遷移先にフォーカスを移動させる

JSでのスクロールではフォーカスが遷移先に移動しないため、element.focus({ preventScroll: true })を使用してターゲット要素にフォーカスを移動させます。

const focusTarget = (element: HTMLElement): void => {
  // ターゲット要素にフォーカスを設定
  element.focus({ preventScroll: true })
  // アクティブな要素がターゲット要素でない場合
  if (document.activeElement !== element) {
    // ターゲット要素のtabindexを一時的に-1に設定
    element.setAttribute('tabindex', '-1')
    // 再度フォーカスを設定
    element.focus({ preventScroll: true })
  }
}

この際、遷移先がフォーカスの当たらない見出し要素のような場合には一時的にtabindex="-1"を付与し、再度フォーカスを行います。

なお、フォーカスされたtabindex="-1"の要素にはoutlineが出現してしまうため、リセットCSSによっては既に含まれている記述ですが、もしも存在しないのなら以下の指定もしておくと良いでしょう。

[tabindex='-1']:focus-visible {
	outline: none !important;
}

特定条件ではスムーススクロールを無効化する

スムーススクロールを無効にする条件が満たされている場合には処理を中断するようにします。

const handleClick = (event: MouseEvent): void => {
  // クリックされたボタンが左ボタンでない場合は処理を中断
  if (event.button !== 0) return
  // クリックされたリンク要素を取得
  const currentLink = (event.target as HTMLElement).closest<HTMLAnchorElement>('a[href*="#"]')
  if (!currentLink) return

  const hash = currentLink.hash
  // スムーススクロールを無効にする条件をチェックし、スムーススクロールを無効にする場合は処理を中断
  if (
    !hash ||
    currentLink.getAttribute('role') === 'tab' ||
    currentLink.getAttribute('role') === 'button' ||
    currentLink.getAttribute('data-smooth-scroll') === 'disabled'
  )
    return

  // ...
}

中断する条件は次のとおりです。

  • クリックされたマウスのボタンが左ボタン(event.button === 0)でない場合
  • クリックされた要素がa要素またはa要素の子孫ではない場合
  • hash(リンクのハッシュ部分)が存在しない場合
  • クリックされたa要素のrole属性がtabである場合
  • クリックされたa要素のrole属性がbuttonである場合
  • クリックされたa要素にdata-smooth-scroll="disabled"が指定されている場合

当ブログではa要素とhidden="until-found"でタブメニューやアコーディオンを作成しているため、これらのクリック時にはスムーススクロールを無効化します。

アンカーリンクのハッシュ部分からターゲット要素を取得し、スムーススクロールを行う

document.getElementById(decodeURIComponent(hash.slice(1)))でアンカーリンクのハッシュ部分からターゲット要素を取得し、また#topの場合はbody要素にスクロールを行います。

const handleClick = (event: MouseEvent): void => {
  // ...

  const hash = currentLink.hash

  // ...

  // アンカーリンクのハッシュ部分からターゲット要素を取得
  const target = document.getElementById(decodeURIComponent(hash.slice(1))) || (hash === '#top' && document.body)

  if (target) {
    // デフォルトのリンク遷移を防止
    event.preventDefault()
    // ターゲット要素までスムースにスクロール
    scrollToTarget(target)
    // ターゲット要素にフォーカスを設定
    focusTarget(target)
    // ブラウザの履歴にアンカーリンクのハッシュを追加
    if (!(hash === '#top')) {
      history.pushState({}, '', hash)
    }
  }
}

hash変数には、クリックされたアンカーリンクのハッシュ部分(例:#section1)が格納されます。そのままだとdocument.getElementById()で参照できないのでslice(1)メソッドを使用して、ハッシュ記号(#)以降の部分を切り出します。

document.querySelector()を使えばいいじゃんと思われる方もいるでしょうが、querySelectorはCSSセレクタを指定するものなので先頭数字のidはエラーになってしまいます。当ブログで使用されているAstro+MDXのように、記事の投稿方法によっては見出しタイトルがそのままidに出力されるケースも多く、数字始まりの見出しだと遷移できなくなります。別途エスケープを行うよりもslice(1)メソッドを使用してdocument.getElementById()で参照したほうが記述量は少なくなるので、こちらの方法で参照しています。

また、ターゲット要素のidに特殊文字が含まれている場合でも正しく取得できるようにするためにdecodeURIComponent()関数で別途デコードも行います。

加えて、#topにアンカーリンクするとページ先頭へスクロールするHTMLの仕様(新しいタブで開く)を尊重し、#topの場合はbody要素にスクロールを行うようにします。

#topでのページ先頭へのスクロールはSafariやFirefoxではフォーカスの消失や移動がされない問題を孕んでいますが、今回の実装ではその点をカバーしています。

URLフラグメントを書き換える

ページ内リンクを擬似的に再現するため、またブラウザの「戻る」「進む」機能を使えるようにするためにhistory.pushState()でURLフラグメントの書き換えを行います。

const handleClick = (event: MouseEvent): void => {
  // ...

  const hash = currentLink.hash

  // ...

  // アンカーリンクのハッシュ部分からターゲット要素を取得
  const target = document.getElementById(decodeURIComponent(hash.slice(1))) || (hash === '#top' && document.body)

  if (target) {
    // デフォルトのリンク遷移を防止
    event.preventDefault()
    // ターゲット要素までスムースにスクロール
    scrollToTarget(target)
    // ターゲット要素にフォーカスを設定
    focusTarget(target)
    // ブラウザの履歴にアンカーリンクのハッシュを追加
    if (!(hash === '#top')) {
      history.pushState({}, '', hash)
    }
  }
}

原則的にScroll Restorationがサポートされているモダンブラウザであればhistory.pushState()の実行だけで履歴操作時のスクロール位置の復元は行われますが、SPAでは復元ができない場合もあります。その場合は使用しているフレームワークのドキュメントや対応する技術記事を参照してください。

また、ページトップの場合はURLフラグメントの書き換えのメリットがそこまでないこと、またid="top"の指定をマークアップで行う必要はないという理由からハッシュが#topの場合は書き換えを無効にしています。

今回の実装で対応していないこと

スクロールアニメーションの間隔、イージング

今回はCSSのスムーススクロールと同様にブラウザの振る舞いを利用しています。そのためカスタムでスクロールアニメーションの間隔やイージングの設定はできません。

もしもスクロールアニメーションの間隔やイージングのカスタムを求められる場合にはrequestAnimationFrameなどを使用して各々でアニメーションの実装を行ってください。

Canvasだけじゃない!requestAnimationFrameを使ったアニメーション表現 - ICS MEDIA

requestAnimationFrame()というとCanvasAPIなどを使ったクリエイティブコーディングでよく使われる手法ですが、クリエイティブコーディングに限ったものではなく、一般的なウェブサイトのアニメーション表現にも役立ちます。

ics.media

JS無効環境ではスムーススクロールしない

今回はJSで実装を行っているため、JS無効環境ではスムーススクロールは動作しませんが、ページ内リンク自体が動作すれば問題ない認識です。

一応、JS無効環境の場合はCSSのスムーススクロールを適用するように設定すればJS無効環境でもスムーススクロールは動作しますが、先述したscroll-behavior:smoothの問題点とは向き合う必要があります。

JS無効の場合のみCSSのスムーススクロールを適用する
@media (scripting: none) {
	html {
		scroll-behavior: smooth;
	}
}

実装コストは大して変わらない

結局のところ、JSを各案件で使い回せばいいだけなのでCSSのスムーススクロールもJSのスムーススクロールも実装コストは変わりません。

グローバルのCSSでhtml要素にscroll-behavior:smoothを指定するか、グローバルのJSでinitializeSmoothScroll()を読み込むかの違いでしかないです。

import initializeSmoothScroll from '@/scripts/initializeSmoothScroll'

document.addEventListener('DOMContentLoaded', () => {
	initializeSmoothScroll()
})

もちろんパフォーマンス面はCSSの方に分がありますので、CSSとJS、どちらを使用するかは皆さんの判断にお任せします。

僕個人としてはscroll-behavior:smoothの問題点を解決できる仕様変更や新たなプロパティが登場しない限りはhtml要素にscroll-behavior:smoothを指定するスムーススクロールを率先して利用することはないでしょう。

参考リンク