GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

横スクロール アニメーション css 9パターン — 慣性・円筒・魚眼レンズ

横スクロール アニメーション css は、縦に入ったホイールの値を横へ向け直すだけでは終わりません — 01番はその値を慣性で滑らせ、06番はカードを円筒の上に投影し、08番は端のピクセルを魚眼レンズで曲げます。

ホイールを回すと横へ流れる九つの場面を、格子から開いてみる

下の格子でカードを押すとその項目の節へ移り、各デモの中でホイールを回せば、縦の操作が横の流れに変わる様子をその場で確かめられます。並べ方は、入力が画面を変えるまでに通る層の数に合わせました。01から03は入力処理の三段階(慣性・区間の割り当て・フリックのスナップ)、04から07は横移動を立体に見せる四つの方法(照明・視差・円筒・ひも)、08と09はピクセルと文字が曲がる二通りの仕上げです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

横スクロール アニメーション css 9パターン — ホイールの慣性・円筒の投影・魚眼レンズで作る横へ流れる画面

先にはっきり書いておきます。この記事の九つの場面はどれも <canvas> の上で JavaScript がフレームごとに計算して描いた絵で、CSS のアニメーションは、ボタンにポインターを載せたとき背景色が変わる transition ひとつだけです。ホイールの縦の値を横の移動に向け直し、そこへ慣性を足す処理は CSS のプロパティだけでは書けない領域なので、wheel イベントの値をスクリプトで直接扱います。見た目の 横スクロール css デザイン として CSS が受け持つのは、舞台の幅・縦横比・角の丸み、状態を示すピル型の表示といった外枠だけです。

九つの場面が共有する時間の骨組み

requestAnimationFrame が渡すタイムスタンプをためて16msの固定ステップ(FIXED_DT)で最大6回動かし、描画は1フレームに1回です。タブを隠して戻ってきても、1フレームで流す時間は100msで打ち切られるので、場面が瞬間移動することはありません。乱数は使わず、05の稜線は整数のハッシュで、ほかの場面の自動の実演は周期のスケジュールで動くので、毎回同じ動きになります。ライブラリ・CDN・WebGL は九つのファイルのどれにも出てきません。

ホイールの横取りは wheelPx と canMove の一組で決まります

入ってきた値は、まず wheelPx が目盛りをそろえます。行単位の装置(deltaMode 1)には16を、ページ単位(2)には480を掛け、|deltaX| > |deltaY| なら横の値を選びます。トラックパッドの横スワイプとマウスの縦回転が、同じ軸に入って同じ道を通るわけです。次に canMove(d) でいまが端の向きかどうかを見て、動ける場合にだけ preventDefault を呼びます。プローブで測ると、端の向きの wheel は止めず(pass)、動ける向きは止める(blocked)という結果が九つの場面すべてで出ました。そのためトラックの端では、ページの縦スクロールがそのまま続きます。

人が触れた瞬間に自動の実演が止まります

人が初めてホイール・ポインター・キーに触れた瞬間、wake() が自動の実演を止め、状態表示の aria-live を off から polite に切り替えます。実演のあいだに画面読み上げソフトがしゃべり続けないための作りです。キャンバスには touch-action: pan-y がかかっていて、横方向に指を引けば場面が動き、縦に払えばブラウザ本来のスクロールになります。

横スクロール css デザイン はライブラリなしでどこまで作れるか?

答えは思ったより遠くまでです。この記事の慣性・スナップ・遠近投影・ピクセルの置き換えは、どれも掛け算と足し算の数行の中で回っているからです。中心になる計算は、01が等比級数ひとつ、03がバネの二つの項、06が割り算一行で、05の山並みも格子ハッシュを4層重ねて描いています。用途から選ぶなら、商品の横一覧やおすすめの列は01、製品のランディングや沿革は02、モバイルのカードカルーセルは03、手順の紹介やブランドストーリーは04、旅行サイトのヒーローは05、アルバムやポートフォリオの表紙は06、写真の列の陳列は07、アイコンのドックやチップの列は08、スローガンの帯は09から開いてみてください。下の九つの節のコード片は、各場面の index.html のスクリプトから行とインデントをそのまま写したものなので、zip を開けば同じ関数名で元の箇所を探し直せます。

