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

スクロール 連動 アニメーション 9種 — 線・道・しっぽ・らせん

スクロール 連動 アニメーションで線や物を曲がった道に沿わせると、曲がり角でだけ急に速くなる癖が出ます。スクロールの進み具合をそのまま曲線の変数に入れると、01番のペン先の速さは曲がりごとに最大6.2倍まで開きます。

舞台の中で回してみるスクロール進行の九場面

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

触らずに置いておくと、各マスはシードで決まった往復の時間割に沿って自分でスクロールします。一周は5,400msで、内訳は待ち200ms・前進2,600ms・終点での停止500ms・後退1,800ms・休み300msです。前進の区間は4本の脚(04と08は5本)に分かれ、脚ごとに進む量とかかる時間が違うので、速さが途中で何度も変わります。マスの上でホイールを回すか、押すか、Tabキーで入ると実演はその場で止まり、その時点の進み具合から先は自分の操作で動かせます。

並び順は計算の依存関係に沿っています。01が長さの表を作り、02はその表から接線を、03は道しるべまでの長さを、04は法線と速さを取り出します。05は表のいらない三次元のらせんへ一度寄り道し、06は表の上を走る頭にしっぽをつなぎ、07は曲線ではなく二つの輪郭の周の長さを測ります。08は一枚の表を160個の点で分け合い、09は01・02・03を配送の道一本にまとめた総まとめです。

スクロール 連動 アニメーション 9種 — 等速の線・接線で向きを変える紙飛行機・道しるべのピン・速さで揺れる線・らせんの投影・引かれるしっぽ・形の変化・流れる点・配送追跡の道のり

九つの場面はどれも2Dのキャンバスに描かれ、スタイルシートに@keyframesは一つもありません。画面に入ったカードをふわっと出す演出とは違い、ここで動きを決めるのは進み具合という数字ひとつで、それを曲線の上の距離に直し、その距離にある点・接線・法線を読み出して絵にします。requestAnimationFrameが渡す経過時間は16msの固定ステップに切られ、1フレームで最大6ステップまで進み、フレームの間が空いても取り戻すのは100msまでです。

キャンバスの上に重ねた透明なスクロール箱

九つのマスは同じ作りです。絵を受け持つのはaria-hidden="true"を付けたキャンバスで、その上にposition: absolute; inset: 0の.sp__scrollerという箱が透明なまま重なり、ホイール・指・キーを受け止めます。箱の中には中身のない.sp__trackがheight: 600%で入っているため、箱の高さ5つ分を回すと進み具合が0から1まで動き、その値は箱自身のscrollTopだけから計算されます。

    // 箱の進み具合 — スクロールした距離 / スクロールできる距離。ページではなく舞台の中の箱の scrollTop だけを読む
    function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
    function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
    function placeScroll(p) {
        scroller.scrollTop = p * maxScroll();
        expectTop = scroller.scrollTop;
    }

480×300の画面で舞台を測ると418.6×261.6pxで、回せる距離はその高さの5倍にあたる1,307pxでした。300pxのホイール1回で進み具合はおよそ23ポイント上がり、そのあいだwindow.scrollYは0から動きませんでした。実演中はコードが箱も同じ進み具合までスクロールしておくのでスクロールバーも一緒に動き、自分で動かした位置をexpectTopに書き残すことで、それを人の操作と取り違えないようにしています。

2秒の録画で数えた九場面の動き

各場面を480×300で2秒間録画して24枚を取り出し、隣り合う2枚の差を23回比べて、画面が変わった回数と変わった面積を数えました。

場面 累積面積 動いたフレーム 強度平均
01 等速の線 18.6% 21/23 82.7
02 接線で向きを変える紙飛行機 11.3% 23/23 112.1
03 道しるべのピン 15.1% 23/23 97.0
04 速さで揺れる線 17.9% 23/23 80.3
05 らせんの投影 47.7% 23/23 119.1
06 引かれるしっぽ 22.1% 23/23 142.7
07 形の変化 25.6% 23/23 106.7
08 流れる点 24.6% 23/23 127.8
09 配送追跡の道のり 12.9% 23/23 120.4

