R RenderComp
remotion countdown timer animation tutorial

Remotionでカウントダウンタイマーアニメーションを作る

Remotionでカウントダウンタイマーアニメーションを作る

カウントダウンタイマーは動画表現の中でも最も汎用性の高い要素のひとつだ。ライブ配信のオープニング、プロダクトリリース告知の演出、スポーツ中継のショットクロック表示、映画的なタイトルシーケンス——あらゆるコンテキストで視聴者の注意を引き、時間的な緊張感を生み出す。

Remotionでカウントダウンタイマーを実装することは、実はこのフレームワークの設計思想と非常に相性が良い。なぜなら、Remotionのアニメーションモデル自体が「決定論的なフレームカウント」を軸に構築されており、それはカウントダウンの概念そのものだからだ。本記事では数値表示から始まり、SVG円形プログレスリング、フリップクロックパターン、ゼロ接近時の緊張感演出まで、段階的に実装を解説する。


基本概念:フレームと時間の関係

コードを書く前に、Remotionにおけるフレームと秒数の関係を明確にしておこう。

useCurrentFrame()は0から始まるフレーム番号を返す。useVideoConfig()durationInFrames(コンポジションの総フレーム数)とfps(フレームレート)を含むオブジェクトを返す。この2つから時間に関するすべての値が導き出せる。

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

// 経過秒数
const elapsedSeconds = frame / fps;

// 残り秒数(カウントダウンに表示する値)
const remainingSeconds = (durationInFrames - frame) / fps;

// 表示用の整数秒(Math.ceilを使う理由は後述)
const displaySeconds = Math.ceil(remainingSeconds);

// 進捗率(0=開始, 1=終了)
const progress = frame / durationInFrames;

Math.floorではなくMath.ceilを使う理由がある。残り3.1秒のとき、カウントダウンは「3」ではなく「4」を表示すべきだ——まだ3秒の時間は来ていないからだ。Math.ceilは「まだ何秒残っているか」を正確に表現する。


シンプルな数値カウントダウン

最もシンプルな実装から始めよう。残り秒数を大きなテキストで表示するだけのカウントダウンだ。

import { useCurrentFrame, useVideoConfig } from "remotion";

export const BasicCountdown: React.FC = () => {
  const frame = useCurrentFrame();
  const { durationInFrames, fps } = useVideoConfig();

  const displaySeconds = Math.ceil((durationInFrames - frame) / fps);

  return (
    <div
      style={{
        width: 1920,
        height: 1080,
        background: "#0f172a",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
      }}
    >
      <span
        style={{
          fontSize: 300,
          fontWeight: 900,
          color: "#f1f5f9",
          fontVariantNumeric: "tabular-nums",
          lineHeight: 1,
        }}
      >
        {displaySeconds}
      </span>
    </div>
  );
};

fontVariantNumeric: "tabular-nums"の指定が重要だ。これはすべての数字グリフを等幅で表示する設定で、「10」→「9」→「8」と数値が変化するときに数字が横にずれるガタつきを防ぐ。カウントダウンでこの指定を忘れると、数値変化のたびにテキストが微妙に動いて見え、不自然な印象を与える。


ミリ秒表示の実装

モータースポーツ、eスポーツ、精密な時間計測が必要なコンテキストではミリ秒表示が求められる。

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

const framesRemaining = durationInFrames - frame;
const secondsRemaining = Math.floor(framesRemaining / fps);
const msRemaining = Math.floor((framesRemaining % fps) / fps * 1000);

const minutes = Math.floor(secondsRemaining / 60);
const seconds = secondsRemaining % 60;

const formatted = [
  String(minutes).padStart(2, "0"),
  ":",
  String(seconds).padStart(2, "0"),
  ".",
  String(msRemaining).padStart(3, "0"),
].join("");

30fpsの場合、ミリ秒の精度は約33ms単位となる。60fpsなら約17ms単位で、より滑らかなミリ秒変化が視覚的に伝わる。レーシングゲームのリプレイや競技映像では60fpsレンダリングを推奨する。


SVG円形プログレスリング

数値だけでなく、時間の経過を視覚的に表現する円形リングは最も訴求力の高いデザインのひとつだ。前述のstroke-dashoffsetテクニックを応用して実装できる。

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

const RADIUS = 220;
const STROKE_WIDTH = 16;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

