requestAnimationFrameで実現する、スクロール方向に応じたUI切り替え
スクロールの上下方向に応じて、UIの見た目を変化させたい場面は意外と多くあります。
その都度スクロール方向を判定する方法もありますが、body要素等にカスタムデータ属性でグローバルにスクロール方向を保持するという手法が、複数箇所で使いまわせて便利だと最近気がつきました。
コードの全体像
さっそくですが、スクロール方向をdata-scroll-directionというカスタムデータ属性で管理するコード例を紹介します。
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属性に応じて、ヘッダーのtransformの値を変更することで実現しています。
/* スクロール時 */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));}このような実装は、ヘッダーが比較的大きい場合や、コンテンツの閲覧領域を少しでも広く確保したい場合に有効だと感じます。ヘッダーの動きのほかに以下のような事例にも活用できます。
- 下方向スクロール時にフローティングボタンを隠す
- 上方向スクロール時に「トップへ戻る」ボタンを表示
/* フローティングボタンを隠す */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は実行されないのでは?と思うかもしれません。
let isScheduled = false;
window.addEventListener("scroll", () => { if (!isScheduled) { requestAnimationFrame(updateScrollDirection); isScheduled = true; }});しかし実際には2回目以降のスクロールでも適切にコールバックが登録されて実行されます。
その理由はrequestAnimationFrameのコールバック内でフラグをリセットしているからです。以下のような順序で処理が実行されます。
| 1回目のスクロール: |
|
|---|---|
| 2回目のスクロール (同一フレーム内): |
|
| 2回目のスクロール (次のフレーム): |
|
行ごとの処理を追うと、実行タイミングのイメージは以下のようになります。ポイントとしてはupdateScrollDirectionの実行タイミングが次のフレームの直前である点です。
window.addEventListener("scroll", () => { if (!isScheduled) { // ① requestAnimationFrame(updateScrollDirection); // ② isScheduled = true; // ③ }});
const updateScrollDirection = () => { // ... DOM更新処理 ... isScheduled = false; // ④ ここでフラグをリセット!}以上のことからrequestAnimationFrameはコールバック関数を登録し、すぐには実行せず次の再描画の直前に処理が行われることがわかります。
requestAnimationFrameを使用するメリット
ではrequestAnimationFrameを使うと何が良いのか?
ここでは単純にスクロールイベントのコールバック関数としてupdateScrollDirectionをそのまま渡した例と比較してみます。
window.addEventListener("scroll", updateScrollDirection);| requestAnimationFrameなし | requestAnimationFrameあり |
|---|---|
|
|
requestAnimationFrameを使うことで、同一フレーム内に複数回発火するスクロールイベントを、描画1回につき1回の処理に間引くことができます。ブラウザの描画タイミングに同期するため、無駄な処理が発生せず、パフォーマンスの向上に直結します。
まとめ
requestAnimationFrameとdata属性を組み合わせて、スクロール方向の検出を一度だけ用意し、あとはCSSだけでさまざまなUI制御を拡張できました。
今回のようにグローバルなカスタムデータ属性でUIを制御する方法は、パフォーマンス向上のみならず保守や拡張をする上でも大きなメリットがあると感じます。