05だけが47.7%と大きいのは、ボールを追ってカメラが下りるにつれ段の板が舞台の中央を埋めたまま上へ送られ、階段全体の画素が入れ替わるためと考えられます。02と09が11〜13%にとどまるのは、線と飛行機、あるいは箱ひとつが通り過ぎた画素だけが変わる場面だからと考えられます。01だけが21/23なのは、進み具合が6%を越えるまで線を引き始めないうえに最初の200msの待ちが重なり、冒頭の数枚が同じ絵だったためと考えられます。

紹介ページから配送状況まで、載せる場所

九つの場面には、それぞれ実際のページでの居場所を一つずつ決めてあります。01はサービス紹介ページを縦に貫く流れ線、02はお問い合わせ欄へ視線を運ぶ飾り、03は会社沿革のタイムライン、04はランディングページの区切り線です。05はアプリ機能の段階紹介、06はブランドのマスコット案内、07は料金プラン比較欄のアイコン切り替え、08はデータの流れの説明図に向いています。動く見本を手元で触れるスクロール アニメーション サンプルとして使う場合も、この割り当てを手がかりに自分のページのどこへ置くかを決めやすいはずです。

09は注文の配送状況画面をまねた架空の場面で、地名や住所は出てきません。停留所は「出発地」「停留所 1」「停留所 2」「到着地」の四つで、地図はシードで大きさだけを変えた四角いブロック40個でできています。本物の配送状況画面なら進み具合にはスクロールではなく配送段階から計算した値を入れることになると考えられますが、残りの距離を長さの割合で数える部分はそのまま使えます。

01曲がり道でも同じ速さで引かれる線

点の間隔をわざと不ぞろいにした9点の道に、進み具合6%から94%のあいだで線を引く場面で、進み具合を曲線の変数uへ直接入れず、PATH_SAMPLES 200区間で作った累積の長さの表を二分探索して長さごとのuを求めます。進み具合を400等分の刻みで上げたときのペン先は一刻み3.047〜3.267pxで1.07倍以内に収まり、同じ長さごとに打った24本の目盛りでそのそろい方が目に見えるので、サービス紹介ページで一定の速さで伸びてほしいスクロール 線 アニメーションに向いています。

曲がり道でも同じ速さで引かれる線
新しいタブで開く

01 · 曲がり道でも同じ速さで引かれる線 — 計測 累積面積 18.596% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PATH_SAMPLESDRAW_STARTDRAW_END
    function buildLengthTable(curve, samples) {
        const us = [0];
        const lens = [0];
        let prev = curve(0);
        let acc = 0;
        for (let i = 1; i <= samples; i += 1) {
            const u = i / samples;
            const p = curve(u);
            acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
            us.push(u);
            lens.push(acc);
            prev = p;
        }
        return { curve: curve, us: us, lens: lens, total: acc };
    }
    // 長さ → u — 累積の長さの表で s が入る区間を二分探索で見つけ、区間の中は長さに比例して分ける
    function uAtLength(tab, s) {
        const L = clamp(s, 0, tab.total);
        let lo = 0;
        let hi = tab.lens.length - 1;
        while (hi - lo > 1) {
            const mid = (lo + hi) >> 1;
            if (tab.lens[mid] < L) {
                lo = mid;
            }
            else {
                hi = mid;
            }
        }
        const span = tab.lens[hi] - tab.lens[lo];
        const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
        return lerp(tab.us[lo], tab.us[hi], k);
    }

02道の接線の向きへ機首を向けて飛ぶ紙飛行機

紙飛行機は今の点と18px先の点の差から接線の角度を取り、機首の向きとの差を-180°〜180°に折りたたんでから、そのうちTURN_SMOOTH 0.14だけをティックごとに回します。逆向きにスクロールすると最後に動いた向きdirが-1になって後ろの点を見るようになり、その瞬間に目標は177.6°裏返りますが、機首は640msかけて半回転し、目標との差が5°以内に戻ります。ピンクの点線が先に目標を指し、飛行機がそれを追いかけるので、お問い合わせ欄へ視線を導く飾りに合います。