01縦のホイール値が横の移動に変わり、入力が止まったあとも摩擦でゆっくり止まるカードトラック

ホイールの縦の1目盛り(100)は WHEEL_GAIN の0.12を掛けて1ステップあたり12pxの速度になり、その後はステップごとに FRICTION の0.92が掛かって、1056msのあいだに149.4px滑ってから止まります。トラックの両端では、はみ出した距離に応じて入力を d/(1+o/RUBBER_PX) に弱めてゴムのように踏ん張り、キーボードでは speedFor が目標の距離でちょうど止まる初速を逆算します。レンダーの測定は累積面積41.4%・強度109.7、動いたフレームは16/23で、止まって待つ区間があるぶんフレームが休みます。

縦のホイール値が横の移動に変わり、入力が止まったあとも摩擦でゆっくり止まるカードトラック
新しいタブで開く

01 · 縦のホイール値が横の移動に変わり、入力が止まったあとも摩擦でゆっくり止まるカードトラック — 計測 累積面積 41.419% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FRICTIONWHEEL_GAINEDGE_KRUBBER_PX
    // ホイール一回の移動量を px に — 行単位(deltaMode 1)・ページ単位(deltaMode 2)の装置も同じ目盛りにそろえる。
    // 横と縦の大きいほうを使う: トラックパッドの横スワイプとマウスホイールの縦回転が同じ向きで入ってくる
    function wheelPx(ev) {
        const unit = ev.deltaMode === 1 ? 16 : (ev.deltaMode === 2 ? W : 1);
        const d = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
        return d * unit;
    }
    // 端の外へ押す入力は、はみ出した距離に応じて弱める — ゴムのように踏ん張る
    function rubber(d) {
        const out = (x <= 0 && d < 0) || (x >= MAX_X && d > 0);
        return out ? d / (1 + Math.abs(overshoot(x)) / RUBBER_PX) : d;
    }
    function push(d) {
        v += rubber(d) * WHEEL_GAIN;
        moving = true;
        say(TXT_GLIDE);
    }
    // 今の速度のまま放すとあとどれだけ進むか — ステップごとに FRICTION を掛けるので等比級数 v·f/(1−f)
    function glideDistance(v0, f) { return v0 * f / (1 - f); }
    // 逆に、distance だけ進んで止まるには最初の速度がいくつ要るか
    function speedFor(distance, f) { return distance * (1 - f) / f; }
    function canMove(d) { return d > 0 ? x < MAX_X - 0.5 : x > 0.5; }

02セクションが画面に固定されたまま、縦の進み具合がパネルごとの横移動に分けて変わる固定パネルの場面

縦の進み具合0から1をパネルの間の三つの区間に分け、区間の中でも前後18%(HOLD)はとどまり、真ん中の64%を smoothstep で渡るので、画面は1パネルずつ階段のように移ります。ホイール100pxで進み具合が0.09増え、画面は PIN_LERP の0.12で追いかけるため、128msの追従の遅れがつきます。測定は累積面積50.4%・強度94.1・動いたフレーム23/23です。

セクションが画面に固定されたまま、縦の進み具合がパネルごとの横移動に分けて変わる固定パネルの場面
新しいタブで開く

02 · セクションが画面に固定されたまま、縦の進み具合がパネルごとの横移動に分けて変わる固定パネルの場面 — 計測 累積面積 50.447% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PANEL_NHOLDP_GAINPIN_LERP
    // 進み具合 p(0~1) → パネル位置(0~PANEL_N−1)。区間ごとに前後 HOLD だけとどまり、真ん中を smoothstep で渡る
    function panelPos(p) {
        const segs = PANEL_N - 1;
        const s = clamp(p, 0, 1) * segs;
        const k = Math.min(Math.floor(s), segs - 1);
        const u = s - k;
        return k + smooth((u - HOLD) / (1 - 2 * HOLD));
    }
    function canMove(d) { return d > 0 ? target < 1 : target > 0; }

03引いて放すと、放した瞬間の速度から到着点を先に計算し、いちばん近い枠へバネで吸い付くドラッグスナップ

