horizontal-scroll-scenes について
ネイティブスクロール位置から表示状態を導く、横方向の章立てシーンを構築するスキルです。スクロール連動の物語を作る開発者向けに、可逆ナビゲーション、画面遷移、アクセシビリティ、ライフサイクルへの対応を説明します。
主な機能
- 01正規化したスクロール位置から状態を算出
- 02横方向のワールド軸に章を配置
- 03ワイプまたはパンでシーンを切り替え
- 04逆スクロール、ジャンプ、再読み込みに対応
- 05動きを減らす設定とスクリーンリーダーに配慮
活用シーン
- 01スクロール連動の章立てストーリーを作成
- 02アンカージャンプ後もシーン状態を維持
- 03離散したビジュアルシーン間の遷移を設計
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mengto/ skills --skill horizontal-scroll-scenesインストール後、Claude Code で/horizontal-scroll-scenesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
demo.htmlPROMPT.mdagents/openai.yaml詳細
水平スクロールシーン
仕組み: 状態はスクロール位置だけです。1つの純粋関数が、毎フレーム scrollY / (scrollHeight - innerHeight) を {scene, dwell 0→1, move 0→1} に変換し、画面上のすべて(カメラ、ワイプライン、クラフト時計、テキスト、インデックス)をその3つの値から描画します。以前の位置を記憶するものはないため、すべてのビートがページ上の位置になります。
これがKiboriの仕組みであり、translateX スライダーではありません。実行して検証済みです。ページは 1742vh のネイティブスクロールトラック(1440×900で15,678 px)で、固定ステージには1つのWebGLワールドがあり、カメラは8つのステーションを通って左から右へ0 → 49.7ワールドユニット移動します。ステーション間では、ギザギザの焼け跡のようなラインが、離れるシーンのフリーズフレームから、次のシーンをすでにフレーミングしたライブシーンへとワイプします。ホイールハンドラーも、スナップも、「現在のチャプター」の保存もありません。状態は蓄積するのではなく導出されるため、途中からの再読み込み、スクロールバーのドラッグ、アンカージャンプも機能します。
ストーリーや3Dワールドの側面には scroll-world-storytelling または build-threejs-scroll-worlds、固定されたスティッキーセクションには gsap-scrolltrigger-storytelling、プログレスラインには scroll-progress-timeline を使ってください。それぞれに停止するビートがある個別のチャプターをX軸に沿って配置する成果物を作り、逆方向へのスクロール、スキップ、リフレッシュに状態マシンを対応させることが難しい場合は、こちらを使ってください。デモはVanilla JavaScript + DOM/CSS + inline SVGで、ソースはthree.jsです。状態マシンは同一です。
ペース:2つの重みとトラックの高さ
すべての数値は DWELL、MOVE、トラックの高さで決まります。1単位はスクロール画面1つ分です。
| 定数 | 値 | 意味 |
|---|---|---|
DWELL | 1.15 | シーン内でクラフトが進行している間のスクロール量(高さ900の場合、約1,030 px) |
MOVE | 0.85 | 次のシーンへ移動するスクロール量(約760 px)。最後のシーンにはありません |
CYC | 2.00 | 画面2つ分につきシーン1つ |
SPAN | N*DWELL + (N-1)*MOVE | シーン8つ:15.15にフッター1.35を加えて16.5。デモはシーン5つで9.15 |
| トラック | (SPAN + 1) * 100vh | これにより1単位 ≈ 画面1つ分になります。Kiboriは16.5に対して1742vhです |
function travel(sp){ // sp = scrollY / (scrollHeight - innerHeight)
const u = Math.min(SPAN - 1e-5, sp*SPAN);
const i = Math.min(N - 1, Math.floor(u/CYC));
const local = u - i*CYC;
const dw = Math.min(1, local/DWELL); // the scene's own clock
const f = i >= N-1 ? 0 : clamp01((local - DWELL)/MOVE); // the move
const rc = clamp01((f - 0.01)/0.97); // the line's own clock
return {i, u, dw, f, fe: sstep(0,1,f), L: -0.08 + 1.24*rc, // L: line, off-frame at both ends
live: Math.min(N-1, f < 0.39 ? i : i+1)}; // what the index shows
}
scrollHeight - innerHeight で正規化してください。固定のピクセル高は使わないでください。スマートフォンでURLバーが折りたたまれるとスクロール中に innerHeight が変わり、pxベースのマッピングはずれてしまいます。
ルールと、それぞれが防ぐ問題
- 蓄積せず、導き出します。
currentSceneを保持したり、ホイールの差分を加算したりしてはいけません。保存された状態は、スクロールバーのドラッグ、Home/End、アンカーへのジャンプ、戻る・進む、更新で同期がずれます。これを検証します。前方向に進みながら20個のu値で表示中のすべてのスタイルのシグネチャを記録し、次に逆方向でも記録し、その後は各値へ直接ジャンプして記録し、さらに3つの深さでreload()した後にも記録します。すべて一致する必要があります(デモ実行では、不一致0件、スキップ0件、再読み込み0件)。 - ホイール操作を横取りせず、
scroll-snapも使いません。 横取りすると、タッチ、トラックパッドの慣性、Space/PageDown、ページ内検索が機能しなくなります。スナップは、静止する区間と競合します。この区間はすでに静止するプラトーです(何も動かず、機体だけが動く1.15画面分の区間です)。ネイティブスクロールは1:1のままです。ホイールで2,000 pxスクロールすると、正確にu = 2000/(scrollHeight - vh)*SPANとなりました。 - 機体のクロックは、静止区間の外側で固定します。 シーン
kの進行度は、k < iなら1、k == iならdw、それ以降なら0です。こうすれば、通り過ぎた後に何も巻き戻らず、逆方向にも正確にたどれます。ステージ上にカスタムプロパティ--p{k}と、イージング済みの--e{k}を書き込みます。両方のワールドコピーがこれを読み取るため、フレームごとの書き込みは1回だけです。 - 動くものがない移動は、無駄なスクロールです。 線をフレームの端からほぼすぐに動かし始めます(
rc = (f-0.01)/0.97)。開始位置をさらに外側にすると、各移動の最初の8分の1で何も起きませんでした。 - ワイプでカメラを隠します。 切り替え前のフレームを線の手前で固定し、その背後ではライブワールドがすでに移動先に立っているようにします。線の下でカメラが動き続けると、二重露光のように見えます。デモでは、レイヤーAをカメラ
iの位置で線の奥側にクリップし、レイヤーBをi+1の位置で手前側にクリップしています。ワイプを使わない場合は、パンモード(translateX(-(i+fe)/N*100%))が代替になります。 - 線が次の章を書き込みます。 入ってくるテキストがある側から交互にスイープします。切り替え前のテキストには
linear-gradient(a, transparent (L-.01)%, #000 (L+.016)%)、切り替え後のテキストには#000 (L-.062)%, transparent (L-.048)%のマスクを適用します。aは90degまたは270degです。2つの章が同じフレーム領域を共有することはありません。スマートフォンでは両方が1つのスロットに入るため、線が通過し終わるまで切り替え後のテキストを非表示にします(sstep(1.00, 1.14, L))。 - インデックスを切り替えるのは
f < 0.39の時点で、0.5ではありません。 中間点では切り替え後のタイトルがすでに半分表示されているため、切り替えが遅いと別のシーン名が表示されます。非アクティブな行は0ではなく0.55までフェードさせます。 - 章のクロックは意図的に重ねます。 到着はシーンのホームの
MOVE*0.80前に始まり、DWELL*0.62だけその中に入った時点で終わります。出発はDWELL + MOVE*0.28で始まり、MOVE*0.34かけて進みます。各要素(眉見出し、タイトル、リード文、数値)は、それぞれarriveの異なる区間(0、.12、.40、.60に加えて0.30のランプ)を使います。 - 高速スキップでは最後の移動だけを再生します。 2つ以上のシーンを飛ばす場合は、380 msかけて煙を広げ、移動先の手前の停車位置まで
scrollToします(前進時は(tgt-1)*CYC + DWELL*0.96、後退時は(tgt+1)*CYC + 0.02)。その後、幕が最初の24%の区間で開くのに合わせて、1-(1-x)^2.4を使い、最後の移動を2,600 msかけて再生します。通過するシーンは非表示にします。そうしないと、そのテキストが一瞬表示されます。移動先はtgt*CYC + DWELL*0.80にして、目標のシーンが組み上がった状態にします。1つ以下のシーンを移動する場合は、clamp(700 + |dy|/vh*150, 900, 2400)msの単純な移動を、三次のイン・アウトで行います。これがないと、1から8へのジャンプで6つの火が点滅します。 - ホイール、タッチ、ナビゲーションキーの操作があれば、グライドをキャンセルします。 そうしないと、ページが操作する手と競合します。
html{scroll-behavior:smooth}は、グライドとすべてのキャプチャを壊します。 フレームごとのscrollToが自身を追いかけることになり、Playwright のスクリーンショットはスクロール途中で撮影され、重なりのバグのように見えます。autoを設定します。- 非表示の章には
visibility:hiddenとinertを設定します。 不透明度を0にするだけでは、見えないリンクにフォーカスでき、スクリーンリーダーからも読み取れてしまいます。 scroll、resize、pageshow、load、fonts.readyで描画します。 再読み込み時は最初の描画後にスクロール位置が復元され、bfcache からの復元ではスクロールイベントが発生しません。ステージのサイズが0の場合は処理を行わず、ResizeObserverでサイズを取得します。- ワールドを複製する場合は、SVGのIDを一意にする必要があります。 2つのコピーで同じ
clipPathIDを使うと、url(#id)は片方だけを参照します。 - 確定した変更だけを通知します。 丁寧なライブリージョンは、グライドが着地したときにのみ「シーン4/5:焼杉」と1回通知し、途中で通過するしきい値ごとには通知しません。
アクセシビリティとライフサイクル
- インデックスの各行は
aria-current="step"を持つ実際のリンクで、5 px のオフセットに 1 px の輪郭線が表示されます。ブランドリンクはシーン 1 に戻ります。ステージは高さだけを持つプレーンなトラックの上にposition:fixedで配置されているため、Space、矢印キー、PageDown、Home、Endでネイティブにスクロールします。 prefers-reduced-motion: reduce:ラインは表示されず、移動もしません。シーン B はf0.30 → 0.70 の範囲でクロスフェードし、次のチャプターはf = 0.64まで待機します。グライドは即時のscrollToになり、作品はスクロールに合わせて進みます(これはユーザー自身の入力です)。コントロールは引き続き操作できます。document.hidden:グライドがあれば目標位置で終了させ、タブの再開時に一時停止中の移動を反映しないようにします。- モバイル:インデックスには現在の行(
03 / 05 Kigumi)のみを表示し、チャプターを下部に、作品をその上に配置します。
計測したコスト
ステートマシンのコストはゼロです。400 ステップのスイープでは render() は 0.1 ms のタイマー下限(p50 0、p90 0.1)に収まり、デモの 2.6 秒のグライド中も p50 は 8.3 ms(120 Hz の上限)、最大で 9.2 ms です。コストがかかるのはシーンの描画です。Kibori では、4096² のシャドウマップが毎フレーム再描画されないようにするまでは、静止フレームのほうが遷移中より遅く、さらにシーンをライブで描画する範囲を scissor で限定して解決しました。kibori-60fps のメモを参照してください。travel() の最適化に労力をかけないでください。
問題の兆候
| 表示される現象 | 原因 |
|---|---|
| 再読み込みするとシーン 1 が表示される | 状態が JS 内に保持されています。scrollY から導出し、load/pageshow で描画してください |
| シーンが 2 つのビートの間で「止まる」 | スナップまたはホイール入力の捕捉が原因です。削除してください |
| ラインの下で 2 つの部屋が重なる | ワイプ中もカメラが移動しています |
| タイトルが表示されるとインデックスには前のシーンが表示される | しきい値を 0.39 ではなく 0.5 に設定しています |
| ジャンプリンクで複数のシーンが点滅する | 最後の移動ではなく、すべての移動でグライドが再生されています |
| ジャンプリンクが行き過ぎる、またはカクつく | html に scroll-behavior:smooth が設定されています |
| 2 つ目のシーンのクリップが機能しない | SVG の id が重複しています |
| スクリーンリーダーが画面にないチャプターを読み上げる | inert を設定せずに opacity 0 にしています |
| スマートフォンでラインの位置に 2 つのタイトルがつながって表示される | ラインが通過するまで次のチャプターを表示しないようにしていません |
出典
KIBORI 木彫の、京都の 8 つの木工技術を紹介するページから抜粋しました。three.js の単一ファイル(kibori.html。travel() とモジュールスクリプト末尾付近のグライドブロック)に収録されています。デモはそのページの暗色と残り火のデザインを採用し、手描きの SVG クラフト 5 点を使用しています。