道の接線の向きへ機首を向けて飛ぶ紙飛行機
新しいタブで開く

02 · 道の接線の向きへ機首を向けて飛ぶ紙飛行機 — 計測 累積面積 11.272% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOLLOW_LOOKAHEADTURN_SMOOTHPLANE_SIZE
    function aimAt(s) {
        const a = pointAtLength(TABLE, s);
        const b = pointAtLength(TABLE, s + dir * FOLLOW_LOOKAHEAD);
        const dx = b[0] - a[0];
        const dy = b[1] - a[1];
        if (Math.hypot(dx, dy) < 0.5) {
            return target;
        }
        return Math.atan2(dy, dx);
    }
    function step(dp) {
        if (dp > 1e-6) {
            dir = 1;
        }
        else if (dp < -1e-6) {
            dir = -1;
        }
        target = aimAt(travel());
        // いちばん短い向き — 角度の差を -180°〜180° に折りたたんでから、その割合だけ回る
        turn = wrapAngle(target - heading) * TURN_SMOOTH;
        heading = wrapAngle(heading + turn);
    }

03通り過ぎた瞬間にばねで立ち上がる道しるべ

五つの道しるべはuを0.1・0.3・0.5・0.7・0.9と等分した位置に立っていますが、長さで測ると543.2pxの道の8.0・27.1・48.0・69.3・90.3%にあたるため、lengthAtUで一本ずつの長さを先に記録しておき、進んだ長さがそれを越えた瞬間に旗の目標を1へ切り替えます。剛性0.12・減衰0.8のばねで旗は直立を29.2°行き過ぎてから544msで落ち着き、戻すと反対側へ同じ544ms揺れて倒れるので、年ごとの出来事が順に立ち上がる会社沿革のタイムラインにそのまま使えます。

通り過ぎた瞬間にばねで立ち上がる道しるべ
新しいタブで開く

03 · 通り過ぎた瞬間にばねで立ち上がる道しるべ — 計測 累積面積 15.065% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MILESTONE_COUNTPIN_STIFFNESSPIN_DAMPING
    function step(dp) {
        const s = travel();
        for (let i = 0; i < PINS.length; i += 1) {
            const pin = PINS[i];
            pin.up = s >= pin.s;
            const goal = pin.up ? 1 : 0;
            pin.v += (goal - pin.x) * PIN_STIFFNESS;
            pin.v *= PIN_DAMPING;
            pin.x += pin.v;
        }
    }

04速く回すほど太く揺れる線

ティックごとの進み具合の差から符号を外した大きさをVELOCITY_SMOOTH 0.08のローパスフィルターに通した値は、1ティックあたり0.008の進みで効果が最大の強さに達し、それより速く回しても強さはそこで頭打ちです。その値に比例して、目印のまわりの線は太さ3pxから16pxまで膨らみ、法線方向へ最大14px押し出すサイン波がかかります。目印から離れると波も太さも150pxの釣り鐘形の中で細り、手を止めるとフィルターの値は464msで8%を下回り、波は480msで1px未満に収まるので、ランディングページの区切り線がスクロールの勢いを一拍だけ残してから静まります。

速く回すほど太く揺れる線
新しいタブで開く

04 · 速く回すほど太く揺れる線 — 計測 累積面積 17.865% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

VELOCITY_SMOOTHWOBBLE_AMP_MAXSTROKE_WIDTH_MAX
    function step(dp) { vel += (Math.abs(dp) - vel) * VELOCITY_SMOOTH; }
    // 長さ s の位置の波 — 法線方向のサイン変位 × 釣り鐘形の減衰
    function waveAt(s, s0, amp) {
        const base = pointAtLength(TABLE, s);
        const n = normalAt(TABLE, s);
        const d = (s - s0) / WAVE_REACH;
        const env = Math.exp(-d * d);
        const off = Math.sin(s * WAVE_K - prog * WAVE_DRIFT) * amp * env;
        return [[base[0] + n[0] * off, base[1] + n[1] * off], env];
    }