export const CircularTimer: React.FC = () => {
  const frame = useCurrentFrame();
  const { durationInFrames, fps } = useVideoConfig();

  // progressが0→1に増加するにつれ、リングが減少する
  const progress = frame / durationInFrames;
  const strokeDashoffset = CIRCUMFERENCE * progress;

  const displaySeconds = Math.ceil((durationInFrames - frame) / fps);

  return (
    <div
      style={{
        width: 1920,
        height: 1080,
        background: "#0f172a",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
      }}
    >
      {/* SVGを-90度回転して12時位置からリングが減少するようにする */}
      <svg
        width={500}
        height={500}
        viewBox="0 0 500 500"
        style={{ transform: "rotate(-90deg)", position: "absolute" }}
      >
        {/* トラックリング(背景) */}
        <circle
          cx={250}
          cy={250}
          r={RADIUS}
          fill="none"
          stroke="rgba(255,255,255,0.1)"
          strokeWidth={STROKE_WIDTH}
        />
        {/* アクティブリング */}
        <circle
          cx={250}
          cy={250}
          r={RADIUS}
          fill="none"
          stroke="#6366f1"
          strokeWidth={STROKE_WIDTH}
          strokeLinecap="round"
          strokeDasharray={CIRCUMFERENCE}
          strokeDashoffset={strokeDashoffset}
        />
      </svg>
      {/* リングの中央に数値を重ねる */}
      <div
        style={{
          fontSize: 160,
          fontWeight: 900,
          color: "#f1f5f9",
          fontVariantNumeric: "tabular-nums",
          position: "relative",
          zIndex: 1,
        }}
      >
        {displaySeconds}
      </div>
    </div>
  );
};

SVGをrotate(-90deg)しているのは、SVGの座標原点がデフォルトで3時(右)の位置だからだ。12時(上)からリングが減っていく自然な表現にするために、-90度回転してから描く。


ゼロ接近時のカラーテンション演出

カウントダウンが残り少なくなるにつれて色が変化するのは、視覚的な緊張感を高める最も効果的な演出のひとつだ。interpolateColors()を使って色の遷移を滑らかに実装できる。

import { interpolateColors } from "remotion";

const progress = frame / durationInFrames;

const ringColor = interpolateColors(
  progress,
  [0, 0.5, 0.75, 1],
  ["#22c55e", "#eab308", "#f97316", "#ef4444"]
);

緑(余裕あり)→黄(注意)→オレンジ(警戒)→赤(緊急)という色の変化は、言葉なしに視聴者へ時間的切迫感を伝える。この演出は世界中のあらゆる文化圏で「赤=危険」「緑=安全」という直感的な読み取りが成立するため、国際向けコンテンツにも効果的だ。

最後の数秒でのパルス演出

さらに緊張感を高める方法として、残り5秒以内になったら画面全体をわずかにスケールアップ・ダウンさせるパルス演出がある。

import { spring } from "remotion";

const remainingSeconds = Math.ceil((durationInFrames - frame) / fps);
const isLastFive = remainingSeconds <= 5;

const pulseScale = isLastFive
  ? spring({
      frame: frame % Math.round(fps * 0.5),
      fps,
      config: { stiffness: 400, damping: 10 },
      from: 1,
      to: 1.04,
      durationInFrames: Math.round(fps * 0.5),
    })
  : 1;

frame % cycleDurationを使うことで、spring()のアニメーションを一定周期で繰り返させるハートビート効果を作れる。


フリップクロックアニメーション

空港や駅の古い出発案内板のような「カードが半分に割れて数字が変わるフリップクロック」は、ノスタルジックかつ高い訴求力を持つ演出だ。

仕組みは次のとおり:カード上半分と下半分で別々の要素を作り、数字が変わるタイミングでそれぞれが3Dの回転アニメーションを行う。

import { interpolate, Easing, useCurrentFrame } from "remotion";

