remotion インフォグラフィクス データ可視化 チャート アニメーション

Remotionでアニメーションインフォグラフィクスを作る:データストーリーテリングの実践

Remotionでアニメーションインフォグラフィクスを作る:データストーリーテリングの実践

静止したインフォグラフィクスはスクロールされる。動くインフォグラフィクスは目を止める。84%という数値が画面に静的に表示されるよりも、0から84へと数字が上昇していく方が、人間の認知に与えるインパクトははるかに大きい。これは好みの問題ではなく、視覚皮質の仕組みだ。動きは変化を示し、変化は意味を持つ。

NYT(ニューヨーク・タイムズ)、The Economist、フィナンシャル・タイムズといった一流メディアのデータジャーナリズム部門が、なぜアニメーションインフォグラフィクスを積極的に採用しているのかはここにある。単に「見栄えがいい」からではなく、情報の伝達速度と記憶定着率が上がるからだ。

Remotionは、このようなアニメーションインフォグラフィクスの制作環境として特に優れている。なぜなら、Remotionのアニメーションモデルはフレーム番号という単一の整数から完全に決定論的に導かれるからだ。状態管理不要で特定フレームを正確にデバッグでき、データを外部JSONから注入してバッチレンダリングを自動化できる。

この記事では、Remotionで本格的なアニメーションインフォグラフィクスを構築するためのコアパターンを解説する。数値カウンター、棒グラフ、ドーナツチャート、折れ線グラフの描画アニメーション、JSONプロップスによるデータバインディング、そして要素の段階的なエントランスによるストーリーテリング設計まで、実装コードを交えて説明する。


基礎:springinterpolate

Remotionでインフォグラフィクスを作るアニメーションのほぼすべては、2つのプリミティブに還元される。springinterpolateだ。

spring:物理ベースのイージング

springはばね-質量-ダンパー系を模倣する。現在のフレームと設定パラメーターを受け取り、0から1へと自然な加速・減速・わずかなオーバーシュートを経て落ち着くプログレス値を返す。

import { spring, useCurrentFrame, useVideoConfig } from "remotion";

const frame = useCurrentFrame();
const { fps } = useVideoConfig();

const progress = spring({
  frame,
  fps,
  config: {
    stiffness: 80,  // 高いほどキビキビした動き
    damping: 14,    // 高いほどオーバーシュートが少ない
    mass: 1,
  },
});
// progressは0から約1へ、自然なバウンスを伴って収束する

データの数値に適用するには、progress * targetValueとするだけだ。棒グラフの幅、ドーナツの角度、カウンターの値を0からターゲットへなめらかにアニメーションできる。

interpolate:値域のマッピング

interpolateは入力値をある範囲から別の範囲へ変換する。クランプオプションで範囲外に飛び出すのを防げる。

import { interpolate } from "remotion";

// フレーム0〜60をopacity 0→1にマッピング(範囲外はクランプ)
const opacity = interpolate(frame, [0, 60], [0, 1], {
  extrapolateLeft: "clamp",
  extrapolateRight: "clamp",
});

springが「データ値のアニメーション」に向くのに対し、interpolateは「タイミングを厳密に制御したい演出」に向く。この2つを組み合わせることで、あらゆるインフォグラフィクスのアニメーションを表現できる。


数値カウンターアニメーション

0からターゲット値へ数値が上昇するカウンターは、インフォグラフィクスで最もよく使われる演出だ。

import { spring, useCurrentFrame, useVideoConfig } from "remotion";

interface CounterProps {
  targetValue: number;
  label: string;
  prefix?: string;
  suffix?: string;
  startFrame?: number;
}

export const AnimatedCounter: React.FC<CounterProps> = ({
  targetValue,
  label,
  prefix = "",
  suffix = "",
  startFrame = 0,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const progress = spring({
    frame: Math.max(0, frame - startFrame),
    fps,
    config: { stiffness: 60, damping: 12, mass: 1 },
  });

  const displayValue = Math.round(progress * targetValue);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
      }}
    >
      <div
        style={{
          fontSize: 96,
          fontWeight: 800,
          color: "#1a1a2e",
          lineHeight: 1,
        }}
      >
        {prefix}{displayValue.toLocaleString()}{suffix}
      </div>
      <div
        style={{
          fontSize: 20,
          color: "#64748b",
          marginTop: 12,
          letterSpacing: "0.08em",
          textTransform: "uppercase",
        }}
      >
        {label}
      </div>
    </div>
  );
};