05らせん階段を回り下りるボールと遠近

らせんの角度(2.5周)も高さ(300)も一つの進み具合に正比例するので、この場面は長さの表なしで同じ速さで下り、段の点はカメラの高さを引いて0.5 rad傾け、360 ÷(360 + 奥行き)を掛けて画面に移されます。この倍率はいちばん奥の板の先で0.751、いちばん手前で1.496になり、ボールの直径は21.5pxから34.3pxまで変わり、板・柱の切れ端・ボールの影・ボールを奥から手前の順に並べ直して、最も奥の側は背景色へ55%寄せて淡くします。下りる一段ごとに機能を一つ割り当てれば、アプリ機能の段階紹介として読める並びになります。

らせん階段を回り下りるボールと遠近
新しいタブで開く

05 · らせん階段を回り下りるボールと遠近 — 計測 累積面積 47.66% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HELIX_TURNSHELIX_RADIUSCAMERA_DISTANCE
    function project(x, y, z) {
        const yy = y - camY;
        const c = Math.cos(CAMERA_TILT);
        const sn = Math.sin(CAMERA_TILT);
        const y2 = yy * c - z * sn;
        const z2 = yy * sn + z * c;
        const k = CAMERA_DISTANCE / (CAMERA_DISTANCE + z2);
        return { x: CX + x * k, y: CY + y2 * k, z: z2, k: k };
    }

06節の長さを保って道に沿って引かれるしっぽ

頭は道の上の進んだ長さに固定され、12個の節は前のティックで動いた分の0.9倍を今回も動いたあと、隣の節との距離を15pxへ戻す合わせ直しを6回受けます。実演の一周で節の長さの誤差は最大18.3%でしたが、合わせ直しを1回にすると88.1%まで広がってしっぽがゴムのように伸びたので、しっぽがキャラクターの一部になるブランドのマスコット案内では、繰り返しの回数にかかる手間を払う価値があります。

節の長さを保って道に沿って引かれるしっぽ
新しいタブで開く

06 · 節の長さを保って道に沿って引かれるしっぽ — 計測 累積面積 22.145% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TAIL_SEGMENTSSEGMENT_LENGTHCONSTRAINT_ITERATIONS
        // 前の位置との差で動かす — 前のティックで進んだ分だけ(引きずりを掛けて)今回のティックも進む
        for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
            const vx = (xs[i] - pxs[i]) * TAIL_DRAG;
            const vy = (ys[i] - pys[i]) * TAIL_DRAG;
            pxs[i] = xs[i];
            pys[i] = ys[i];
            xs[i] += vx;
            ys[i] += vy;
        }
        // 節の距離の合わせ直し — 頭(0)は道の上に固定、1 番は一人で全部動き、残りの隣り合う組は半分ずつ動く
        for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
            for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
                const dx = xs[i] - xs[i - 1];
                const dy = ys[i] - ys[i - 1];
                const d = Math.hypot(dx, dy) || 1e-6;
                const diff = (d - SEGMENT_LENGTH) / d;
                if (i === 1) {
                    xs[i] -= dx * diff;
                    ys[i] -= dy * diff;
                }
                else {
                    xs[i] -= dx * diff * 0.5;
                    ys[i] -= dy * diff * 0.5;
                    xs[i - 1] += dx * diff * 0.5;
                    ys[i - 1] += dy * diff * 0.5;
                }
            }
        }

07同じ長さの間隔で点を打ち直して形を変える線

星の10点とハートの40点はもともと間隔が不ぞろい(ハートは2.07〜20.71px)なので、両方の輪郭を周の長さで等間隔に96点ずつ打ち直し、96通りの回し方のうち距離の二乗和がいちばん小さい48マスのずらしで始点をそろえます。そろえずに混ぜると点一つが形の中を平均137.6px横切るのに対し、ずらしたあとは34.4pxで済み、進み具合0.25〜0.75のあいだは舞台の中央に形を止めたまま混ぜるので、料金プラン比較欄のアイコン切り替えに向いています。

