requestAnimationFrameで実現する、スクロール方向に応じたUI切り替え

スクロールの上下方向に応じて、UIの見た目を変化させたい場面は意外と多くあります。

その都度スクロール方向を判定する方法もありますが、body要素等にカスタムデータ属性でグローバルにスクロール方向を保持するという手法が、複数箇所で使いまわせて便利だと最近気がつきました。

コードの全体像

さっそくですが、スクロール方向をdata-scroll-directionというカスタムデータ属性で管理するコード例を紹介します。

script.js
const initScrollDirectionDetection = () => {
let lastScrollY = window.scrollY;
let isScheduled = false;
// スクロール方向を更新する関数
const updateScrollDirection = () => {
const currentScrollY = window.scrollY;
const direction = currentScrollY > lastScrollY ? "down" : "up";
if (document.body.dataset.scrollDirection !== direction) {
document.body.dataset.scrollDirection = direction;
}
lastScrollY = currentScrollY;
isScheduled = false;
};
window.addEventListener("scroll", () => {
if (!isScheduled) {
requestAnimationFrame(updateScrollDirection);
isScheduled = true;
}
});
};
initScrollDirectionDetection();

スクロール方向に応じて、body要素のdata-scroll-direction属性が次のように動的に変化します:

  • 下方向にスクロール:down
  • 上方向にスクロール:up
data-scroll-direction属性が変化する様子

ページロード直後の初期状態ではスクロールイベントが発生していないため、data-scroll-direction属性は追加されません。

ヘッダーをスクロール方向に応じて表示/非表示を切り替える

実務でよくあるユースケースとして、「スクロール方向に応じてヘッダーを見え隠れさせる」といった実装があります。

以下の例では、下方向にスクロールするとヘッダーが非表示になり、上方向にスクロールすると再び表示されるという動きを実現しています。

スクロールに応じたUI切り替えの例

この挙動は、スクロール時のdata-scroll-direction属性に応じて、ヘッダーのtransformの値を変更することで実現しています。

style.css
/* スクロール時 */
body[data-scroll-direction="down"] {
/* ヘッダーの高さ分だけ上に移動(-1pxは余裕を持たせるため) */
--header-translate-y: calc(-100% - 1px);
}
.l-header {
position: sticky;
inset: 0 0 auto 0;
transition: transform 0.3s;
transform: translateY(var(--header-translate-y));
}

このような実装は、ヘッダーが比較的大きい場合や、コンテンツの閲覧領域を少しでも広く確保したい場合に有効だと感じます。ヘッダーの動きのほかに以下のような事例にも活用できます。

  • 下方向スクロール時にフローティングボタンを隠す
  • 上方向スクロール時に「トップへ戻る」ボタンを表示
style.css
/* フローティングボタンを隠す */
body[data-scroll-direction="down"] {
--floating-button-opacity: 0;
}
.floating-button {
position: fixed;
inset: auto 0 0 auto;
transition: opacity 0.3s;
opacity: var(--floating-button-opacity);
pointer-events: none;
}
/* 「トップへ戻る」ボタンを表示 */
body {
--back-to-top-opacity: 0;
}
body[data-scroll-direction="up"] {
--back-to-top-opacity: 1;
}
.back-to-top {
position: fixed;
inset: auto 20px 20px auto;
transition: opacity 0.3s;
opacity: var(--back-to-top-opacity);
}

グローバルにカスタムデータ属性を設定することで、JavaScriptを追加せず、CSSだけで複数のUIを制御できる点がとても便利だと感じます。

requestAnimationFrameとは

今回の実装ではrequestAnimationFrameを使用しています。
時々見かけるけどもあまり深くまで理解できていないメソッドの代表格ではないでしょうか。

MDNでは以下のように説明されています。

ブラウザーにアニメーションを行いたいことを知らせ、指定した関数を呼び出して次の再描画の前にアニメーションを更新することを要求します。

つまり、次の再描画の直前にコールバック関数を実行してくれる仕組みです。

スクロール処理での動き

次のコードでは、一見すると6行目でisScheduled = true;にしているため、2回目以降のスクロールではrequestAnimationFrameは実行されないのでは?と思うかもしれません。

script.js
let isScheduled = false;
window.addEventListener("scroll", () => {
if (!isScheduled) {
requestAnimationFrame(updateScrollDirection);
isScheduled = true;
}
});

しかし実際には2回目以降のスクロールでも適切にコールバックが登録されて実行されます。

その理由はrequestAnimationFrameのコールバック内でフラグをリセットしているからです。以下のような順序で処理が実行されます。

1回目のスクロール:
  1. スクロールイベント発生
  2. isScheduled = falseなので条件通過
  3. requestAnimationFrameupdateScrollDirectionを予約
  4. isScheduled = trueに設定
  5. 次のフレームでupdateScrollDirection実行
  6. updateScrollDirection内でisScheduled = falseにリセット
2回目のスクロール
(同一フレーム内):
  1. スクロールイベント発生
  2. isScheduled = trueなので条件をスキップ(実行されない)
2回目のスクロール
(次のフレーム):
  1. 前フレームでisScheduled = falseにリセット済み
  2. スクロールイベント発生
  3. isScheduled = falseなので再び条件通過可能

行ごとの処理を追うと、実行タイミングのイメージは以下のようになります。ポイントとしてはupdateScrollDirectionの実行タイミングが次のフレームの直前である点です。

script.js
window.addEventListener("scroll", () => {
if (!isScheduled) { // ①
requestAnimationFrame(updateScrollDirection); // ②
isScheduled = true; // ③
}
});
const updateScrollDirection = () => {
// ... DOM更新処理 ...
isScheduled = false; // ④ ここでフラグをリセット!
}

以上のことからrequestAnimationFrameはコールバック関数を登録し、すぐには実行せず次の再描画の直前に処理が行われることがわかります。

requestAnimationFrameを使用するメリット

ではrequestAnimationFrameを使うと何が良いのか?

ここでは単純にスクロールイベントのコールバック関数としてupdateScrollDirectionをそのまま渡した例と比較してみます。

requestAnimationFrameなしの例
window.addEventListener("scroll", updateScrollDirection);
requestAnimationFrameなしrequestAnimationFrameあり
  • スクロールイベントが発生するたびに直接実行される。
  • 環境によっては1フレームあたり複数回発火する
  • 1フレームあたり1回のみ実行される。
  • 最大でも1秒間に約60回に制限される(デバイスにより異なる)

requestAnimationFrameを使うことで、同一フレーム内に複数回発火するスクロールイベントを、描画1回につき1回の処理に間引くことができます。ブラウザの描画タイミングに同期するため、無駄な処理が発生せず、パフォーマンスの向上に直結します。

まとめ

requestAnimationFramedata属性を組み合わせて、スクロール方向の検出を一度だけ用意し、あとはCSSだけでさまざまなUI制御を拡張できました。

今回のようにグローバルなカスタムデータ属性でUIを制御する方法は、パフォーマンス向上のみならず保守や拡張をする上でも大きなメリットがあると感じます。

参考サイト

この記事を書いた人

写真:ブール 代表 西川雅人

Masato Nishikawa

ウェブ制作会社・デザイナー様向けに、コーディングやWordPress実装を承っているフリーランスです。繁忙期のサポートや、継続的な外注先をお探しでしたら、お気軽にお声がけください。

コーディングや
WordPress実装の
ご相談を承っています。

新規制作から運用フェーズの修正まで柔軟に対応します。
お見積りや実装可否の確認など、お気軽にご連絡ください。