startFrameプロップスを使えば複数カウンターの登場タイミングをずらせる。最初のカウンターはframe 0、次はframe 20、その次はframe 40というように設定すれば、カスケード状のエントランスが生まれる。

toLocaleString()で数値が1,240,000のようにカンマ区切りで表示される。パーセント表示ならsuffix="%"、通貨ならprefix="¥"を渡せばよい。


アニメーション棒グラフ

各バーが左から右へと成長し、順番にエントランスする棒グラフを実装する。

import { spring, interpolate, useCurrentFrame, useVideoConfig } from "remotion";

interface BarData {
  label: string;
  value: number;
  color: string;
}

interface BarChartProps {
  data: BarData[];
  maxValue: number;
  startFrame?: number;
  staggerFrames?: number;
}

export const AnimatedBarChart: React.FC<BarChartProps> = ({
  data,
  maxValue,
  startFrame = 0,
  staggerFrames = 10,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: 28,
        width: "100%",
        fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
      }}
    >
      {data.map((bar, index) => {
        const barStartFrame = startFrame + index * staggerFrames;

        const progress = spring({
          frame: Math.max(0, frame - barStartFrame),
          fps,
          config: { stiffness: 70, damping: 16, mass: 1 },
        });

        const widthPercent = (bar.value / maxValue) * 100 * progress;

        const labelOpacity = interpolate(
          frame,
          [barStartFrame, barStartFrame + 12],
          [0, 1],
          { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
        );

        return (
          <div key={bar.label}>
            <div
              style={{
                display: "flex",
                justifyContent: "space-between",
                marginBottom: 8,
                opacity: labelOpacity,
              }}
            >
              <span style={{ fontSize: 18, color: "#334155", fontWeight: 600 }}>
                {bar.label}
              </span>
              <span style={{ fontSize: 18, color: bar.color, fontWeight: 700 }}>
                {bar.value.toLocaleString()}
              </span>
            </div>
            <div
              style={{
                height: 44,
                background: "#f1f5f9",
                borderRadius: 6,
                overflow: "hidden",
              }}
            >
              <div
                style={{
                  width: `${widthPercent}%`,
                  height: "100%",
                  background: bar.color,
                  borderRadius: 6,
                  transition: "none",
                }}
              />
            </div>
          </div>
        );
      })}
    </div>
  );
};

バートラック要素にoverflow: 'hidden'を設定することが重要だ。springのオーバーシュートフェーズでは進捗値が1を超えることがあり、これがなければバーがコンテナをはみ出して表示される。


JSONプロップスによるデータバインディング

RemotionのコンポジションはinputPropsとしてJSONシリアライズ可能な任意のデータを受け取れる。これがデータ駆動型インフォグラフィクスの核心だ。

// Root.tsxでのコンポジション登録
<Composition
  id="RevenueChart"
  component={RevenueChartScene}
  width={1920}
  height={1080}
  fps={30}
  durationInFrames={180}
  defaultProps={{
    data: [
      { label: "東日本", value: 4200, color: "#3b82f6" },
      { label: "西日本", value: 3100, color: "#8b5cf6" },
      { label: "海外", value: 5800, color: "#06b6d4" },
    ],
    maxValue: 6500,
    title: "地域別売上(2025年度)",
  }}
/>

CLIから異なるデータセットで書き出す場合:

npx remotion render RevenueChart out-q4.mp4 \
  --props '{"data":[{"label":"Q4","value":3200,"color":"#3b82f6"}],"maxValue":4000,"title":"Q4実績"}'

あるいはNode.js APIで自動化する:

import { renderMedia, selectComposition } from "@remotion/renderer";
import { bundle } from "@remotion/bundler";

const datasets = await loadMonthlyData(); // 外部データソースから読み込む
const bundleLocation = await bundle({ entryPoint: "./src/index.ts" });

