スクロールモーショングラフィックス
スクロール位置によって駆動されるCanvasタイムライン:ピン留めされたステージ、進行度の純粋関数であるリール、そして描画が決して触れてはならないキャプション。この手法は、ImuTechzホームページのストーリードラム、R&Dドラム、Founderパスフィルムで使用されています。また、assets/demo.htmlの5リールデモでも採用されています。
このテクニック全体は一文で表現できます。スクロール位置が唯一の時計です。それをp ∈ [0, 1]にマッピングし、すべてのピクセル、キャプション、回転をpの関数にします。
理想の動作
- ビデオのようにスクラブする。 前方にスクロールするとフィルムが再生され、後方にスクロールするとフレーム単位で巻き戻されます。途中で止めると、意味のあるフレームが表示されます。
- ページが乗っ取られることはありません。 ホイール、トラックパッド、タッチ、キー操作は、他の場所とまったく同じようにページを動かします。セクションは単に高くなるだけです。
- テキストは常に読みやすい。 キャプションはカード上の実際のDOMテキストです。描画はキャプションが残したスペースに自身を配置し、Canvasは安全策としてキャプションの周りでクリップされます。
- 見せるためだけでなく、操作のためでもある。 ストーリードラムの面はリンクです。そのレール、矢印ボタン、矢印キー、マウスドラッグはすべてページをスクロールすることでシークするため、スクロールとドラムの間で矛盾が生じることはありません。
- アイドル時のコストはゼロ。 pが安定した後、またはセクションがビューポートから外れた後は、requestAnimationFrameは実行されません。
- 動きの軽減設定を尊重する。 スムージング、中間ドラム回転、キャプション出現アニメーションは行われません。すべてのコンテンツはそのまま存在します。
- 360pxから1920pxの幅、ライトモードとダークモード、DPR 1と2で動作する。
厳格なルール
- pはスクロール位置から取得し、時計からは決して取得しない。 リール内で
performance.now()、Date、CSSアニメーション、またはsetIntervalを使用しないでください。スムージング(ターゲットpへのlerp)は、同じフレームに収束するため許可されます。 - リールは純粋関数
draw(ctx, t, env)である。 フレーム間で状態を保持せず、Math.random()を呼び出してはいけません。代わりに、シード付きのhash(i)を使用してください。同じ (t, env) は同じピクセルを描画しなければなりません。 - ピン留めはJSではなくCSSで行う。 高いトラック(
position: relative; height: N×vh)を使用し、その中にposition: sticky; top: 0; height: 100svhのステージを配置します。ステージにposition: fixedを設定したり、bodyをロックしたり、トラックの祖先にoverflow: hidden|auto|clipを設定したりしないでください。 - 入力は決して奪わない。 ホイールやタッチに対して
preventDefaultを使用せず、htmlやbodyにoverflow: hiddenを設定せず、トラックにスクロールスナップを設定しないでください。フォーカスされたドラムのみが矢印キーを処理でき、その場合もページをスクロールすることで行います。 - シークはスクロールで行う。 すべてのボタン、レールアイテム、キー、アンカーは
window.scrollYを変更します。pや回転を直接設定しないでください。プログラムによるジャンプでアニメーションさせたくない場合はbehavior: "instant"を使用してください。"auto"はページのCSSscroll-behavior: smoothに従うためです。 - テキストはDOM内に存在する。 キャプション、タイトル、リンクはHTMLです。Canvasテキストは、年号、軸の目盛り、ステップ名などの装飾ラベルに限定されます。
- 毎フレームキャプションを測定し、描画をキャプションから遠ざける。 各キャプションとHUD要素は立入禁止領域(
data-keep)です。リールはenv.free内にレイアウトされ、エンジンは偶数奇数のクリップで立入禁止領域をクリップします。 - 一度に2つのキャプションを表示しない。 キャプションウィンドウは、各境界にギャップを設けてリール内に配置され、リールはそのギャップ内でクロスフェードします。
- アイドル時はループを停止する。 セクションがビューポートの近くにある場合にのみ、スクロールまたはリサイズ時にrAFを開始し(IntersectionObserver)、pがターゲットと等しくなったら停止します。
- Canvasのサイズは
min(devicePixelRatio, 2)でステージに合わせる。 毎フレームsetTransform(dpr, …)を呼び出し、毎フレームクリアします。 - 色はCSSカスタムプロパティから取得する。 ライトモードのパレットをリールにハードコードしないでください。
prefers-color-schemeの変更時にパレットを再読み込みします。 prefers-reduced-motion: reduceを尊重する。 スムージングを使用せず、ドラムを面ごとにスナップさせ、キャプション出現アニメーションをオフにします。フレームは引き続きスクロールに追従します。これはユーザーが操作しているためです。
構築手順
この順序で行います。各ステップは次のステップの前にテスト可能です。
- まずコンテンツ。 チャプターを書きます。各チャプターのキッカー、タイトル、1~2文の説明、オプションのリンク。リールの数(3~7)を決め、それぞれに概ね80~100svhのスクロール範囲を与えます。
- トラックとステージ。 高いセクションとその中にスティッキーな100svhのステージを追加し、JSなしでピン留めされることを確認します(layout-and-captions 参照)。
- スクラブエンジン。
ScrollMotion.scrubまたはfilm(engine 参照)を配線し、pをログ出力します。ピン開始時に0、ピン終了時に1を読み取り、双方向に動くことを確認します。 - DOM内のキャプションとHUD。 ブレークポイントごとに配置し、
data-keepとしてマークし、エンジンにpから不透明度を制御させます。 - リールを一つずつ。
env.free内でdraw(ctx, t, env)を書き(drawing 参照)、次のリールを書く前に前後でスクラブします。 - モーション文法。 エントリー、ホールド、エグジットのエンベロープ、スターガー、ディウェル、クロスフェードを追加します(motion-grammar 参照)。
- インタラクティブ性(ドラムの場合)。 面のリンク、レール、前後ボタン、キー、ドラッグを追加します。これらはすべてスクロールでシークします。
- パフォーマンスとアクセシビリティのパス。 アイドルループ、DPR、動きの軽減、フォーカス順序、スクリーンリーダーテキスト、ダークモードをカバーします(performance-and-a11y 参照)。
- 検証。 ヘッドレススクリプトを使用して、360、390、414、768、1024、1280、1440、1920で検証します(verification 参照)。すべてのチェックが通るまでは完了と呼びません。
決定チェックリスト
Canvasフィルム or DOM/CSS変換?
- 多数の小さな可動部品、線を描画するもの、パーティクル、チャート → Canvasフィルム(
ScrollMotion.film)。 - 少数のカード、画像、テキストパネル →
scrubによって駆動されるDOM変換(ドラムはこちら)。 - 本物の3Dメッシュやシェーダー → このスキルだけでは不十分です。WebGLを使用しますが、ルール1から5と9から12は守ってください。
ピンモード or ビューモード?
- ストーリーが再生されている間、セクションが画面を保持する必要がある場合 →
mode: "pin"(高いトラックとスティッキーステージ)。 - マーキーやパララックスのように、効果がコンテンツとともに流れていく場合 →
mode: "view"(ピン留めなし)。
トラックの高さは?
- フィルムの場合は
n × 80–100svh + 100svh、ドラムの場合はn × 70vh + 100vhを使用します。テストリーダーが「速すぎる」と言った場合は、高さを追加します。ホイールを奪って速度を落とすことは絶対にしないでください。
キャプションはどこに配置する?
- デスクトップ(≥ 760 px):描画の横(左または右のカード)または広い下部カード。スマートフォン:常に下部シートで、描画はその上。
- 固定サイトヘッダーがある場合は、HUDとドラムがそれを避けるように
--sm-nav-hを設定します。
ドラムは必要か?
- チャプターがユーザーが移動したいと思うもの(ページ、製品、セクション)である場合にドラムを使用します。それ以外の場合は、フィルムのみを使用します。
失敗モードと修正方法
| 症状 | 原因 | 修正 |
|---|---|---|
| ステージがピン留めされずにスクロールアウトする | 祖先に overflow: hidden/auto/clip がある、またはステージに top がない | overflowを削除し(必要に応じてbodyに overflow-x: clip を使用)、top: 0 を設定する |
| アニメーションが前方にしか再生されない、またはスクロールアップ時にジャンプする | フレーム間で状態が蓄積されている、またはリール内でクロックや Math.random を使用している | リールをtの純粋関数にする。ランダム性には hash(i) を使用する |
| 描画がキャプションの下に表示される | リールがステージ全体にレイアウトされている | env.free 内にレイアウトする。キャプションに data-keep を保持し、エンジンにクリップさせる |
| フェード中にキャプションボックスが間違った場所で測定される | getBoundingClientRect(変換を含む)を使用している | レイアウトボックスを測定する(offsetLeft/Top/Width/Height、boxIn 参照) |
| 境界で2つのキャプションが重なる | キャプションウィンドウが接触している | キャプションを 1 − cross でフェードアウトし、cross でフェードインする。リールはギャップ内でクロスフェードする |
| アイドルページでファンが回る | rAFループが停止しない | cur === target になったら停止する。IntersectionObserverでゲートする |
| スマートフォンでCanvasがぼやける / 4Kで遅い | CanvasがCSSサイズである、またはDPRが制限されていない | width = W × min(dpr, 2) と setTransform(dpr, 0, 0, dpr, 0, 0) を使用する |
| レールをクリックした後にドラムとページが一致しない | コントロールが回転を直接設定した | すべてのコントロールは scrollToProgress を呼び出す。回転は常にpからのみ取得される |
| ドラッグがガタつき、その後スナップバックする | ページに scroll-behavior: smooth があるため、scrollTo(x, y) がアニメーションする | ドラッグ中は scrollTo({ top, behavior: "instant" }) を使用する |
| ドラッグ後に面をクリックするとリンクが開く | ドラッグを終了するクリックが発火する | 4px以上動いたドラッグ後、1回のキャプチャフェーズクリックを飲み込む |
| URLバーが非表示になるときのモバイル100vhジャンプ | ステージに vh を使用している | ステージには 100svh を使用する。ステージ自身の高さでpを計算する |
| ダークモードでライトモードの描画が表示される | ハードコードされた色 | readPalette で --sm-* トークンを読み取る。スキーム変更時に再読み込みする |
| ドラムの背面が透けて見える | backface-visibility: hidden がない | -webkit- プレフィックス付きで、すべての面に設定する |
| ドラム面のサムネイルが間違ったサイズで描画される | 3D変換された面の投影矩形を使用した | clientWidth/clientHeight でサイズ指定する |
ファイル
- references/engine.md: p、スクラブループ、
film()、シーク、Reactフック。 - references/drawing.md: リールコントラクト、Canvas設定、ルート、ポップイン、パーティクル、パレット。
- references/layout-and-captions.md: トラックとステージのCSS、立入禁止領域、自由矩形、クリップ、ドラムレイアウト。
- references/motion-grammar.md: エンベロープ、スターガー、ディウェル、クロスフェード、ペーシング、イージング。
- references/performance-and-a11y.md: アイドルループ、レイアウトコスト、動きの軽減、キーボード、フォーカス、スクリーンリーダー。
- references/verification.md: 8つの幅、ヘッドレスBrave/Chromeスクリプト、手動パス。
- assets/demo.html: スタンドアロン、依存関係ゼロ、ダブルクリックで実行。5つのリールとストーリードラム。
- assets/scroll-motion.js: エンジン(
scrub、film、drum、scrollToProgressとヘルパー)、UMD。 - assets/reels.js: 5つのサンプルリール(orbit、path、stack、wave、close)とサムネイル用の
still()。 - assets/scroll-motion.css: フィルムとドラムのスタイル、ライトおよびダークトークン。
- assets/ScrollFilm.jsx: フィルム用Reactラッパー、
useScrubフック。 - assets/StoryDrum.jsx: Reactストーリードラム、自動回転する
DrumPreview。 assets/preview-orbit.jpg、assets/preview-path.jpg、assets/preview-close.jpg: 1440 × 900でのデモ静止画。
デモから始めてください。開いてソースを表示し、コンテンツを置き換えます。ファイル下部の配線は約60行です。