引いて放した瞬間、predictSlot が round((x + v·0.2)/232) で着地の枠を先に決め、その枠へ向けて a = 120·(目標−x) − 14·v のバネがかかります。減衰比0.639なので行き過ぎは5.5%、収まるまで912msです。ホイールは速度を残さず、最後の回転から140ms後にいまの位置からいちばん近い枠へ吸い付くので、フリックはドラッグからだけ生まれます。測定は累積面積46.5%・強度83.3・動いたフレーム21/23でした。

引いて放すと、放した瞬間の速度から到着点を先に計算し、いちばん近い枠へバネで吸い付くドラッグスナップ
新しいタブで開く

03 · 引いて放すと、放した瞬間の速度から到着点を先に計算し、いちばん近い枠へバネで吸い付くドラッグスナップ — 計測 累積面積 46.493% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TAUSNAP_KSNAP_CPITCH
    // 端の外へ引いた距離は縮めて見せる — ゴム
    function rubberClamp(p) {
        if (p < 0) {
            return p / (1 - p / RUBBER_PX);
        }
        if (p > MAX_X) {
            const o = p - MAX_X;
            return MAX_X + o / (1 + o / RUBBER_PX);
        }
        return p;
    }
    // 放した瞬間の速度で τ 秒後の位置を先に見積もり、いちばん近い枠を選ぶ
    function predictSlot(x0, v0) {
        return clamp(Math.round((x0 + v0 * TAU) / PITCH), 0, CARD_N - 1);
    }
    // バネ・ダンパーの1ステップ — 加速度 = K·(目標−位置) − C·速度 (半陰的オイラー)
    function springStep(dt) {
        const a = SNAP_K * (target - x) - SNAP_C * v;
        v += a * dt;
        x += v * dt;
    }
    function canMove(d) { return d > 0 ? x < MAX_X - 0.5 : x > 0.5; }

04横に並んだパネルが進み具合に応じて屏風のように折れては開き、折れた面ごとに光の当たる角度で明るさが変わる屏風パネル

六枚のパネルの折れ角 θ は進み具合ひとつで決まり、頂点は persp(z) = 420/(420+z) で遠近投影されて台形の面になります。面の明るさは法線と光源方向(−0.6, −0.3, −0.75)の内積で決まり、たたみ切ったときは偶数の面が0.3・奇数の面が0.961、広げ切ると六面とも0.686にそろいます。レンダーの値は累積面積41.9%・強度59.5・動いたフレーム21/23です。

横に並んだパネルが進み具合に応じて屏風のように折れては開き、折れた面ごとに光の当たる角度で明るさが変わる屏風パネル
新しいタブで開く

04 · 横に並んだパネルが進み具合に応じて屏風のように折れては開き、折れた面ごとに光の当たる角度で明るさが変わる屏風パネル — 計測 累積面積 41.942% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_FOLDFOCALYAWAMBIENT
    // 面 i の法線と光の内積 → 明るさ。偶数の面は右の奥へ、奇数の面は右の手前へ折れる
    function panelLight(i, th) {
        const n = yawRot((i % 2 === 0 ? 1 : -1) * Math.sin(th), -Math.cos(th));
        const dot = n[0] * LIGHT[0] + n[1] * LIGHT[2];
        return AMBIENT + DIFFUSE * Math.max(0, dot);
    }
    // 面 i の上の点(u: 0 左~1 右) → 回す前の横位置と奥行き
    function panelLocal(i, u, th) {
        const run = PANEL_W * Math.cos(th);
        const rise = PANEL_W * Math.sin(th);
        const total = PANEL_N * run;
        const X = W / 2 - total / 2 + (i + u) * run;
        const Z = i % 2 === 0 ? u * rise : (1 - u) * rise;
        return yawRot(X - W / 2, Z);
    }

05ノイズで描いた稜線の山・丘・木の層が、奥行きに応じて違う速さで横へ流れる横方向の視差の風景

