スムーススクロールの実装例
広告
スムーススクロールの実装メモです。このブログの見出しのページリンクやトップへ戻るボタンで使われている実装と同じものになります。
そもそもスムーススクロールは必要か?という議論は置いておいて、現在ではCSSのみでスムーススクロールの実装はできますが、当ブログではそれを使用せずにJSで実装を行っています。
スムーススクロールのコードと実装例
投稿日の翌日にリリース予定のFirefox 125によりポップオーバー APIが全モダンブラウザでサポートされるため、ハンバーガーメニューにはお試しでポップオーバー APIを使用しています。
JavaScriptの実装はGitHub Gistに纏めています。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。
CSSの scroll-behavior:smooth を使わない理由
前提として、スムーススクロールはJSを使わずともCSSでワンライナーで手軽に実装できます。
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のスムーススクロールが働いてしまうため、意図せぬスムーススクロールがされる可能性があります。
scrollTo()
メソッド自体はiOS Safari 10.3から使用できましたが、behavior
オプションは遅れて14からのサポートとなります。そのため、対応範囲によってはbehavior
オプションを指定していなかったり、そもそもbehavior
オプションを指定しなくても動作に支障が無いから初期値のauto
のまま実装しているといったケースが考えられます。そのため、即時スクロールが期待される箇所でもスムーススクロールが働いてしまうリスクがあることは覚えておいたほうがいいです。
例えば過去に僕が投稿した記事「モーダルを開いている時に背面コンテンツのスクロールを抑制する方法」では背面の固定を解除した後にscrollTo()
メソッドを使用してスクロール位置を復元しています。記事のアップデートにより現在はbehavior
オプションのinstant
が追加されていますが、記事のリリース時にはオプションを指定していないscrollTo()
メソッドを使用していたためスムーススクロールが発生し、結果として動作に支障が出ます。
自前で用意しているJSならともかく、サードパーティーのJSでオプションの無いscrollTo()
が使用されていると修正するコストが掛かったり、それを取り除く必要が出るかもしれません。それならばCSSのスムーススクロールは諦めて、自前のスムーススクロールを用意したほうがコストは抑えられるでしょう。
【要検証】ページ内検索もスムーススクロールされてしまう?
scroll-behavior:smooth
を使用したスムーススクロールはページ内検索時にも行われてしまうため、検索した語句を見つけるのに時間がかかりユーザビリティを下げてしまうと一時期話題になっていました。
これを対策するための手段として、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"
を付与してください。
画面固定配置のヘッダーのブロックサイズを取得する
画面固定配置のヘッダーが存在する場合は、そのヘッダーが固定配置されているかを確認し、ブロックサイズ(横書きの場合は縦幅、横書きの場合は横幅)を取得します。
scrollIntoView()でスクロールを行う
スクロールにはscrollIntoView()
メソッドを使用します。
scrollIntoView()
メソッドでは、固定ヘッダーのブロックサイズ分のオフセットを直接指定することができません。そのため、遷移先の要素にscroll-margin-block-start
プロパティを設定することで、固定ヘッダーの高さ分のオフセットを確保します。
スクロールはブロック方向(横書きの場合は縦方向、縦書きの場合は横方向)に行われるため、inline
オプションの設定は不要なのですが、ここではinline: 'end'
を指定しています。
ChromeやSafariではオプションを指定しない場合、ブロック方向の先頭(block: 'start'
)にスクロールされますが、Firefoxでは縦書きの場合にinline: 'end'
を指定しないとブロック方向の先頭にスクロールが行われません。
恐らくはFirefoxの独自の解釈によるものだと思われ、釈然としない気持ちは残りますが一旦はこのまま実装することとします。
デバイスで視差効果(アニメーション)を減らす設定がされている場合にはスムーススクロールは行わない
スクロールする際はwindow.matchMedia('(prefers-reduced-motion: reduce)').matches
でデバイスで視差効果(アニメーション)を減らす設定がされているかどうかを判定し、設定がされている場合は即時(instant
)、そうではない場合はスムース(smooth
)でスクロールを行うようにします。
一部のユーザーはWebサイトのアニメーション効果により前庭機能障害によるめまい、頭痛、吐き気などを引き起こす可能性があるため、アクセシビリティの観点からprefers-reduced-motion
によるアニメーションの無効化は行っておいたほうが良いでしょう。
遷移先にフォーカスを移動させる
JSでのスクロールではフォーカスが遷移先に移動しないため、element.focus({ preventScroll: true })
を使用してターゲット要素にフォーカスを移動させます。
この際、遷移先がフォーカスの当たらない見出し要素のような場合には一時的にtabindex="-1"
を付与し、再度フォーカスを行います。
なお、フォーカスされたtabindex="-1"
の要素にはoutline
が出現してしまうため、リセットCSSによっては既に含まれている記述ですが、もしも存在しないのなら以下の指定もしておくと良いでしょう。
特定条件ではスムーススクロールを無効化する
スムーススクロールを無効にする条件が満たされている場合には処理を中断するようにします。
中断する条件は次のとおりです。
- クリックされたマウスのボタンが左ボタン(
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
要素にスクロールを行います。
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フラグメントの書き換えを行います。
原則的にScroll Restorationがサポートされているモダンブラウザであればhistory.pushState()
の実行だけで履歴操作時のスクロール位置の復元は行われますが、SPAでは復元ができない場合もあります。その場合は使用しているフレームワークのドキュメントや対応する技術記事を参照してください。
また、ページトップの場合はURLフラグメントの書き換えのメリットがそこまでないこと、またid="top"
の指定をマークアップで行う必要はないという理由からハッシュが#top
の場合は書き換えを無効にしています。
今回の実装で対応していないこと
スクロールアニメーションの間隔、イージング
今回はCSSのスムーススクロールと同様にブラウザの振る舞いを利用しています。そのためカスタムでスクロールアニメーションの間隔やイージングの設定はできません。
もしもスクロールアニメーションの間隔やイージングのカスタムを求められる場合にはrequestAnimationFrame
などを使用して各々でアニメーションの実装を行ってください。
JS無効環境ではスムーススクロールしない
今回はJSで実装を行っているため、JS無効環境ではスムーススクロールは動作しませんが、ページ内リンク自体が動作すれば問題ない認識です。
一応、JS無効環境の場合はCSSのスムーススクロールを適応するように設定すればJS無効環境でもスムーススクロールは動作しますが、先述したscroll-behavior:smooth
の問題点とは向き合う必要があります。
実装コストは大して変わらない
結局のところ、JSを各案件で使い回せばいいだけなのでCSSのスムーススクロールもJSのスムーススクロールも実装コストは変わりません。
グローバルのCSSでhtml
要素にscroll-behavior:smooth
を指定するか、グローバルのJSでinitializeSmoothScroll()
を読み込むかの違いでしかないです。
もちろんパフォーマンス面はCSSの方に分がありますので、CSSとJS、どちらを使用するかは皆さんの判断にお任せします。
僕個人としてはscroll-behavior:smooth
の問題点を解決できる仕様変更や新たなプロパティが登場しない限りはhtml
要素にscroll-behavior:smooth
を指定するスムーススクロールを率先して利用することはないでしょう。