同じ長さの間隔で点を打ち直して形を変える線
新しいタブで開く

07 · 同じ長さの間隔で点を打ち直して形を変える線 — 計測 累積面積 25.627% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MORPH_POINTSPIN_RANGE_STARTPIN_RANGE_END
    // 始点合わせ — b を k マス回して組にしたとき、距離の二乗和がいちばん小さい k を選ぶ
    function bestShift(a, b) {
        const n = a.length;
        let best = 0;
        let bestCost = Infinity;
        for (let k = 0; k < n; k += 1) {
            let cost = 0;
            for (let i = 0; i < n; i += 1) {
                const q = b[(i + k) % n];
                cost += (a[i][0] - q[0]) * (a[i][0] - q[0]) + (a[i][1] - q[1]) * (a[i][1] - q[1]);
            }
            if (cost < bestCost) {
                bestCost = cost;
                best = k;
            }
        }
        return best;
    }

08スクロールの速さの分だけ道を流れる点の群れ

160個の点はそれぞれ道の上の長さと横へのずれを別々に持ち、ティックごとに基本の流れ0.5pxに、0.2の割合でならしたスクロール量の2.4倍を足した分だけ進みます。横のずれはシード付きの256マスの値ノイズで±40pxの中を揺れて群れが水流のように見え、実演中の1ティックの前進は最大で基本の40倍の20.01pxに達し、戻るときには-18.71pxと逆向きに流れたので、データの流れの説明図で向きまで伝えられます。

スクロールの速さの分だけ道を流れる点の群れ
新しいタブで開く

08 · スクロールの速さの分だけ道を流れる点の群れ — 計測 累積面積 24.613% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLOW_DOTSFLOW_SPEED_GAINFLOW_SPREAD
    function step(dp) {
        flow += (dp * TABLE.total - flow) * FLOW_SMOOTH;
        age += 1;
        const adv = advance();
        for (let i = 0; i < dots.length; i += 1) {
            const d = dots[i];
            d.s = ((d.s + adv * d.pace) % TABLE.total + TABLE.total) % TABLE.total;
        }
    }
    function sideOf(d, s) {
        return clamp(d.lane * FLOW_SPREAD * 0.75 + noise1(s * JITTER_SCALE + age * JITTER_TIME + d.seed) * FLOW_SPREAD * JITTER_AMP, -FLOW_SPREAD, FLOW_SPREAD);
    }

09地図の道を走りながら残りの距離を数える配送追跡の道のり

01の線引き・02の接線追い・03のばねのピンを695.1pxの一本の道にまとめた場面です。箱は接線の向きへ体を回さず、減衰で追う接線の角度がそのティックで回った分の8倍だけ曲がりの内側へ傾くだけで、その傾きは±0.35 rad(20.1°)を超えません。残りの距離は12.6 km ×(1 − 進んだ長さ ÷ 全体の長さ)で、uを等分して置いた四つの停留所は12.6・8.66・4.10・0 kmという不ぞろいな読みで通過し、状態の行は「出発準備」から「出発地 通過」「停留所 1 通過」のように停留所の名前に「通過」が付いた表示を経て「到着しました」へ移るので、注文の配送状況画面の流れそのものです。

地図の道を走りながら残りの距離を数える配送追跡の道のり
新しいタブで開く

