まず説明から
これまでのスクロールの上下方向を調べるスクリプトは、スクロールの値が増えたら「下」へ、減れば「上」へと判断していましたが、毎回毎秒それらを処理するために余計な負荷がかかると言われており、デバウンスで間引いたり、方向を維持するために「フラグで今までは上向き」だったと言うようなことをしなければいけませんでした。
何だかんだコードのリファクタリングをAIで依頼する際に、「それだとタッチパッドなどで下向きと見える中で、微妙な上向きが混じったりしてcssクラスの付け替えがその1pxでも行われるのでデバウンスなどで微妙な方向の違いを無視する必要があるのですよ」というわけです。
それはそうだけども、その数秒(あるいはもっと短いかも知れませんが間引いている間)にも操作が行われるかも知れず、間引くのは負荷は減るだろうけど直感的ではないなと。
またrequestAnimationFrame()を使用して、ブラウザの描画に合わせることで負荷を最小限に抑えられるなどとも言いますが、いや欲しいのはscrollの合間の値じゃなくて、「上向いてるか下向いてるか」だけなんだよと。
MdNを見ると、scrollend イベントってのがあるじゃないと。それってスクロールが停止したらイベントが発火するわけで、それと同じようにスクロールが上向けば通知してくれたら便利じゃないかと。
- 1pxの違いでも方向は変わってしまう
- 間引くとその瞬間の判断ができない
などなど文句をつければ色々とあるわけです。
だったら基本は変わらないが違う方法で向きが判別できないだろうかと
ある時、画面内に見えない四角形と、それを追従する同じ四角形とがあったとして、その位置の差が方向であり強さであるのではないかと思いついたわけです。
天才的やんと意気揚々とAIにこういうのはどうだ?これならお前らが言う文句は解消できるやろ!と言うと、
「そのアイデア、完全に大正解ですし、めちゃくちゃエレガントな思考です!鳥肌が立ちました。」
といつもの太鼓持ちから始まって、
「プログラミングや数理モデルの世界では、まさにその仕組みを「線形補間(Lerp: Linear Interpolation)」や「ローパスフィルタ(平滑化フィルタ)」と呼びます。ゲーム開発でのカメラの遅延追従や、Apple製品などの滑らかな慣性スクロールの裏側は、まさにその「見えない四角形を遅れて追いかける」数式で動いています。」
なんや、既にあるんかい!(怒
この方法のメリット
AIの解説
- 微小なノイズ(チャタリング)の自動吸収
- スマホのスクロールや指の震えで、下スクロール中に一瞬だけ 1px 上に戻るようなノイズ(微振動)が混ざることがあります。前の値をそのまま比較するとここで「上向き!」と誤検知しますが、遅れて追従する四角形(B)は大きな流れ(A)に引っ張られている最中なので、一瞬の小刻みな揺れを完全に無視(吸収)できます。
-
「スクロールの強さ(エネルギー)」の可視化
- 観念の四角形(実際のスクロール)と、追従する四角形の「距離(差)」は、まさにユーザーがどれだけ勢いよく画面を弾いたか(速度・慣性)を表します。
- 「ゆっくり上に戻している時はボタンを出さないけれど、シュッと勢いよく上にフリックした(=一刻も早く戻りたい)時だけ即座にボタンを出す」という、超能力のような気が利くUIが作れます。
-
ネイティブAPI(scrollend)すら不要になる
- 差の大きさが「しきい値(不感帯 / Deadzone)」以下になったら、自動的に「追いついた=静止した」とみなせるため、ブラウザのイベントに頼らず、自分たちの数式だけで完璧な停止判定ができるようになります。
が、考えられるそうです。私としては単にゲームの慣性操作みたいのをイメージしただけなんですが色々メリットはあるようです。また付け加えると、単にスクリプトを組んでではなく、カスタムイベントとして登録できないかと思っていたわけです。
scrollendでいけるんなら同じようにしたらaddEventLitenerでスクロールが「上むいた時だけ」「下向いた時だけ」を教えてくれたら良いわけで、道中の判断はどうでもいいと。
元々は、従来の方法で組んだスクリプトを「リファクタリングして欲しい」なんなら「モダンでシンプルかつ、わかりやすいので」と会話していたのですが、従来の方法のデメリットを聞く中で、こっちの慣性的な方法のが色々と便利じゃなかろうかと相成ったという感じです。
実際問題、スクロールが「上向いているか」「下向いているか」は「値が増えてる」か「減ってる」かだけの判断で済むわけですが、1つの計算だけをしておけば、後はお任せで向きだけわかると言う具合になり、メインのスクリプトがグッと短くなったので応用すれば色んな事に使えるのではないかと、ここに残しておこうかと思ったわけです。
そのスクリプトがこれだ
まずは処理させる部分から。モジュール形式で行く予定なのでそのように書いあります。以下のコードを任意の名前をつけた.jsとして保存し、メインのスクリプトからは、import { initPhysicalScrollWatcher } from './任意の名前をつけた.js';として呼び出します。
この時のディレクトリとしては、
# skipCopy
assets/
├── main.js # ここの先頭でimport
└── module/
└── 任意の名前をつけた.js # ここに以下のコード
として使用します。
const TOP_THRESHOLD = 100; // ★これより上は「最上部エリア」とみなして初期化
const DEADZONE = 1; // 静止しているとみなす「差」のしきい値(px)
const EASING = 0.15; // 追従の遅れ度合い(0〜1)
export const initPhysicalScrollWatcher = () => {
let targetY = window.scrollY; // 観念の四角形(実際のスクロール位置)
let currentY = window.scrollY; // 遅れて追従する四角形
let isLooping = false;
let currentDirection = 'top'; // 'down' | 'up' | 'stop' | 'top'
const updatePhysics = () => {
// 1. 【最上部ガード】実際のスクロールが閾値より上なら、即座に top 状態にして終了
if (targetY <= TOP_THRESHOLD) {
currentY = targetY; // 位置を同期
if (currentDirection !== 'top') {
currentDirection = 'top';
window.dispatchEvent(new CustomEvent('scroll-state-change', { detail: { state: 'top' } }));
}
isLooping = false;
return;
}
// 2. 通常エリアにいる場合は、じわっと追従計算
currentY += (targetY - currentY) * EASING;
const diff = targetY - currentY;
const absDiff = Math.abs(diff);
let nextDirection = currentDirection;
// 3. 状態の割り出し(最上部エリアは上で抜けているので、純粋に動いているかどうかだけ)
if (absDiff <= DEADZONE) {
nextDirection = 'stop';
} else {
nextDirection = diff > 0 ? 'down' : 'up';
}
// 4. 状態が変わった瞬間だけ信号を飛ばす
if (nextDirection !== currentDirection) {
currentDirection = nextDirection;
window.dispatchEvent(new CustomEvent('scroll-state-change', {
detail: { state: currentDirection }
}));
}
// 静止したらループを止めて省電力化
if (currentDirection === 'stop') {
currentY = targetY;
isLooping = false;
return;
}
requestAnimationFrame(updatePhysics);
};
window.addEventListener('scroll', () => {
targetY = window.scrollY;
if (!isLooping) {
isLooping = true;
requestAnimationFrame(updatePhysics);
}
}, { passive: true });
};
ややコード自体は長めではありますが、比較的読みやすいのではと思います。また、window.dispatchEvent(new CustomEvent('scroll-state-change', {の所に、その他の部分でも色々手を加えて、isFast: absDiff > SWIPE_SPEEDという差の大きさで速度を付け足すということもできます。その場合は、
素早く動かした場合に反応するようにする
const DEADZONE = 1; // 静止しているとみなす「差」のしきい値(px)
const EASING = 0.15; // 追従の遅れ度合い(0〜1: 小さいほどじわっと遅れる)
const SWIPE_SPEED = 15; // 「勢いよくスクロールした」とみなす速度のしきい値
export const initPhysicalScrollWatcher = () => {
let targetY = window.scrollY; // 観念の四角形(実際のスクロール位置)
let currentY = window.scrollY; // 遅れて追従する四角形
let isLooping = false;
let currentDirection = 'stop'; // 'down' | 'up' | 'stop'
// 毎フレーム実行される物理演算ループ
const updatePhysics = () => {
// 1. 四角形をじわっと近づける(イージングの数式)
currentY += (targetY - currentY) * EASING;
// 2. 2つの四角形の「差(距離と方向)」を計算
const diff = targetY - currentY;
const absDiff = Math.abs(diff);
let nextDirection = currentDirection;
// 3. 差がしきい値以下なら「静止(追いついた)」
if (absDiff <= DEADZONE) {
nextDirection = 'stop';
} else {
// 差の正負で方向を確定(ノイズはここで吸収される)
nextDirection = diff > 0 ? 'down' : 'up';
}
// 4. 【状態変化の瞬間】だけカスタムイベントを発火
if (nextDirection !== currentDirection) {
currentDirection = nextDirection;
window.dispatchEvent(new CustomEvent('scroll-state-change', {
detail: {
state: currentDirection,
// 差の大きさを「速度」としてオマケで渡してあげる
isFast: absDiff > SWIPE_SPEED
}
}));
}
// 追いついて静止したらループを止めてブラウザのバッテリーを節約
if (currentDirection === 'stop') {
currentY = targetY; // 完全に位置を同期
isLooping = false;
return;
}
// 次のフレームも計算を継続
requestAnimationFrame(updatePhysics);
};
// スクロールイベントは、観念の位置(目標値)を更新してループを起動するだけ
window.addEventListener('scroll', () => {
targetY = window.scrollY;
if (!isLooping) {
isLooping = true;
requestAnimationFrame(updatePhysics);
}
}, { passive: true });
};
というような書き方もできますが、今回元々必要だったのは方向を知るためにどう書くかという話し合いだったので、最初の方のコードで行きます。
メインの処理
import { initPhysicalScrollWatcher } from './scroll-filter.js'; // import
const target = document.querySelector('.任意のセレクタ'); // ここにcssをつけたり外したり
initPhysicalScrollWatcher();
window.addEventListener('scroll-state-change', (e) => {
const { state } = e.detail;
if (state === 'up') {
// ① 上向きスクロールならtargetに.upを追加
target.classList.add('up');
}
else if (state === 'down' || state === 'top') {
// ② 下向きスクロール、または「最上部100pxエリア」に入ったら.upを取り除く(初期化)!
target.classList.remove('up');
}
});
これだけです。upが要素につけたいcssのクラスです。VidPickで実際に使用しています。upというcssのクラスを付けるか外すだけで、画面上部からトップへ戻るボタンが出現します。
「出現する」か「消す」と二択しか無いのでupを「付ける」か「外す」だけと言うことになりますが、もちろん、下向きの時に何かしたり方向が切り替わった時に何かしら処理を変えるということも簡単にできると思います。
カスタムイベントのコードはちょっと長くもなりがちですが、別ファイルにあって結果と処理が完全に分離しているので、メインがとてもシンプルに書けると思います。ここが一番のメリットかと思っています。
どういう事が可能になるか
top, down, up, stopという4つの状態がわかるとできることは色々あると思います。
コンテキストに応じた「スマート・ヘッダー」
単にヘッダーを「出す/隠す」だけでなく、ユーザーの読み進め方に合わせてヘッダーの役割(見た目)を変化させることが可能です。
- top(最上部):ロゴもナビゲーションもフルサイズで表示(サイトの顔)。
- down(スクロール中):コンテンツに集中させるため、ヘッダーを完全に隠す(全画面読書モード)。
- stop(読書の手が止まった):ヘッダーを「薄い透過状態(ミニマル版)」でスッと出す。邪魔にならないサイズで、現在読んでいる記事のタイトル(進行度)だけを表示する。
- up(戻り始めた):メニューをタップしたい意思の表れなので、文字や背景を「100%不透明」にして、押しやすいフル機能のヘッダーに復元する。
「間違えて押してトップに行ってしまった」を救う、スクロール位置の復帰履歴(タイムトラベルUI)
- 状態が
upからtopに切り替わった瞬間(=トップへ戻るボタンを押して最上部に戻った時)、直前までいた位置(targetY)をメモリに1つ保持します。 - 最上部に戻った後、ボタンのテキストを「元の位置(◯◯%)に戻る」に変化させます。ユーザーがそれを押すと、さっきまで読んでいた位置に一瞬でワープ(復帰)できます。
これがあると、「誤操作でトップに戻ってしまい、読んでいた場所を見失う」というユーザーの最大のストレスを完全にゼロにできます。
この発想はずっと前からあって、例えば脚注をカードの裏に記載しておき、フリップしてそれらを表示する時、再度元に戻る時は前の位置に戻れるように考えていたことがあります。これをスクロールで実現するということです。
先読みの最適化
スクロールの状態に合わせて、次の記事や重い画像などの「通信」をコントロールし、ブラウザのメモリとネットワーク帯域を節約します。
- down時には、「このまま先を読む確率が高い」ため、画面外にある画像や次の関連記事のデータをバックグラウンドで先読み(プレフェッチ)し始めます。
stop や up時には、「読む手が止まっている、または引き返している」ため、無駄な通信を即座に一時停止します。- 従来の「位置だけで判定する無限スクロール」のように、境界線をまたいで微振動しただけで何回も無駄なリクエストが飛ぶ、といったバグが根底から発生しなくなります。
速度(四角形と仮想四角形の差)をCSS変数に注入するマイクロインタラクション
これは処理している側のスクリプトを別途追加しないといけませんが(isFastのような感じで)、差がどれだけあるかがわかるのでそれを反映させるだけで、JS側でアニメーションをゴリゴリ書くことなく、CSS側だけで以下のような「慣性に超リアルに連動するエフェクト」が作れます。
- 要素の傾き(Skew):シュッと激しくスクロールした時だけ、記事のカードやボタンが移動方向にクニャッとわずかに傾く(ゲームのような心地よい粘性)。
- ブラー(Blur):スクロールの速さに応じて、背景や画像にうっすらとモーションブラー(移動ボケ)がかかり、スクロールが圧倒的に滑らかに感じられる。
アニメでも足が高速に動く時に省略することってありますよね?スクロールの速度の変化とブラー(ボカシ)を少し入れるとよりそれらを表現できるようになるかも。
まとめ
このように、個人的には一番もったいない「スクロールの方向だけ知りたい(しかも「上方向」だけと言う)」と言う使い方しかしていませんが、アイデアによっては色んな事に応用が効くと思うのです。
スクロールの値ではなく、状態を知るというのはずっと意味があることなのではないかと。ChatGPTが言っていたと思うのですが、世間では従来のスクロールの方向を知る方法ではなく、どう変化したかを知る方法を取る人もいるそうです。
ブラウザAPIは昔から低レベルな方法だけ実装して、プログラマではなく使用している人にはあまり優しい作りではありません。date関数然り、サイズを知るのにgetBoundingClientRect()とかコマンドが長くて面倒くさいとか、位置を知るためにどうの、色を付けるためにどうのと、細かく設定してあるのは、ある意味親切なのかも知れませんが、ブラウザを作っている側によって解釈が違うというのもありますよね?
FirefoxとChromeでは同じ事ができてもスクロールバーですら挙動が違う。<br>の挙動もちょっと違うと。htmlやcssは世界共通だろ?というのにやや違うと言うジレンマ。
コンピューター寄りではなく、人間の感覚に近い操作感って大事ですよね。そろそろそのあたりに向かっても良いのではないかと思う今日このごろです。