滾動動態圖形
一個由滾動位置驅動的畫布時間軸:一個固定的舞台、作為進度純函數的捲軸,以及繪圖永遠不允許碰觸的字幕。此方法驅動了 ImuTechz 首頁上的故事轉盤、研發轉盤以及創辦人路徑影片。它也驅動了 assets/demo.html 中的五捲示範。
整個技術可以用一句話概括。滾動位置是唯一的時鐘:將其映射到 p ∈ [0, 1],並讓每一個像素、字幕和旋轉都成為 p 的函數。
理想的成果
- 它像影片一樣可來回拖曳。 向前滾動,影片播放。向後滾動,它逐格倒帶。停在半途,會顯示一個合理的畫面。
- 頁面從未被劫持。 滾輪、觸控板、觸控和按鍵對頁面的操作方式與其他地方完全相同。段落只會變高。
- 文字永遠清晰可讀。 字幕是真實的 DOM 文字,放在卡片上。繪圖佈局在字幕留下的空間中,而畫布會圍繞它們進行裁剪,作為安全網。
- 它是一個控制項,而不僅僅是展示。 故事轉盤的各個面都是連結。其軌道、箭頭按鈕、方向鍵和滑鼠拖曳,都是透過滾動頁面來定位,因此滾動與轉盤永遠不會不一致。
- 閒置頁面不耗費任何資源。 一旦 p 值穩定,或段落離開視窗,就不會執行任何
requestAnimationFrame。 - 尊重減少動畫效果設定。 沒有平滑過渡、沒有轉盤中間滾動、也沒有字幕入場動畫。所有內容仍然存在。
- 在 360 到 1920 像素寬的螢幕上都表現良好,支援淺色與深色模式,以及 DPR 1 和 2。
嚴格規則
- p 僅來自滾動位置,絕不來自時鐘。 請勿在捲軸內部使用
performance.now()、Date、CSS 動畫或setInterval。允許平滑處理(向目標 p 值的線性插值 lerp),因為它最終會收斂到相同的畫面。 - 捲軸是純函數
draw(ctx, t, env)。它在影格之間不保留狀態,且絕不呼叫Math.random();請改用種子化的hash(i)。相同的 (t, env) 必須繪製出相同的像素。 - 使用 CSS 而非 JS 來固定。 使用一個高軌道(
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。畫布文字僅限於裝飾性標籤,例如年份、軸刻度與步驟名稱。
- 每影格測量子幕位置,並讓繪圖避開它們。 每個字幕和 HUD 元素都是一個禁區(
data-keep)。捲軸在env.free內部進行佈局,引擎則使用奇偶裁剪法則裁剪出這些禁區。 - 永遠不要同時顯示兩個字幕。 字幕視窗位於其捲軸內部,並在每個邊界處留有空隙,而捲軸則在該空隙內進行交叉淡入淡出。
- 閒置時停止迴圈。 僅在段落靠近視窗時(透過 IntersectionObserver),才在滾動或調整大小時啟動 rAF,並在 p 等於目標值時停止。
- 畫布的大小設定為舞台的
min(devicePixelRatio, 2)倍。 每影格呼叫一次setTransform(dpr, …),並在每影格清除畫布。 - 顏色來自 CSS 自訂屬性。 不要將淺色模式的調色板硬編碼到捲軸中。在
prefers-color-scheme變更時重新讀取調色板。 - 尊重
prefers-reduced-motion: reduce。 不使用平滑效果,讓轉盤面對面直接跳轉,並關閉字幕入場動畫。畫面仍會跟隨滾動,因為滾動是由使用者驅動的。
建置順序
按此順序進行。每個步驟都可以在下一個步驟之前進行測試。
- 先有內容。 撰寫章節:每個章節包含一個導語、一個標題、一兩句話和一個可選的連結。選擇捲軸數量(3 到 7 個),並為每個捲軸分配大約 80 到 100 svh 的滾動空間。
- 軌道和舞台。 加入一個高的區段,內部包含一個黏性的 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)。直到所有檢查都通過,才能宣告完成。
決策檢查清單
畫布影片還是 DOM/CSS 轉換?
- 許多小型移動部件、繪製的線條、粒子或圖表 → 畫布影片 (
ScrollMotion.film)。 - 少數卡片、圖片或文字面板 → 由
scrub驅動的 DOM 轉換(故事轉盤是其中一種)。 - 真正的 3D 網格或著色器 → 此技能不足夠。使用 WebGL,但保留規則 1 到 5 以及 9 到 12。
固定模式還是瀏覽模式?
- 區段應在播放故事時保持在螢幕上 →
mode: "pin"(高軌道和黏性舞台)。 - 效果隨內容通過,例如跑馬燈或視差 →
mode: "view",無需固定。
軌道要多高?
- 影片使用
n × 80–100svh + 100svh,轉盤使用n × 70vh + 100vh。如果測試人員說「太快」,則增加高度。永遠不要透過攔截滾輪來減慢速度。
字幕放哪?
- 桌面版(≥ 760 像素):繪圖旁邊(左側或右側卡片)或作為一個寬的底部卡片。手機版:始終使用底部工作表,繪圖在其上方。
- 如果有固定的網站頁首,設定
--sm-nav-h以使 HUD 和轉盤避開它。
需要轉盤嗎?
- 當章節是用戶可能想要前往的內容(頁面、產品、區段)時,使用轉盤。否則僅使用影片。
故障模式與修復
| 症狀 | 原因 | 修復 |
|---|---|---|
| 舞台滾走而非固定 | 祖先元素有 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) |
| 兩個字幕在邊界處重疊 | 字幕視窗相接 | 在 1 − cross 處淡出字幕,在 cross 處淡入;捲軸在空隙中交叉淡入淡出 |
| 風扇在閒置頁面上旋轉 | rAF 循環從未停止 | 在 cur === target 時停止;透過 IntersectionObserver 控制 |
| 手機上畫布模糊 / 4K 上速度慢 | 畫布為 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" }) |
| 拖曳後點擊某個面會打開其連結 | 結束拖曳的點擊事件觸發 | 在移動超過 4 像素的拖曳之後,吞掉一個捕獲階段的點擊事件 |
| URL 列隱藏時 Mobile 100vh 跳動 | 舞台使用了 vh | 舞台使用 100svh;用舞台自身的高度計算 p |
| 深色模式顯示淺色模式繪圖 | 硬編碼的顏色 | 透過 readPalette 讀取 --sm-* token;在配色方案變更時重新讀取 |
| 轉盤的背面透出前景 | 缺少 backface-visibility: hidden | 設定此屬性,並加上 -webkit- 前綴,套用於每個面 |
| 轉盤面縮圖繪製尺寸錯誤 | 使用了 3D 變換面的投影矩形 | 根據 clientWidth/clientHeight 設定大小 |
檔案
- references/engine.md: p、拖曳迴圈、
film()、定位、React 鉤子。 - references/drawing.md: 捲軸合約、畫布設定、路徑、彈出元素、粒子和調色板。
- references/layout-and-captions.md: 軌道和舞台 CSS、禁區、自由矩形、裁剪、轉盤佈局。
- references/motion-grammar.md: 包絡線、交錯、停留、交叉淡入淡出、節奏和緩動。
- references/performance-and-a11y.md: 閒置迴圈、佈局成本、減少動畫效果、鍵盤、焦點和螢幕閱讀器。
- references/verification.md: 八個寬度、無頭 Brave/Chrome 腳本和手動檢查。
- assets/demo.html: 獨立、零依賴、雙擊即可執行。包含五個捲軸和故事轉盤。
- assets/scroll-motion.js: 引擎(
scrub、film、drum、scrollToProgress和輔助函式),UMD。 - assets/reels.js: 五個範例捲軸(orbit、path、stack、wave、close)和用於縮圖的
still()。 - assets/scroll-motion.css: 影片和轉盤樣式、淺色和深色 token。
- 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 行。