稜線の高さは、整数格子のハッシュ(hash1)で作ったバリューノイズを4オクターブ重ね、0から1に正規化した fBm です。scroll がどんな実数でも、0.25px刻みで調べた最大の跳びは0.337pxにとどまり、継ぎ目のない曲線になります。五つの層は奥行き [8, 5, 3.2, 2, 1.2] で移動量を割って受け取るので、scroll 1000pxに対して層ごとの移動は125pxから833.3pxまで、6.67倍の差で流れます。測定は累積面積37.1%・強度39.3・動いたフレーム23/23です。

ノイズで描いた稜線の山・丘・木の層が、奥行きに応じて違う速さで横へ流れる横方向の視差の風景
新しいタブで開く

05 · ノイズで描いた稜線の山・丘・木の層が、奥行きに応じて違う速さで横へ流れる横方向の視差の風景 — 計測 累積面積 37.122% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SEEDOCTAVESDEPTHSFREQS
    // 整数格子ひとマスの値 — シードが同じなら毎回同じ数 (整数を混ぜるハッシュ)
    function hash1(n, seed) {
        let h = (Math.imul(n | 0, 374761393) + Math.imul(seed, 668265263)) | 0;
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        h ^= h >>> 16;
        return (h >>> 0) / 4294967296;
    }
    // 1D のバリューノイズ — 格子の値二つを smoothstep でつなぐ。格子が切れ目なく続くので継ぎ目がない
    function vnoise(x, seed) {
        const i = Math.floor(x);
        const f = x - i;
        const u = f * f * (3 - 2 * f);
        return lerp(hash1(i, seed), hash1(i + 1, seed), u);
    }
    // fBm — 周波数を2倍、強さを半分ずつにして OCTAVES 層重ね、0~1 にそろえる
    function fbm(x, seed) {
        let sum = 0;
        let amp = 0.5;
        let freq = 1;
        let norm = 0;
        for (let o = 0; o < OCTAVES; o += 1) {
            sum += amp * vnoise(x * freq, seed + o * 101);
            norm += amp;
            amp *= 0.5;
            freq *= 2;
        }
        return sum / norm;
    }

06カードが円筒のまわりに並び、横の移動量だけ回って、遠近の投影で大きさ・明るさが変わり、前後の順が並べ直される円筒ギャラリー

カードごとに円筒上の角度を持たせ、s = 270/(520 − 250·cosφ) で画面の位置と倍率を求めるので、手前のカードは1.000・横は0.519・奥は0.351と大きさがひとりでに変わり、描く順番も毎フレーム cosφ の小さい順(遠いものから)に並べ直されます。facing がカメラを向く限界は cosφ が250/520を超える±61.3°で、その先は背を向けた暗いカードになりますが、明るさは BACK_DIM の0.35から1までを (cosφ+1)/2 で混ぜた値なので、0.35まで沈むのは真後ろのカードだけで、±61.3°ではまだ約0.83あります。測定は累積面積28.8%・強度87.3・動いたフレーム17/23です。

カードが円筒のまわりに並び、横の移動量だけ回って、遠近の投影で大きさ・明るさが変わり、前後の順が並べ直される円筒ギャラリー
新しいタブで開く

06 · カードが円筒のまわりに並び、横の移動量だけ回って、遠近の投影で大きさ・明るさが変わり、前後の順が並べ直される円筒ギャラリー — 計測 累積面積 28.848% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RADIUSCAM_DSTEP_ANGBACK_DIM
    // 円筒上の角度 φ の点 → 画面の x と遠近の倍率。カメラは軸から CAM_D 離れていて、いちばん手前のカードが倍率 1
    function project(phi) {
        const X = RADIUS * Math.sin(phi);
        const Z = CAM_D - RADIUS * Math.cos(phi);
        const s = (CAM_D - RADIUS) / Z;
        return [W / 2 + X * s, s];
    }
    // 高さ h(カード中央 0、下が +)の画面 y — カメラが LIFT だけ上にあるので、遠いカードほど地平線(HORIZON)の側へ上がる
    function screenY(h, s) { return HORIZON + (h + LIFT) * s; }
    // カメラのほうを向いているか — 外向きの法線とカメラ方向の内積 = CAM_D·cosφ − R
    function facing(phi) { return CAM_D * Math.cos(phi) - RADIUS; }
    function canMove(d) { return d > 0 ? rot < MAX_ROT - 0.001 : rot > 0.001; }