09 · 地図の道を走りながら残りの距離を数える配送追跡の道のり — 計測 累積面積 12.894% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ROUTE_TOTAL_KMSTOP_COUNTBOX_TILT_MAX
    function step(dp) {
        if (dp > 1e-6) {
            dir = 1;
        }
        else if (dp < -1e-6) {
            dir = -1;
        }
        // 02 — 接線の角度をいちばん短い向きへ減衰で追い、今回のティックで回った角度に比例して箱を曲がりの内側へ傾ける
        target = aimAt(travel());
        turn = wrapAngle(target - heading) * ROUTE_TURN_SMOOTH;
        heading = wrapAngle(heading + turn);
        tilt += (clamp(turn * TILT_GAIN, -BOX_TILT_MAX, BOX_TILT_MAX) - tilt) * 0.2;
        // 03 — 停留所の長さを越えるとピンがばねで立ち、戻すと再び押し下げられる
        const s = travel();
        for (let i = 0; i < STOPS.length; i += 1) {
            const st = STOPS[i];
            st.up = s >= st.s;
            st.v += ((st.up ? 1 : 0) - st.x) * STOP_STIFFNESS;
            st.v *= STOP_DAMPING;
            st.x += st.v;
        }
    }

進み具合をそのまま曲線の変数に入れると、なぜ曲がり角で速くなったり遅くなったりするのか?

区間ごとに同じだけのuが配られる

ここで使う道は、makeCurveが点の並びを通るように作る三次曲線です。二つの点のあいだの一区間の形は前後四つの点で決まり、曲線の変数uはその区間が長くても短くても同じ量ずつ割り当てられます。長い区間ではuがわずかに変わるだけでペン先が遠くまで進み、短い区間や鋭く曲がる区間では同じ変化が数pxにしかならないため、一定の進み具合が画面の上では不ぞろいな速さに化けます。

400刻みで測ったペン先の速さ

01の道で進み具合を400等分の刻みで上げ、一刻みでペン先が進んだ距離を、4,000区間で測った本当の長さと照らし合わせました。道の全長は1,261.8pxで、200区間の表は1,261.1pxを記録します。

進み具合から位置への変換 一刻みの最小 一刻みの最大 最大 ÷ 最小 変動係数
uをそのまま 0.842 px 5.191 px 6.16倍 36.49%
24区間の長さの表 1.252 px 5.365 px 4.29倍 25.24%
200区間の長さの表 3.047 px 3.267 px 1.07倍 1.12%

uをそのまま入れても、0.25・0.5・0.75は道の23.3%・47.7%・76.6%の地点に来るので、大まかには合っているように見えます。困るのはそのあいだの一刻みずつで、ばらつきが6倍を超え、目は平均よりも先にそのがくつきに気づきます。SVGならgetTotalLengthとgetPointAtLengthで長さを測って同じ問題を避けられますが、キャンバスでは次の節の表がその役を引き受けます。

曲線を区切って累積の長さの表を作り、進み具合を長さに直して点と接線を求める計算

表は一度だけ作り、ティックごとに二分探索する

buildLengthTableはuを200区間に切り、各区間の終わりまでの直線距離を足し上げていきます。これは場面の始まりに一度だけで、あとはティックごとにuAtLengthが長さsの入る区間を二分探索で見つけるだけです。表の項目は201個なので比較は8回以内で終わり、区間の中では長さに比例してuを分けます。01は進み具合をDRAW_STARTとDRAW_ENDのあいだで切り取って0〜1に引き伸ばし、全長を掛けて引く長さを出し、tracePathが道全体で600刻みにあたる細かさでuをたどるので、表の区間数にかかわらず描かれた線はなめらかです。

同じ表から接線と法線を取り出す

表があれば長さsの位置の点はすぐ引けるので、向きも長さで測ります。02と09はsとs + 18px(09は16px)の点の差をatan2に渡して接線の角度を出し、二つの点が0.5px以内に寄る道の端では前の角度を保ちます。04のnormalAtはsの前後1pxの点の差を90°回して法線を作り、波はその向きへ押し出されます。03と09は逆向きにlengthAtUを使い、道しるべや停留所が道の何px目にあるかを前もって測っておきます。

表を使わない二つの場面

05のらせんは角度も高さも進み具合に正比例するので、直すべき長さの偏りがありません。07は曲線ではなく多角形の輪郭を扱うので、resampleが辺の長さを直接足して周の長さを出し、等間隔に点を打ち直します。残りの七つ、01〜04・06・08・09は同じ表の作り方と引き方を共有しています。