for (const dataset of datasets) {
  const composition = await selectComposition({
    serveUrl: bundleLocation,
    id: "RevenueChart",
    inputProps: dataset,
  });

  await renderMedia({
    composition,
    serveUrl: bundleLocation,
    codec: "mp4",
    outputLocation: `./out/${dataset.period}.mp4`,
  });
}

この設計により、アニメーションコードはデータから完全に切り離される。月次レポートを自動生成したり、12ヶ月分のデータを12本の動画として一括レンダリングしたりするパイプラインが、少量のコードで実現できる。


ドーナツチャートのリングアニメーション

SVGのstroke-dashoffsetを使って、リングが0からターゲットパーセンテージまで描画されるドーナツチャートを実装する。

import { spring, useCurrentFrame, useVideoConfig } from "remotion";

interface DonutProps {
  percentage: number;
  label: string;
  color?: string;
  size?: number;
  strokeWidth?: number;
}

export const AnimatedDonut: React.FC<DonutProps> = ({
  percentage,
  label,
  color = "#3b82f6",
  size = 240,
  strokeWidth = 24,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;

  const progress = spring({
    frame,
    fps,
    config: { stiffness: 50, damping: 14, mass: 1 },
  });

  const dashOffset = circumference - (circumference * percentage * progress) / 100;
  const displayPercentage = Math.round(percentage * progress);

  return (
    <div
      style={{
        position: "relative",
        width: size,
        height: size,
        fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
      }}
    >
      <svg width={size} height={size} style={{ transform: "rotate(-90deg)" }}>
        {/* トラック(背景の円) */}
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke="#e2e8f0"
          strokeWidth={strokeWidth}
        />
        {/* アニメーションするリング */}
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke={color}
          strokeWidth={strokeWidth}
          strokeDasharray={circumference}
          strokeDashoffset={dashOffset}
          strokeLinecap="round"
        />
      </svg>
      {/* 中央のテキスト */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <span
          style={{
            fontSize: size * 0.22,
            fontWeight: 800,
            color: "#1e293b",
            lineHeight: 1,
          }}
        >
          {displayPercentage}%
        </span>
        <span
          style={{
            fontSize: size * 0.09,
            color: "#64748b",
            marginTop: size * 0.03,
            textAlign: "center",
          }}
        >
          {label}
        </span>
      </div>
    </div>
  );
};

CSSのconic-gradientよりSVGのstroke-dashoffsetを推奨する理由は、Remotionのヘッドレスレンダラー(Chromium)での再現性の高さと、数値の精密な制御にある。


折れ線グラフの描画アニメーション

パスが左から右へと線を描くように描画されるアニメーション。SVGのstroke-dashoffsetを使った古典的な技法だ。

import { interpolate, useCurrentFrame } from "remotion";

interface LineChartProps {
  data: number[];
  width?: number;
  height?: number;
  color?: string;
  animationDuration?: number;
}

export const AnimatedLineChart: React.FC<LineChartProps> = ({
  data,
  width = 900,
  height = 400,
  color = "#3b82f6",
  animationDuration = 90,
}) => {
  const frame = useCurrentFrame();

  const padding = { top: 20, right: 20, bottom: 40, left: 20 };
  const chartW = width - padding.left - padding.right;
  const chartH = height - padding.top - padding.bottom;

  const minVal = Math.min(...data);
  const maxVal = Math.max(...data);
  const range = maxVal - minVal || 1;

  const points = data.map((v, i) => ({
    x: padding.left + (i / (data.length - 1)) * chartW,
    y: padding.top + (1 - (v - minVal) / range) * chartH,
  }));

  const pathD = points
    .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
    .join(" ");

  // パスの総長さを近似計算
  const pathLength = points.reduce((total, p, i) => {
    if (i === 0) return total;
    const prev = points[i - 1];
    const dx = p.x - prev.x;
    const dy = p.y - prev.y;
    return total + Math.sqrt(dx * dx + dy * dy);
  }, 0);

  const drawProgress = interpolate(
    frame,
    [0, animationDuration],
    [0, 1],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  const dashOffset = pathLength * (1 - drawProgress);

  // ラインをトレースするドット
  const dotIdx = Math.min(
    Math.floor(drawProgress * (points.length - 1)),
    points.length - 1
  );
  const dotPoint = points[dotIdx];

  return (
    <svg width={width} height={height}>
      {/* グリッドライン */}
      {[0, 0.25, 0.5, 0.75, 1].map((f) => (
        <line
          key={f}
          x1={padding.left}
          y1={padding.top + f * chartH}
          x2={padding.left + chartW}
          y2={padding.top + f * chartH}
          stroke="#e2e8f0"
          strokeWidth={1}
        />
      ))}

      {/* アニメーションライン */}
      <path
        d={pathD}
        fill="none"
        stroke={color}
        strokeWidth={4}
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeDasharray={pathLength}
        strokeDashoffset={dashOffset}
      />

      {/* トレースドット */}
      {drawProgress > 0 && (
        <circle cx={dotPoint.x} cy={dotPoint.y} r={8} fill={color} />
      )}
    </svg>
  );
};

strokeDasharrayをパスの総長さに設定し、strokeDashoffsetpathLengthから0へアニメーションする。この手法はSVGの任意の<path>に適用でき、矢印、国境線、カスタムシェイプの描画アニメーションにも使える。


要素の段階的なエントランスでストーリーを語る

データジャーナリズムの定番手法は、グラフの要素を順番に登場させることだ。読者は各要素を理解してから次の要素と出会う。これはRemotionではstartFrameのオフセット設定だけで実現できる。

// 汎用的な「startFrameから始まるspringリビール」フック
const useReveal = (startFrame: number) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const progress = spring({
    frame: Math.max(0, frame - startFrame),
    fps,
    config: { stiffness: 80, damping: 14, mass: 1 },
  });

  return {
    opacity: Math.min(1, progress * 2),
    translateY: (1 - progress) * 30,
  };
};

// フルシーンのインフォグラフィクス
export const InfographicScene: React.FC<{ data: BarData[]; title: string }> = ({
  data,
  title,
}) => {
  const titleReveal = useReveal(0);
  const subtitleReveal = useReveal(15);
  const chartReveal = useReveal(30);
  const footerReveal = useReveal(90 + data.length * 10);

  return (
    <div
      style={{
        width: 1920,
        height: 1080,
        background: "#ffffff",
        padding: 80,
        fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
      }}
    >
      <h1
        style={{
          opacity: titleReveal.opacity,
          transform: `translateY(${titleReveal.translateY}px)`,
          fontSize: 56,
          fontWeight: 800,
          color: "#0f172a",
          margin: 0,
          marginBottom: 16,
        }}
      >
        {title}
      </h1>

      <p
        style={{
          opacity: subtitleReveal.opacity,
          transform: `translateY(${subtitleReveal.translateY}px)`,
          fontSize: 24,
          color: "#64748b",
          margin: 0,
          marginBottom: 60,
        }}
      >
        出典: 社内BI システム
      </p>

      <div
        style={{
          opacity: chartReveal.opacity,
          transform: `translateY(${chartReveal.translateY}px)`,
        }}
      >
        <AnimatedBarChart
          data={data}
          maxValue={Math.max(...data.map((d) => d.value)) * 1.1}
          startFrame={30}
          staggerFrames={10}
        />
      </div>

      <p
        style={{
          opacity: footerReveal.opacity,
          transform: `translateY(${footerReveal.translateY}px)`,
          fontSize: 16,
          color: "#94a3b8",
          marginTop: 40,
        }}
      >
        ※ 全数値は概算。詳細は別紙財務資料を参照。
      </p>
    </div>
  );
};

16:9インフォグラフィクスのタイミング設計指針

1920×1080の標準的なインフォグラフィクスシーンに適したフレーム配分:

  • タイトルエントランス:frame 0〜30
  • サブタイトル・データソース表示:frame 15〜45
  • チャート描画開始:frame 30
  • バー/ラインの段階的出現:1要素あたり8〜12フレームのずらし
  • カウンター類:チャートが表示された後、1要素あたり10〜15フレームのずらし
  • 最終状態の保持(ホールド):最低60フレーム(30fpsで2秒)
  • 4バーのチャート1シーン全体の目安:180〜240フレーム(6〜8秒)

最終状態を十分な時間保持することが重要だ。データが表示されてすぐに次のシーンへ移行すると、視聴者が数値を読む時間がなくなる。


データジャーナリズムのレイアウト原則

NYT、The Economist、FTが確立したデータアニメーションの視覚的慣習は参考に値する。

1画面1メッセージ。 1シーンは1つのデータポイントか傾向を伝えるためのもの。5本の棒グラフがあれば5シーン構成にする方が明快だ。

結論から始める。 タイトルはチャートの説明ではなく、発見した事実を書く。「地域別売上比較」ではなく「アジア太平洋が2025年に最大成長」。

インサイトに向かってアニメーションする。 突出したバーを示したいなら、他のバーを先に出し、最後に目玉のバーを登場させると効果的だ。

カラーパレットを絞る。 ハイライトしたいデータ系列に1色のアクセントカラーを使い、他はニュートラルグレーにする。5色使うと視線が分散する。

軸ラベルはアニメーションさせない。 ラベルは最初のデータ要素と同時か直前に即座に表示する。ラベルのアニメーションは可読性を下げるだけだ。


RenderCompテンプレートで素早く始める

ゼロからプロダクション品質のアニメーションインフォグラフィクスを構築するには相当な開発時間がかかる。RenderCompでは、数値カウンター、棒グラフ、ドーナツチャート、完全なデータストーリーシーンなど、Remotionベースのインフォグラフィクステンプレートを提供している。defaultPropsでデータを差し替えるだけでそのまま使え、レポーティングパイプラインへの組み込みにも対応している。

rendercomp.comでテンプレートライブラリを確認してみてほしい。


よくある質問

Q: D3.jsやRechartsをRemotionコンポジション内で使えますか? A: D3は使えます。D3はSVG操作の低レベルライブラリなので、座標計算にD3を使い、レンダリングはReactで行うパターンが最も相性が良いです。RechartsなどCSSトランジションやResizeObserverに依存するチャートライブラリは、Remotionのヘッドレスレンダラーで正しくアニメーションしない可能性があるため、この記事で示したような手動SVGアプローチを推奨します。

Q: springのオーバーシュートで棒グラフがコンテナからはみ出します。どうすればいいですか? A: バートラックの親要素にoverflow: 'hidden'を設定してください。springはオーバーシュートフェーズで一時的に1を超える値を返すため、これがないとバーがコンテナ幅を超えて表示されます。overflow: hiddenでクリッピングされます。

Q: CSVファイルのデータをRemotionに渡せますか? A: RemotionのinputPropsはJSONシリアライズ可能な値である必要があります。CSVを事前にJSONに変換する前処理ステップを挟んでください。Node.jsのcsv-parseライブラリを使えば数行のコードで変換できます。変換後のJSONをrenderMedia()inputPropsに渡す設計が標準的です。

Q: 12ヶ月分のデータを12本の動画として自動生成できますか? A: はい、これはRemotionの最も強力な使い方の一つです。renderMedia()をループで呼び出し、各呼び出しに異なるinputPropsを渡すだけです。各呼び出しが独立した出力ファイルを生成します。

Q: ソーシャルメディア向けのインフォグラフィクスはどの解像度が適していますか? A: LinkedInとTwitter/Xのフィード投稿は1200×628(1.91:1)が最適です。Instagram正方形は1080×1080。YouTubeや動画ビジネスコンテンツは1920×1080(16:9)。アスペクト比ごとに別コンポジションを用意するのが現実的です。

Q: 数値の小数点表示(例:73.4%)はどう実装しますか? A: spring値にtargetValueを掛けた結果に.toFixed(1)を適用します: (progress * targetValue).toFixed(1)。算術が必要な場合はparseFloatでラップしてください。

Q: チャートのアニメーションが完了した後にフッターを表示させるにはどうすればいいですか? A: 全チャートアニメーションが落ち着くフレーム(staggerの合計フレーム数 + springの収束時間約30フレーム)をstartFrameとして使います。interpolateextrapolateRight: 'clamp'を設定した単純なフェードインが最もシンプルな実装です。

すぐに使える

1,400以上のRemotionテンプレートを一括入手

買い切り永続ライセンス。TypeScript製。今日から動画制作スピードが変わります。

RenderCompを試す →