D3.js × Remotion 系列(一):Bar Chart Race 排行榜競賽動畫
用 D3 的 scaleLinear、scaleOrdinal、format 算 layout,用 Remotion 的 interpolate 把每一幀的數值與排名平滑播放出來——彭博風格的橫向長條競賽圖。
成品預覽
15 秒的示範影片裡,8 個國家從 1990 年一路競賽到 2025 年。每一幀你都會看到:右上角的「年份大數字」從 1990.0 平滑跑到 2025.0、長條的長度連續變化、彩色漸層的橫條按照當下排名上下移動位置——當中國的長條一格一格往上爬、超越日本、再超越德國的時候,那個視覺張力就是 bar chart race 之所以好看的原因。底部還有一條從左跑到右的進度條告訴你影片走到哪裡,下方一行小字標註資料來源與年份範圍。
整支影片沒有用任何 SVG、沒有任何 D3 transition,全部都是純 React <div> 加 inline style,由 D3 負責「算」、Remotion 負責「畫」。
為什麼要把 D3 放進 Remotion?
D3 是世界上最強的「把資料變成像素」的函式庫,這點沒什麼好爭的。從 New York Times 的互動圖表到 Observable 上幾萬個 notebook,背後都是 D3。
但如果你直接拿一份 D3 範例丟到 Remotion 裡,會發現一個尷尬的事實:D3 內建的動畫系統完全不適用。
- D3 的
transition()是綁在 DOM 與 SVG element 上跑的,而 Remotion 雖然有 DOM,但它是 frame by frame 截圖的環境 - D3 的時間軸來源是
requestAnimationFrame——也就是 wall clock,跟 Remotion 的useCurrentFrame()完全不同步 - D3 的 selection API 會 mutate DOM,而 Remotion 偏好的是 React 的 declarative 模型
幸好,D3 不只是動畫——D3 的真正核心其實是一堆純函式:scale、layout、generator、format、sort 等等。這些東西都是 pure:給定相同輸入永遠回傳相同輸出,沒有副作用,不碰 DOM。
於是策略變得非常清楚:
只用 D3 的純數學部分計算每一幀的 layout,把動畫推進的責任交給 Remotion 的
useCurrentFrame()。
這樣換來的好處:
- Pixel-perfect render:同一個 frame 永遠產生同一張畫面,可以並行渲染
- SSR-friendly:純函式不需要 DOM,跑在 Node.js worker 裡也沒問題
- 沒有 DOM mutation:所有東西都走 React 的 declarative 流程,hot reload、time travel、跨幀偵錯全部行得通
- D3 的所有 idiom 你都還能用:scale、format、sort、interpolate⋯⋯只是不再用
.transition()
這篇會用到
d3v7(一份套件就涵蓋所有子模組)d3.scaleLinear— 把數值映射到像素寬度d3.scaleOrdinal+d3.schemeTableau10— 把國家代碼映射到顏色d3.format(',.0f')— 把數字加上千位逗號d3.sort+d3.descending— 每一幀重新排序- Remotion 的
useCurrentFrame、interpolate、Sequence、Audio - React 的
useMemo確保 scale 不會每幀重建
不需要 React-D3 hybrid 套件、不需要 react-faux-dom、不需要 ref。我們用的 render layer 就是普通 <div> 加 CSS,D3 只負責算。
前置知識
建議先看過 T03:片頭動畫,對 useCurrentFrame() 與 interpolate() 有基本理解。本篇的所有平滑動畫都建立在這兩個 API 上。
Step 1:安裝 D3
npm install d3 @types/d3D3 v7 的整包大概 270KB(minified),但因為它的子模組支援 tree-shaking,這篇我們只用到 scale、format、sort 幾個,實際打包進去的可能不到 30KB。如果你的 bundle 大小很敏感,也可以只裝 d3-scale、d3-format、d3-array 這幾個子套件——不過那樣 import 就要寫成 import {scaleLinear} from 'd3-scale',比較囉嗦。為了範例好讀,我們就直接用 import * as d3 from 'd3'。
Step 2:準備 keyframe 資料集
Bar chart race 的核心資料是「N 個對象 × M 個時間點」的矩陣。我們用 8 個國家從 1990 到 2025 每 5 年一筆 GDP 數值(虛構,只為了示範):
const KEYFRAME_YEARS = [1990, 1995, 2000, 2005, 2010, 2015, 2020, 2025] as const;
type Country = {
code: string; // 2-letter
flag: string; // emoji
name: string;
values: number[]; // length = KEYFRAME_YEARS.length
};
const COUNTRIES: Country[] = [
{
code: 'US',
flag: '🇺🇸',
name: 'United States',
values: [5963, 7640, 10250, 13036, 14992, 18238, 21060, 27360],
},
{
code: 'CN',
flag: '🇨🇳',
name: 'China',
values: [360, 734, 1211, 2286, 6087, 11226, 14688, 19370],
},
{
code: 'JP',
flag: '🇯🇵',
name: 'Japan',
values: [3133, 5449, 4888, 4831, 5759, 4444, 5040, 4210],
},
{
code: 'DE',
flag: '🇩🇪',
name: 'Germany',
values: [1764, 2591, 1942, 2843, 3399, 3357, 3889, 4456],
},
{
code: 'IN',
flag: '🇮🇳',
name: 'India',
values: [320, 366, 468, 820, 1675, 2103, 2667, 3737],
},
{
code: 'GB',
flag: '🇬🇧',
name: 'United Kingdom',
values: [1093, 1235, 1665, 2538, 2491, 2928, 2764, 3340],
},
{
code: 'FR',
flag: '🇫🇷',
name: 'France',
values: [1275, 1607, 1366, 2196, 2647, 2439, 2640, 3050],
},
{
code: 'BR',
flag: '🇧🇷',
name: 'Brazil',
values: [462, 786, 655, 892, 2209, 1802, 1476, 2173],
},
];這個資料結構有兩個值得注意的設計決定:
1. Keyframe 是稀疏的(每 5 年一筆)。 影片有 15 秒、30fps、共 450 幀,但我們只給 D3 看 8 個關鍵年份的數值。剩下的 442 幀全部由 Remotion 在這些 keyframe 之間做插值算出來——這正是 interpolate() 的拿手好戲。如果你硬要每幀都餵一份真實資料,你的 dataset 會變成 8 × 450 = 3600 筆,又大又難維護。
2. 用「程式碼」當主鍵而不是 index。 code: 'US' 是穩定的識別字,後面我們重新排序之後還是能用它去查 rank、查顏色。如果用 index,sort 一次之後 index 就亂了。
Step 3:D3 scales(比例尺)
D3 的核心 idiom 是 scale:給它一段「資料的範圍」(domain),給它一段「畫面的範圍」(range),它回傳一個函式,把任何 domain 裡的值映射成 range 裡的值。
我們需要兩個 scale:
import * as d3 from 'd3';
import {useMemo} from 'react';
// ...component 內部
// 顏色 scale:國家代碼 → 顏色
const colorScale = useMemo(
() =>
d3
.scaleOrdinal<string>()
.domain(COUNTRIES.map((c) => c.code))
.range(d3.schemeTableau10 as readonly string[]),
[],
);
// 寬度 scale:數值 → 像素寬度(依當下最大值動態調整)
const maxValue = ranked[0]?.value ?? 1;
const widthScale = useMemo(
() =>
d3
.scaleLinear()
.domain([0, maxValue])
.range([0, CHART_WIDTH - 480]),
[maxValue],
);兩個 scale 的差異很重要:
colorScale是 ordinal:domain 是離散的字串集合(國家代碼),range 也是離散的顏色陣列(d3.schemeTableau10是 D3 內建的 10 色 categorical palette)。每個 code 永遠對應同一個顏色。所以它可以useMemo([])一次建好不變。widthScale是 linear:domain 是連續數值[0, maxValue],range 是連續像素[0, 寬度]。重點:domain 的上限會隨著年份變化——1990 年最高的美國是 5963、2025 年是 27360,差了快 5 倍。如果不動態調整,1990 年的長條只會佔螢幕的 1/5。所以widthScale的 dependency 是[maxValue],每幀重建。
這就是 scale 的精髓:它是「資料空間到螢幕空間」的橋樑,而且是純函式——widthScale(5963) 永遠回傳同一個像素值,沒有 DOM 副作用、沒有時間依賴。我們把這種純粹性丟進 Remotion 的 deterministic render,自然完美契合。
Step 4:把 frame 換算成「年份時間」
Remotion 給我們的是 frame number(0 到 449),但我們的資料軸是年份(1990 到 2025)。中間需要一個換算層:
const frameToYearTime = (frame: number): number => {
const INTRO = 30; // 開頭 1 秒留白
const OUTRO = 30; // 結尾 1 秒留白
const totalRace =
TUTORIAL_D3_BAR_CHART_RACE_DEMO_DURATION_FRAMES - INTRO - OUTRO;
const racePos = Math.max(0, Math.min(totalRace, frame - INTRO));
const t = racePos / totalRace; // 0..1
const first = KEYFRAME_YEARS[0];
const last = KEYFRAME_YEARS[KEYFRAME_YEARS.length - 1];
return first + t * (last - first);
};幾個關鍵點:
- 回傳的是浮點數年份,例如
1995.7或2018.3,不是整數。這個小數點是 bar race 平滑感的來源——每一幀年份都往前走一點點(450 幀走完 35 年,每幀大約 +0.083 年),對應的數值也跟著走一點點。 - 保留 intro / outro:開頭 1 秒和結尾 1 秒不參與 race,用來讓 title 進場、最終結果停留一拍。
Math.max/Math.min把 racePos clamp 在合理範圍。
接著我們要把這個浮點年份轉成「該年份的數值」。因為 keyframe 是每 5 年一筆,1995.7 介於 1995 和 2000 之間,要做插值:
const valueAtYear = (country: Country, year: number): number => {
if (year <= KEYFRAME_YEARS[0]) return country.values[0];
if (year >= KEYFRAME_YEARS[KEYFRAME_YEARS.length - 1]) {
return country.values[country.values.length - 1];
}
for (let i = 0; i < KEYFRAME_YEARS.length - 1; i++) {
const a = KEYFRAME_YEARS[i];
const b = KEYFRAME_YEARS[i + 1];
if (year >= a && year <= b) {
const t = (year - a) / (b - a);
// Smoothstep 讓 keyframe 之間的轉換更柔和
const eased = t * t * (3 - 2 * t);
return country.values[i] * (1 - eased) + country.values[i + 1] * eased;
}
}
return country.values[0];
};那個 t * t * (3 - 2 * t) 是經典的 smoothstep 函式——一條 S 型曲線,讓每個 keyframe 經過時的進場/出場速度比較柔,不會像 linear 一樣在 keyframe 點上有「折角」感。如果你不在意這個細節,純線性 t 也完全能跑。
寫到這裡你可能會問:「為什麼不直接用 Remotion 的 interpolate()?」答案是:可以,但 interpolate() 接受的是「frame → 一個輸出值」,這裡我們要算的是 8 個國家、每個都要插值。寫成函式比較好複用。內部其實做的事情跟 interpolate() 一模一樣。
Step 5:每幀重新排序
這是 bar chart race 之所以叫「race」的核心:每一幀都要重新排序,讓排名變化反映在垂直位置上。
const ranked = useMemo(() => {
const arr = COUNTRIES.map((c) => ({
country: c,
value: valueAtYear(c, yearTime),
}));
// d3.sort 回傳新陣列(不會 mutate 原本)
return d3.sort(arr, (a, b) => d3.descending(a.value, b.value));
}, [yearTime]);
// 建立 code → rank 的查詢表
const rankByCode = useMemo(() => {
const m = new Map<string, number>();
ranked.forEach((r, i) => m.set(r.country.code, i));
return m;
}, [ranked]);兩個 D3 小工具:
d3.sortvs JS 內建的Array.prototype.sort:前者不會 mutate 原陣列,後者會。在 React 裡你絕對不想 mutate props 或 state,所以d3.sort是更安全的選擇。d3.descending是內建的比較器,相當於(a, b) => b - a,但語意更清楚。
接著就是魔法時刻:每一個 bar 的垂直位置 (top) 直接由 rankByCode.get(code) 算出來。當某個國家的數值在某一幀超越另一個國家時,它的 rank 從 5 變成 4,下一幀的 top 就會少一個 ROW_HEIGHT。這就是長條互相超越的視覺效果。
但如果只是 rank 突然從 5 跳到 4,畫面會「跳一格」。我們希望它平滑滑過去——這就是為什麼即使 rank 是離散的,bar 的渲染也要用「rank × ROW_HEIGHT」這種連續值,並且讓 frame 之間靠 React 重新 render 自動產生新位置。實際上因為相鄰兩幀的 yearTime 變化很小,相鄰兩幀如果 rank 沒變、bar 就不動;rank 變了的瞬間,下一幀直接跳到新位置——但因為 frame 間隔只有 1/30 秒,你看到的就是平滑的「位置切換」。
如果你想讓 rank 切換有真正的緩動(譬如 0.3 秒滑過去),可以額外維護一個「target rank」與「current rank」用 interpolate() 平滑。我們的範例為了簡單就直接跳,視覺上已經很流暢。
Step 6:畫橫條
每一個 bar 由三個部分組成(從左到右):
- 代碼徽章(96px 寬):國旗 emoji + 兩字母國家代碼,灰白漸層底
- 長條本身:彩色漸層,寬度由
widthScale(value)決定 - 數值 + 國家名:放在長條外面(右側),永遠跟在 bar 末端
第三點是非常重要的設計決定。很多 bar chart race 把 label 放在 bar 內部(左對齊),這在 bar 很長的時候沒問題,但 bar 短的時候 label 會被裁切或溢出。把 label 放在 bar 外面則永遠看得到,不管 bar 多短。
{ranked.map(({ country, value }) => {
const rank = rankByCode.get(country.code) ?? 0;
const y = CHART_TOP + rank * ROW_HEIGHT + (ROW_HEIGHT - BAR_HEIGHT) / 2;
const w = Math.max(2, widthScale(value));
const color = colorScale(country.code);
return (
<div
key={country.code}
style={{
position: 'absolute',
left: CHART_LEFT,
top: y,
width: CHART_WIDTH,
height: BAR_HEIGHT,
display: 'flex',
alignItems: 'center',
}}
>
{/* 代碼徽章 */}
<div style={{ width: 96, /* ... */ }}>
<span>{country.flag}</span>
{country.code}
</div>
{/* Bar + label container */}
<div style={{ position: 'relative', height: BAR_HEIGHT, flex: 1 }}>
{/* 長條本身 */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: BAR_HEIGHT,
width: w,
borderRadius: BAR_HEIGHT / 2,
background: `linear-gradient(90deg, ${color} 0%, ${color}cc 70%, ${color}88 100%)`,
boxShadow: `0 8px 28px ${color}55, inset 0 0 0 1px ${color}aa`,
}}
/>
{/* 數值 + 國家名(永遠在 bar 外面) */}
<div
style={{
position: 'absolute',
left: w + 16,
top: 0,
height: BAR_HEIGHT,
display: 'flex',
alignItems: 'center',
gap: 14,
}}
>
<span style={{ fontSize: 36, fontWeight: 800 }}>
${formatValue(value)}B
</span>
<span style={{ fontSize: 28, color: '#cbd5e1' }}>
{country.name}
</span>
</div>
</div>
</div>
);
})}幾個小細節:
Math.max(2, widthScale(value)):強制 bar 至少有 2px 寬,避免某個國家數值極小的時候完全看不見linear-gradient(90deg, ...):彩色漸層讓 bar 有立體感。${color}cc、${color}88是同一個顏色加上 alpha(CSS hex 8 位數寫法)box-shadow用同色光暈:每個 bar 自帶一圈柔光,讓視覺更彭博風label的left: w + 16:label 緊貼 bar 末端往右 16px。bar 變寬,label 跟著往右走;bar 變窄,label 跟著往左走
注意整段沒有任何 transition CSS——我們不能用 CSS transition,因為 Remotion 是 frame by frame 截圖,CSS transition 依賴瀏覽器 wall clock,跑不起來。所有「平滑感」都來自於 frame 之間的 React re-render + 我們在 Step 4 算出來的連續年份。
Step 7:年份大數字 + 進度條
右上角的大年份用一行 JSX 就好:
<div
style={{
position: 'absolute',
top: 70,
right: CHART_RIGHT,
fontSize: 168,
fontWeight: 900,
fontVariantNumeric: 'tabular-nums',
textAlign: 'right',
}}
>
{yearTime.toFixed(1)}
</div>fontVariantNumeric: 'tabular-nums' 是一個常被忽略的 CSS 屬性——它讓所有數字字元都用同樣的字寬(monospace digits),這樣 1995.7 變成 1995.8 的時候,整串數字不會抖動。對於每幀都在變的數字 UI 來說,這個屬性是必須的。
底部進度條更簡單——frame / totalFrames 算出 0 到 1 的值,當作 div 的 width 百分比:
const progress = frame / (TUTORIAL_D3_BAR_CHART_RACE_DEMO_DURATION_FRAMES - 1);
<div style={{
width: `${progress * 100}%`,
height: '100%',
background: 'linear-gradient(90deg, #38bdf8, #818cf8, #f472b6, #fb923c)',
}} />這條 progress bar 既是裝飾,也是 debug 工具——如果某天你的 race 看起來不對勁,看 progress bar 跟年份大數字有沒有對齊就能立刻判斷是 frame 計算錯還是資料插值錯。
Step 8:音效
15 秒的影片用 5 軌音效層疊:
<Sequence from={0} durationInFrames={30}>
<Audio src={staticFile('audio/t15/01-title-swipe.mp3')} volume={0.6} />
</Sequence>
<Sequence from={30} durationInFrames={420}>
<Audio src={staticFile('audio/t14/bgm-relax-beat.mp3')} volume={0.25} />
</Sequence>
<Sequence from={60} durationInFrames={30}>
<Audio src={staticFile('audio/t15/03-ka-ching.mp3')} volume={0.4} />
</Sequence>
{tickStarts.map((start) => (
<Sequence key={`tick-${start}`} from={start} durationInFrames={20}>
<Audio src={staticFile('audio/t14/03-tick.mp3')} volume={0.25} />
</Sequence>
))}
<Sequence from={420} durationInFrames={30}>
<Audio src={staticFile('audio/t15/07-outro-bell.mp3')} volume={0.5} />
</Sequence>設計邏輯:
- Title swipe(0-1s):開頭一聲「嗖」帶出標題
- BGM bed(1-15s):低音量的輕鬆 beat 鋪整支影片,當作底層脊椎
- Ka-ching(2-3s):race 正式開跑那一刻的「叮!」,提示觀眾「重點開始了」
- Year ticks(4s, 6s, 8s, 10s, 12s, 14s):每兩秒一個 tick 聲,給人「秒針推進」的節奏感
- Outro bell(14-15s):結尾的鈴聲收尾
關鍵原則:BGM 是脊椎,SFX 是節拍。BGM 音量壓低(0.25)讓它躲在背景,SFX 音量稍高(0.4-0.6)負責切換瞬間的注意力錨點。
可以擴充的方向
這個模式一旦理解,能擴展的地方非常多:
- 讀真實 CSV —
d3.csvParse(csvString)直接吃文字,回傳物件陣列。把 hardcode 的COUNTRIES換成讀檔即可 - 換成其他資料 — 股票市值、人口、訂閱數、Spotify 月聽眾⋯⋯任何「時間序列 × 多個對象」的資料都能套用同一個模板
- 加上資料來源 footer — 在底部加一行小字「Source: World Bank · Updated 2025-Q3」會讓影片更可信
- 顏色隨數值大小變化 — 換成
d3.scaleSequential(d3.interpolateViridis),bar 的顏色就會跟著數值大小變化,而不是綁定國家 - 串接 LLM 生成資料 — 用 GPT 生一份「2030 年熱門程式語言排行」的虛構資料,自動產出 daily 影片發到社群
小結
D3 提供算 layout 的腦,Remotion 提供精準播放的手——把這兩個分工弄清楚,你就能用最少的程式碼做出最豐富的資料動畫。整篇沒有任何 SVG、沒有任何 D3 transition,只有一堆純函式和 React 的 declarative render。
下一篇我們會用同一個原則做 Force-Directed Graph,看 D3 的物理模擬(d3.forceSimulation)怎麼跟 useMemo 結合,讓 nodes 在每一幀都站到正確的位置上——一樣是 D3 算、Remotion 播。