const FlipDigit: React.FC<{ value: number; prevValue: number }> = ({
  value,
  prevValue,
}) => {
  const frame = useCurrentFrame(); // Sequence内では相対フレーム
  const FLIP_DURATION = 8;

  // 上フラップ:現在値を表示したまま下へ回転
  const topFlipAngle = interpolate(
    frame,
    [0, FLIP_DURATION / 2],
    [0, -90],
    {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: Easing.in(Easing.quad),
    }
  );

  // 下フラップ:前の値を表示したまま上から現れる
  const bottomRevealAngle = interpolate(
    frame,
    [FLIP_DURATION / 2, FLIP_DURATION],
    [90, 0],
    {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: Easing.out(Easing.quad),
    }
  );

  const cardBase: React.CSSProperties = {
    width: 160,
    height: 200,
    background: "#1e293b",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    fontSize: 120,
    fontWeight: 900,
    color: "#f1f5f9",
    fontVariantNumeric: "tabular-nums",
    fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", sans-serif',
    borderRadius: 8,
    overflow: "hidden",
    position: "absolute",
    top: 0,
  };

  return (
    <div style={{ position: "relative", width: 160, height: 200, perspective: 600 }}>
      {/* 静的な上半分(新しい値) */}
      <div style={{ ...cardBase, clipPath: "inset(0 0 50% 0)" }}>{value}</div>
      {/* 静的な下半分(新しい値) */}
      <div style={{ ...cardBase, clipPath: "inset(50% 0 0 0)" }}>{value}</div>
      {/* アニメーション上フラップ(旧値、下へ回転) */}
      <div
        style={{
          ...cardBase,
          clipPath: "inset(0 0 50% 0)",
          transformOrigin: "50% 100%",
          transform: `rotateX(${topFlipAngle}deg)`,
          backfaceVisibility: "hidden",
        }}
      >
        {prevValue}
      </div>
      {/* アニメーション下フラップ(旧値、下から出現) */}
      <div
        style={{
          ...cardBase,
          clipPath: "inset(50% 0 0 0)",
          transformOrigin: "50% 0%",
          transform: `rotateX(${bottomRevealAngle}deg)`,
          backfaceVisibility: "hidden",
        }}
      >
        {prevValue}
      </div>
    </div>
  );
};

実際のカウントダウンに使う場合は、秒数が変わるフレームを計算して<Sequence from={changeFrame}>の中に<FlipDigit>を配置することで、正確なタイミングでフリップが発生する。


放送品質のカウントダウン完成形

円形リング+数値+ステータステキスト+カラーテンションを組み合わせた放送品質のカウントダウンコンポーネントを示す。

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

export const BroadcastCountdown: React.FC<{ label: string }> = ({ label }) => {
  const frame = useCurrentFrame();
  const { durationInFrames, fps } = useVideoConfig();

  const progress = frame / durationInFrames;
  const remainingSeconds = Math.ceil((durationInFrames - frame) / fps);
  const strokeDashoffset = 2 * Math.PI * 200 * progress;

  const ringColor = interpolateColors(
    progress,
    [0, 0.5, 0.8, 1],
    ["#22c55e", "#eab308", "#f97316", "#ef4444"]
  );

  const isLastFive = remainingSeconds <= 5;
  const pulseScale = isLastFive
    ? spring({
        frame: frame % Math.round(fps * 0.5),
        fps,
        config: { stiffness: 400, damping: 10 },
        from: 1,
        to: 1.04,
      })
    : 1;

  return (
    <div
      style={{
        width: 1920,
        height: 1080,
        background: "#0a0a0a",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        gap: 24,
        fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
      }}
    >
      <div style={{ color: "rgba(255,255,255,0.5)", fontSize: 28, letterSpacing: "0.2em" }}>
        {label}
      </div>
      <div style={{ position: "relative", transform: `scale(${pulseScale})` }}>
        <svg width={500} height={500} viewBox="0 0 500 500" style={{ transform: "rotate(-90deg)" }}>
          <circle cx={250} cy={250} r={200} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={12} />
          <circle
            cx={250} cy={250} r={200}
            fill="none"
            stroke={ringColor}
            strokeWidth={12}
            strokeLinecap="round"
            strokeDasharray={2 * Math.PI * 200}
            strokeDashoffset={strokeDashoffset}
          />
        </svg>
        <div
          style={{
            position: "absolute",
            top: "50%",
            left: "50%",
            transform: "translate(-50%, -50%)",
            fontSize: 180,
            fontWeight: 900,
            color: "#ffffff",
            fontVariantNumeric: "tabular-nums",
            lineHeight: 1,
          }}
        >
          {remainingSeconds}
        </div>
      </div>
    </div>
  );
};

ユースケース別の活用例

ライブ配信のプリロール。 配信開始60秒前から始まるカウントダウン動画。待機中の視聴者の離脱を防ぎ、開始直前の期待感を高める。カラーテンション演出が「もうすぐ始まる」という情報を視覚的に伝える。