07紐でつながったカードが先頭のカードに引かれて横へ動き、揺れてから静まる紐に吊るしたカードの列

ひもの節点49個は、速度の配列を持たずに(現在−前回)の位置の差を速度として使う Verlet 積分で、隣との距離が REST より開いたときだけ REST に戻す制約(短いときはそのまま)をステップごとに10回回してひもの長さを保ちます。始めに rest(240) で240ステップを先に解いておくので、最大のたわみ57.9pxの落ち着いた形から始まり、カードは自分の位置の節点の傾き atan2 だけ回ります。測定は累積面積50.2%・強度119.4で、強度は九つの中でいちばん高く、動いたフレームは23/23です。

紐でつながったカードが先頭のカードに引かれて横へ動き、揺れてから静まる紐に吊るしたカードの列
新しいタブで開く

07 · 紐でつながったカードが先頭のカードに引かれて横へ動き、揺れてから静まる紐に吊るしたカードの列 — 計測 累積面積 50.2% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SLACKGRAVITERCARD_WEIGHT
    function relax() {
        for (let it = 0; it < ITER; it += 1) {
            for (let i = 0; i < NODE_N; i += 1) {
                if (isPost(i)) {
                    px[i] = postX(i);
                    py[i] = CORD_Y;
                }
            }
            for (let i = 0; i < NODE_N - 1; i += 1) {
                const dx = px[i + 1] - px[i];
                const dy = py[i + 1] - py[i];
                const d = Math.sqrt(dx * dx + dy * dy) || 1;
                if (d <= REST) {
                    continue;
                }
                const diff = (d - REST) / d;
                const a = isPost(i) ? 0 : (isPost(i + 1) ? 1 : 0.5);
                const b = isPost(i + 1) ? 0 : (isPost(i) ? 1 : 0.5);
                px[i] += dx * diff * a;
                py[i] += dy * diff * a;
                px[i + 1] -= dx * diff * b;
                py[i + 1] -= dy * diff * b;
            }
        }
    }
    // クリップの位置のひもの傾き(ラジアン) — 両隣の点を結ぶ線の角度
    function slopeAt(i) {
        const a = Math.max(0, i - 1);
        const b = Math.min(NODE_N - 1, i + 1);
        return Math.atan2(py[b] - py[a], px[b] - px[a]);
    }

08トラックの両端に近づいたカードが魚眼レンズのように曲がって小さくなり、中央では平らに伸びる端のレンズ

元になるアイコンの列(1730px)を開始時に一度だけ描いて getImageData で読んでおき(その後の読み取りは0回)、レンズや位置の値が変わったフレーム(dirty)にだけ、画面の14万4000ピクセルごとに lensSample のレンズ曲線で元の位置を逆にたどって色を取り、putImageData で戻します。縦は拡大率 lensScale で割り、BEND·u² のたわみを足して凸面に見せ、端の倍率0.488が中央の平らな帯156pxと対比をつくります。測定は累積面積42.5%・強度72.6・動いたフレーム22/23です。

トラックの両端に近づいたカードが魚眼レンズのように曲がって小さくなり、中央では平らに伸びる端のレンズ
新しいタブで開く