区間を減らす・揺れを大きくする・しっぽを長くする三つの変形

名前 値 感触
長さの表の区間を減らす 01 PATH_SAMPLES 200 → 24 200なら一刻み3.05〜3.27px(1.07倍以内)でそろって進みます。24では表の一区間が長くなり、一刻みが1.25〜5.37px(4.3倍)に開いて、急な曲がりで線が一瞬先走ってから止まりかけます
揺れの幅を大きくする 04 WOBBLE_AMP_MAX 14 → 36 14なら最も速いときの波は法線方向に14pxずれます。36では36pxと大きくうねり、フィルターが静まる464msは変わらないのに、波が1px未満に収まるまでが480msから656msに延びます
しっぽの節を増やす 06 TAIL_SEGMENTS 12 → 30 12なら180pxのしっぽで、実演中の節の誤差は最大18.3%です。30では450pxに伸び、先端が道から最大447.5px外れて曲がりの内側を大きく横切り、同じ6回の合わせ直しでは誤差が最大36.4%に広がります

どの値も何かと引き換えです。区間を減らせば表を作る手間が減る代わりに等速が崩れ、揺れを大きくすればスクロールの勢いは派手に伝わる代わりに止めてから静まるまでが長くなります。しっぽを長くすると道から離れやすくなるので、節を30のように増やすときはCONSTRAINT_ITERATIONSも一緒に上げ、誤差を測り直すのが安全です。

鋭く折れた道・逆向きのスクロール・一度に大きく跳ぶホイール — 道をたどる動きが壊れるところ

急な曲がりで区間が足りないとき

24区間の表に減らすと、ペン先の速さが4.29倍に開くうえ、表の全長も1,242.9pxになり本当の長さより18.9px足りなくなります。区間の端どうしを結ぶ弦が曲線のふくらみを近道してしまい、曲がりがきついほど一区間の中でuと長さが比例しなくなるので、区間の内側で速さがまた揺れます。

戻した瞬間に接線が裏返る

02と09では、向きが変わった瞬間に先を見る点が後ろへ飛び、接線の目標が177.6°回ります。TURN_SMOOTHを1にすると飛行機は16msの1ティックで半回転し、0.14なら640msに分けて回ります。FOLLOW_LOOKAHEADを4pxに縮めると5°以内に戻るまでが1,680msに延びたので、近くを見ても向きが速く戻るわけではありません。

ホイール一回で進み具合が大きく跳ぶとき

Endキーのように進み具合が一度に大きく入ると、04の速さの値はすぐ上限へ振り切れ、状態の行は「速く回しています」になります。06では頭が1ティックで先へ飛び、6回の合わせ直しでは追いつけずに節が伸びたまま届きます。03で道しるべをいくつも一度に越えるとそれぞれの旗が別々に揺れ、減衰を0.8から0.95へ上げると行き過ぎは29.2°から71.7°に、落ち着くまでは544msから2,368msに増えます。

長さの表・接線追い・節の合わせ直しを崩さずに収めた九場面のindex.htmlと、同じ計算を移したReact版9本は一つのzipにまとめてあり、展開するときにtmvtd6svを入れると開きます。

舞台の上のホイールは舞台の中で止まる

箱にはoverscroll-behavior: containが付いているので、ポインターが舞台の上にあるあいだホイールは箱だけを回し、端に着いても余った分はページへ渡りません。ホイールを横取りするコードはありませんが、舞台の上のホイールは箱のものなので、記事の途中でそこを回した読者はページが止まったように感じることがあります。ページを読み進めるにはポインターを舞台の外へ出す必要があるため、実際のページでは舞台を画面の高さの一部にとどめるほうが無難と考えられます。進み具合は箱の高さにも縛られていて、回せる距離は箱の高さの5倍なので、320px幅のような小さい舞台ではホイールひと目盛りで進み具合がより大きく動き、画面サイズが変わるとplaceScrollが同じ進み具合の位置まで箱を回し直します。

カメラに迫る点