プロダクトリリース告知。 ランディングページに埋め込む動画ループ。リリース日時のタイムスタンプからコンポジションのdurationInFramesを逆算してレンダリングする。

スポーツ放送オーバーレイ。 ペナルティシュートアウトやショットクロックのグラフィック。透明背景でレンダリングし、ライブ映像に合成する。ミリ秒精度が必要な場合は60fps推奨。

イベントオープナー。 カンファレンスのキーノートや製品発表動画の冒頭。10→0または5→0のシンプルなカウントダウンがオーディエンスの注意を集め、本編への期待感を高める。


RenderCompのカウントダウンテンプレートで素早くスタート

カウントダウンタイマーは数値計算、SVGジオメトリ、カラー補間、アニメーションエフェクトと多くの要素が絡み合う。RenderCompはミニマル数値型、SVG円形リング型、フリップクロック型、放送オーバーレイ型など複数スタイルのRemotionカウントダウンテンプレートを提供している。すべてTypeScriptで型付けされ、パラメータで完全にカスタマイズ可能だ。rendercomp.comでテンプレート一覧を確認し、数時間で完成形に仕上げてほしい。


FAQ

Q1: 実際のリリース日時に合わせてカウントダウンを同期させるにはどうすればよいですか?

レンダリング時にターゲット日時と現在時刻の差分を秒数で計算し、durationInFrames = remainingSeconds * fpsとしてコンポジションをレンダリングします。コンポーネント内ではdurationInFrames / fpsが自動的に正確なカウントダウン秒数になります。

Q2: 時・分・秒を同時に表示するにはどうすればよいですか?

残りフレーム数から各単位を計算します。

const totalSecondsLeft = Math.ceil(framesRemaining / fps);
const hours = Math.floor(totalSecondsLeft / 3600);
const minutes = Math.floor((totalSecondsLeft % 3600) / 60);
const seconds = totalSecondsLeft % 60;

各値をpadStart(2, "0")でゼロ埋めしてHH:MM:SS形式で表示します。

Q3: カウントダウンが「0」になった後も1フレーム余分に表示されてしまいます。

Math.ceilを使っているか確認してください。また、コンポジションのdurationInFramesの設定が意図した値になっているかも確認が必要です。RemotionはdurationInFramesを超えたフレームをレンダリングしないため、最終フレームまで正しく設定されていれば余分な「0」フレームは発生しません。

Q4: カウントダウンがゼロになったときに効果音を鳴らすにはどうすればよいですか?

<Audio>コンポーネントを<Sequence from={durationInFrames - fps}>の中に置くと、終了1秒前に音が鳴り始めます。毎秒のティック音を追加したい場合は、各秒の境界フレームに<Sequence>を配置していきます。

Q5: カウントアップタイマー(0から増える)にするにはどうすればよいですか?

円形リングをカウントアップにする場合はstrokeDashoffsetの方向を逆にします。

// カウントダウン(リングが減る)
const strokeDashoffset = CIRCUMFERENCE * progress;

// カウントアップ(リングが増える)
const strokeDashoffset = CIRCUMFERENCE * (1 - progress);

表示する数値はMath.floor(frame / fps)で経過秒数を計算します。

Q6: 透明背景(アルファチャンネル付き)でレンダリングして他映像に合成できますか?

できます。RemotionはPNGシーケンス出力(アルファチャンネル付き)をサポートしています。背景色の指定をすべてrgba(0,0,0,0)にし、コンポジションのデフォルト背景をnullにした状態でPNGシーケンスとしてレンダリングすることで、透明背景の映像素材として書き出せます。

Q7: カウントダウンに推奨のフレームレートはありますか?

一般的な用途では30fpsで十分です。ミリ秒単位の視覚的精度が重要なスポーツ放送やeスポーツ用途では60fpsを推奨します。60fpsにすることでミリ秒の変化がより滑らかに見え、数字の変化や円形リングの動きの流動性も向上します。

Q8: 「10」から「9」への変化など、2桁から1桁になるときにレイアウトが崩れないようにするにはどうすればよいですか?

数値コンテナにminWidthを設定し、text-align: centerを指定することで固定幅を維持できます。fontVariantNumeric: "tabular-nums"は各桁グリフを等幅にするため、数値変化時の横ズレを防ぎます。これら2点を組み合わせることで桁数が変わっても安定したレイアウトを保てます。

販売中

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

買い切り(一括払い)・サブスクなし・生涯アップデート無料。TypeScript製。

料金プランを見る →