08 · トラックの両端に近づいたカードが魚眼レンズのように曲がって小さくなり、中央では平らに伸びる端のレンズ — 計測 累積面積 42.474% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LENS_KLENS_PBENDPITCH
    // 画面の横位置 u(−1~1) → 元画像の標本位置 s。s = u·(1 + K·|u|^P) — 端ほど元画像を多く押し縮めて収める
    function lensSample(u, k) { return u * (1 + k * Math.pow(Math.abs(u), LENS_P)); }
    // その位置の拡大率 du/ds — 中央 1、端 1/(1 + K·(P+1))
    function lensScale(u, k) { return 1 / (1 + k * (LENS_P + 1) * Math.pow(Math.abs(u), LENS_P)); }
    // レンズの強さ k のとき、画面の半分(240px)が収める元画像の幅の半分
    function halfSpan(k) { return (W / 2) * (1 + k); }
        for (let x = 0; x < W; x += 1) {
            const u = (x + 0.5 - W / 2) / (W / 2);
            const sx = Math.round(center + lensSample(u, kNow) * (W / 2));
            const sc = lensScale(u, kNow);
            const sag = BEND * u * u * (kNow / LENS_K);
            for (let y = 0; y < H; y += 1) {
                const sy = Math.round(CY + (y - CY - sag) / sc);
                const o = (y * W + x) * 4;
                if (sx < 0 || sx >= SRC_W || sy < 0 || sy >= H) {
                    dst[o] = bgR;
                    dst[o + 1] = bgG;
                    dst[o + 2] = bgB;
                    dst[o + 3] = 255;
                    continue;
                }
                const i = (sy * SRC_W + sx) * 4;
                dst[o] = src[i];
                dst[o + 1] = src[i + 1];
                dst[o + 2] = src[i + 2];
                dst[o + 3] = 255;
            }
        }

09文字の帯が正弦波の曲線の上を横へ流れ、文字ごとに曲線の傾きだけ回り、速さが増すと波が高くなる文字のリボン

文字は正弦曲線の上を弧の長さで歩くように置かれ、ds 進むときの画面上の移動は ds/√(1+傾き²)、文字の回転はその位置の傾きの atan です。波の高さは速度を追うローパスで、clamp(10 + 0.09|速度|, 10, 58) の目標を320msの遅れで追いかけ、上限に届く速度は533px/sです。測定は累積面積30.3%・強度84.8・動いたフレーム23/23です。

文字の帯が正弦波の曲線の上を横へ流れ、文字ごとに曲線の傾きだけ回り、速さが増すと波が高くなる文字のリボン
新しいタブで開く

09 · 文字の帯が正弦波の曲線の上を横へ流れ、文字ごとに曲線の傾きだけ回り、速さが増すと波が高くなる文字のリボン — 計測 累積面積 30.333% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WAVE_KAMP_LERPAMP_GAINSPEED_FRICTION
    // 波の曲線とその導関数(傾き) — 文字の回転角は atan(傾き)
    function waveY(x) { return CY + amp * Math.sin(WAVE_K * x + phase); }
    function waveSlope(x) { return amp * WAVE_K * Math.cos(WAVE_K * x + phase); }
    // 流れる速度 → 波の高さの目標。実際の高さはこの値をローパス(AMP_LERP)でゆっくり追う
    function ampTarget(speed) { return clamp(AMP_MIN + AMP_GAIN * Math.abs(speed), AMP_MIN, AMP_MAX); }
    function canMove(d) {
        const s = speedNow();
        return d > 0 ? s < MAX_SPEED : s > -MAX_SPEED;
    }
                while (arc < want) {
                    const sl = waveSlope(x);
                    const ds = Math.min(4, want - arc);
                    x += ds / Math.sqrt(1 + sl * sl);
                    arc += ds;
                }
                if (x > W + 40) {
                    done = true;
                    break;
                }
                ctx.save();
                ctx.translate(x, waveY(x));
                ctx.rotate(Math.atan(waveSlope(x)));

摩擦・バネの強さ・レンズの強さ — 数字ひとつで滑り方が変わるところ

今回のレンダーで2秒間に24枚を撮って数えた三つの値を、項目番号ごとにまとめました。累積面積は画面のうち動きが通った場所の割合、強度はフレーム間のピクセル差の平均、動いたフレームは23あるフレーム間隔のうち変化が見つかった数です。累積面積が30%以上の場面は八つ(06だけ28.8%)で、10%未満はありません。

項目 累積面積 強度の平均 動いたフレーム
01 ホイール慣性のトラック 41.4% 109.7 16/23
02 固定パネル 50.4% 94.1 23/23
03 フリックスナップ 46.5% 83.3 21/23
04 屏風パネル 41.9% 59.5 21/23
05 視差の稜線 37.1% 39.3 23/23
06 円筒ギャラリー 28.8% 87.3 17/23
07 ひものカード 50.2% 119.4 23/23
08 端のレンズ 42.5% 72.6 22/23
09 波のリボン 30.3% 84.8 23/23