05の倍率は360 ÷(360 + 奥行き)なので、奥行きが-360に近づくと割る数が0に近づき、板が破裂するように大きくなります。今の配置でいちばん手前の奥行きは-119で、倍率は1.496で頭打ちです。板は一枚ごとの平均の奥行きで並べているので、長く重なる配置では前後が入れ替わるおそれがあると考えられますが、ここのように板が互いを貫かない並びでは見た目の乱れはありませんでした。

ページのスクロールは奪わない — 舞台だけのスクロール箱と動きを減らすアクセシビリティ

九つの場面でwindowに付けるリスナーはresizeだけで、スクロールとホイールのリスナーは舞台の中の箱に付けます。ホイールのリスナーは{ passive: true }で実演を止める役しか持たず、preventDefaultは呼ばず、position: stickyでページを留める区間もありません。ホイールを横取りして縦の回転を横の移動に変える作りは横スクロールの9場面で扱ったので、この編はその反対の道を選びました。

キーボードで進める操作と状態の行

箱にはtabindex="0"・role="region"と、場面の内容と操作方法を書いたaria-labelがあります。フォーカスがあるとき、↓と→で4%進み、↑と←で4%戻り、PageDownで25%進み、PageUpで25%戻り、Homeで始点、Endで終点へ移ります。preventDefaultはこのキーにだけかかるので、スペースキーなどは箱の中でふだんどおりに働きます。状態の行は実演中はaria-live="off"のまま、最初の操作でpoliteに切り替わり、「進み 30%」のように5%刻みでしか書き換えないので、回すたびにスクリーンリーダーへ数字があふれることはありません。リスナーは一つのAbortControllerで付け、片付けの関数でまとめて外すので、React版のアンマウントも同じ手順です。

動きを減らす設定で残る画面

prefers-reduced-motion: reduceが有効なら実演の時間割もアニメーションのループも動かず、代わりにsettle()が240ステップを描かずに一度で計算して、落ち着いた一枚を残します。01〜06・08・09は進み具合の終点(1)で開くので、01は「線を引き終えました」、09は残りの距離0.0 kmと「到着しました」から始まります。07だけは固定区間の終わりの0.75で止まり、これは1まで進むと形が舞台の外へ抜けて空の舞台しか残らないからです。箱を回したりキーを押したりすればafter()が同じ計算をやり直してその進み具合の落ち着いた画面をすぐ描き、メディアクエリのchangeリスナーがページを開いたまま変えた設定も拾います。

スクロール アニメーション 作り方でつまずきやすい点 — よくある質問

スクロール 連動 css だけで、曲線に沿って線を引けますか?

線を引くところまではCSSでできます。スクロールで語るセクションの経路の地図の項目はCSSのスクロールタイムラインとSVGのパスで線を引き、長さの計算はSVG側が受け持ちます。ただ、02の接線追い、03のばねの落ち着き、06の節の合わせ直しのように前のティックの状態を覚えておく動きは進み具合だけでは決まらないので、スクリプトが必要です。

箱ではなくページ全体のスクロールにつなぐには、どこを変えますか?

進み具合を読む場所はreadScrollの一か所なので、箱のscrollTopの代わりにページの中でその区間が通り過ぎた割合を入れれば、残りの計算はそのまま使えると考えられます。その場合は重ねてあるスクロール箱とoverscroll-behavior: containも外さないと、ホイールが箱に閉じ込められたままになります。ページの深さを数字やバッジで見せるだけなら、スクロールインジケーターのほうが軽く済みます。

ホイールを勢いよく弾くと、計算が遅れることはありますか?

1フレームで進めるのは最大6ステップで、フレームの間が長く空いても取り戻すのは100msまでなので、しばらく放っておいたタブに戻っても何百ステップもまとめて走ることはありません。長さの表は場面の始まりに一度作るだけで、ティックごとの仕事は二分探索だけです。キャンバスの画素の倍率もMath.min(window.devicePixelRatio || 1, 2)で2までに抑えています。

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

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