摩擦係数は等比級数の公比です

01の glideDistance は、ステップごとに速度へ FRICTION の f を掛けるので、滑る距離の合計は v₀·f/(1−f) の等比級数になります。ホイール1目盛りが生む v₀ 12px/ステップに f 0.92を入れると、連続の計算では138px、16msステップのシミュレーションでは149.4px(1056ms)で、離散ステップのほうが少し長く滑ります。同じ式を逆に解いた speedFor が、キーボードの1回をちょうど132pxに収めます。

バネを強くすると行き過ぎが大きくなります

03のバネは a = SNAP_K·(目標−x) − SNAP_C·v の二項だけです。K 120・C 14の減衰比は0.639で、行き過ぎ5.5%・収束912msですが、Kだけを320に上げると固有振動数は √(320/120) ≈ 1.63倍になる代わりに減衰比が0.391まで下がり、行き過ぎは24.1%に広がります。強く引き寄せる手応えと跳ね返る手応えは、この一つの数で分かれます。

レンズ曲線は端を押し縮めて収めます

08の標本位置 s = u·(1+K·|u|²) は、画面の中央(u 0)では元画像をそのまま読み、端(u ±1)では1+K倍を押し縮めて収めます。拡大率はその導関数の逆数 1/(1+3K·|u|²) なので、K 0.35では端の倍率0.488・中央の平らな帯156px・画面が収める元画像648pxと計算され、元のピクセルはgetImageDataで開始時に一度だけ読みます。

トラックパッドとホイールが混ざるとき・カードが数十枚のとき・タブが隠れたときに壊れるところ

九つの場面を触りながら、ソースとプローブで確かめた崩れどころをまとめます。

トラックパッドとホイールは同じ倍率では出会いません

wheelPx が縦横の大きいほうを選ぶのとは別に、トラックパッドの横スワイプは1回のイベントで deltaX が数十から数百pxで入ってくるため、同じ gain ならマウスのホイール(1目盛り100前後)よりずっと速く動きます。定石は gain を下げるか1ステップの移動に上限を置くことで、WHEEL_GAIN を大きく取ると、ホイール1目盛りでトラックの半分を飛び越えます。

端で縦スクロールへ返す条件

canMove(d) が端の向きで false を返すと、リスナーはそのまま戻って preventDefault を呼ばず、wheel イベントはページへ流れて縦スクロールが続きます。プローブで測った pass と blocked の結果が、この作りの裏付けです。09にはもう一つ例外があり、速度がすでに±720(±MAX_SPEED)に届いていれば横取りしません。02は進み具合が0か1に届いた瞬間に固定が外れ、wheel がページへ渡ります。

カードが数十枚になると先に崩れる三つ

01は MAX_X がトラックの長さだけ大きくなり、進捗バーのつまみの幅 (W−48)·W/(MAX_X+W) が12枚の128pxから40枚では39pxまで細くなり、数百枚では下限の10pxに張りつきます。06は dpr 3以上の画面で多角形を塗るカード12枚の描画コストが大きくなり、08は描き直すたびに14万4000ピクセルを置き換えるので、舞台を広げるとそのぶんコストが線形に増えます。元画像の幅 SRC_W の1730も固定なので、それより広い舞台では右端が背景色で埋まります。

タブが隠れていた時間は取り戻しません

1フレームで流す時間が100msで打ち切られ、一度に進むのは最大6ステップなので、タブを長く隠してから戻っても場面は暴走しません。その代わり、隠れていたあいだの時間は追いかけずに捨てます。

値が限界を越えるとすぐに壊れるところ

FRICTION を1以上にすると等比級数が発散してトラックが止まらなくなり、04の MAX_FOLD を90度より大きくすると折れた面どうしが重なり、06は RADIUS が CAM_D に届くと投影の分母が0以下になる角度が出てきます。05の奥行きに0が入ると移動量の割り算が発散し、07は SLACK を1.0以下にするとひもがたわまず、カードの重さを受け止められません。09は高さの上限58で文字の傾きが54.5度まで立ち上がって読みにくくなります。

反対に、03はポインターが取り消されても(pointercancel)そのまま release につながり、バネが枠に収めるので崩れません。

ここまでの定数を書き換えて、どこから崩れるのかを自分の画面で見るには zip を開く必要があり、そのパスワードは gfvx7uu4 です。

摩擦0.97・バネ320・レンズ0.8に変えてみる、そして横へ流れる画面のアクセシビリティ

名前 値 感触
ホイール慣性の摩擦を変える 01 FRICTION 0.92 → 0.97 滑る距離が149.4px/1056msから398.3px/2880msへ約2.7倍に伸び、等比の倍率は11.5から32.7になります。
スナップのバネの強さを変える 03 SNAP_K 120 → 320 減衰比0.639 → 0.391、行き過ぎ5.5% → 24.1%、収束は912ms → 880msでほとんど変わりません。
端のレンズの強さを変える 08 LENS_K 0.35 → 0.8 端の倍率0.488 → 0.294(タイル幅44.9 → 27.1px)、平らな帯156 → 104px、収める元画像648 → 864px。

この表の数字はレンダーの測定ではなく、ソースの関数を Node から直接呼んでシミュレーションした値なので、上の測定表とは分けて読んでください。

キーボードだけでも九つの場面を動かせます。<canvas> ごとに tabindex="0" と項目名の aria-label があり、←/→ で動かし、02は ↑/↓ も、01から08の八つの場面は Home/End も受け付けます(09は ←/→ だけ)。ボタンは場面ごとに一つか二つで、08のレンズの切り替えと09の停止の切り替えは aria-pressed でオンの状態を伝えます。状態表示の aria-live は自動の実演中は off で、人が初めて触れた瞬間に polite に変わります(プローブ実測9/9)。prefers-reduced-motionを有効にしている人にはrequestAnimationFrameのループをそもそも始めず、止まった場面を一度だけ描きますが、そのときもホイール・キー・ドラッグ・ボタンの入力はすぐに反映されます(プローブ実測9/9、07は揺らす操作だけを無視)。CSS 側の reduce ブロックは、ボタンの transition を消す一行だけです。

マウスホイール 横スクロール css はJavaScriptなしでできるか?

正直に答えると、この記事の場面はできません。ホイールの縦の値を横の移動に向け直した時点で、wheel イベントを聞いて条件つきで preventDefault するスクリプトが要り、慣性・スナップ・投影・レンズはフレームごとの計算なので CSS の宣言では書けないからです。九つを 横スクロール css js の分担で見ると、CSS は外枠を、JS は動きの中身を受け持っています。縦の進み具合を横の位置に移すだけの02の形は部分的になら CSS で作れる側に入り、スライダーとカルーセルを CSS だけで作ったcss only スライダーの記事がその隣にあたります。残りの八つの場面は、そのままキャンバスのスクリプトの領域です。スクロールの物理をもっと掘り下げるならスクロール慣性の物理パターンへ、視差の背景はcss パララックススクロールへ続けて読んでください。

画像 横スクロール css を自分のページに移す前によくある質問

横スクロール css js でドラッグして放す動きは、どの場面が受け持っていますか?

ドラッグのフリック専用は03です。放した瞬間の速度で着地の枠を予測し、バネで吸い付きます。02を除く八つの場面もポインターのドラッグを受け付け、01は放すときに引いていた速度(±60で切り詰め)を慣性として引き継ぎますが、その速度で着地の枠を先に選ぶのは03だけです。

カードの枚数を大きく増やしてもそのまま動きますか?

そのままでは崩れるので、定数を取り直す必要があります。01は進捗バーのつまみが12枚の128pxから40枚では39pxまで細くなり、数百枚では下限の10pxに張りつきます。08は元画像の幅 SRC_W(16枚で1730px)がタイルの数から計算されますが、それより広い舞台では右側が背景色で埋まります。03のように枠の幅が決まっている場面は、カードの数を増やすだけで済みます。

スマホのタッチでは縦スクロールとどう分かれますか?

キャンバスに touch-action: pan-y がかかっているので、指で横に引く操作は場面が受け取り、縦のスワイプはブラウザへ渡ります(9/9)。ページの縦スクロールを奪わないので、本文の流れの上に載せて使えます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。