Remotion でローディングリングを実装する — SVG stroke-dasharray と回転ループの実装パターン
Remotion における映像は「フレーム番号を受け取り、その瞬間の画面状態を確定的に返す関数」です。このモデルは、Web アニメーションで頻発するタイマーのドリフトや非決定的な再生順序の問題を根本から排除します。ローディングリングはこの決定論的モデルの恩恵を最も受ける題材のひとつです。「弧の長さ」と「回転角度」という 2 本の状態軸がいずれも純粋な数式で記述できるからです。
CSS アニメーションで同じリングを実装しようとすると、animation-delay の積み重ねや keyframes のパーセンテージ指定でタイミングを調整することになります。一方 Remotion では useCurrentFrame() が返す整数フレームを基準に、すべての状態が一意に決まります。これにより「フレーム 45 でリングが何度回転し、弧が何 px 描かれているか」をテスト可能なコードとして表現できます。
本記事では SVG の stroke-dasharray / stroke-dashoffset による弧の制御から始め、frame % cycle を使った回転ループ、インディタミネートスピナー特有の「弧の呼吸」との合成、そして transform-origin のずれという実装者が必ずはまる落とし穴まで、実際に動くコードで解説します。
SVG の弧: 周長計算と stroke-dasharray の原理
SVG の <circle> は stroke-dasharray で破線パターンを指定できます。値が単一の数値 L のとき、「長さ L の実線 → 長さ L の隙間 → …」と繰り返すパターンになります。ここで L を円の周長 C に設定すると、ちょうど一周分の実線と一周分の隙間が生まれ、見かけ上は普通の実線の円と同じに見えます。
stroke-dashoffset はその実線の始点をどこにずらすかを指定します。値 0 なら始点はそのまま、値 C なら実線全体が隙間側へずれて円は完全に消えます。つまり dashoffset を C から 0 へアニメートすると、円が「描かれていく」演出になります:
dashoffset = C × (1 − progress) // progress: 0→1 で弧が全開になる
周長 C の計算はシンプルです:
const RADIUS = 120;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; // ≈ 753.98
この値はコンポーネントの外側で定数として定義してください。毎フレーム計算する必要はなく、テスト時に参照できると便利です。
基本実装: 弧の描画アニメーション
まず「0 フレーム → 30 フレームで弧が全開になる」最小限のコンポーネントを実装します。
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
const RADIUS = 120;
const STROKE_WIDTH = 10;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const CENTER = 150; // viewBox の中心座標
export const ArcReveal: React.FC = () => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [0, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// dashoffset が C→0 になるにつれ弧が現れる
const dashoffset = CIRCUMFERENCE * (1 - progress);
return (
<AbsoluteFill
style={{
backgroundColor: "#0f0f0f",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width={300} height={300} viewBox="0 0 300 300">
{/* トラック(背景の薄いリング) */}
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke="#2a2a2a"
strokeWidth={STROKE_WIDTH}
/>
{/* アニメートするアーク */}
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke="#6366f1"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashoffset}
// SVG は 3 時方向から描画を開始するため -90 度回転して 12 時スタートにする
transform={`rotate(-90, ${CENTER}, ${CENTER})`}
/>
</svg>
</AbsoluteFill>
);
};
rotate(-90, ${CENTER}, ${CENTER}) の第 2・第 3 引数が重要です。SVG の rotate 変換はデフォルトで座標 (0, 0) を中心に回転します。viewBox="0 0 300 300" の場合、中心は (150, 150) なので必ず明示的に指定する必要があります。この引数を省略すると、アークが左上隅を中心に回転して画面外に消えます。
回転ループ: frame % cycle パターン
次に、アークを連続回転させます。Remotion には無限ループ専用の API はありませんが、JavaScript の剰余演算子 % で簡単に実現できます。
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
const RADIUS = 120;
const STROKE_WIDTH = 10;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const CENTER = 150;
// 30fps × 3 秒 = 90 フレームで 1 回転
const SPIN_CYCLE = 90;
export const SpinningRing: React.FC = () => {
const frame = useCurrentFrame();
// frame を SPIN_CYCLE で割った余り → 0〜89 の繰り返し
const cycleFrame = frame % SPIN_CYCLE;
const rotation = interpolate(cycleFrame, [0, SPIN_CYCLE], [0, 360]);
return (
<AbsoluteFill
style={{
backgroundColor: "#0f0f0f",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width={300} height={300} viewBox="0 0 300 300">
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke="#2a2a2a"
strokeWidth={STROKE_WIDTH}
/>
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke="#6366f1"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={CIRCUMFERENCE * 0.25} // 弧の長さを 75% に固定
transform={`rotate(${rotation - 90}, ${CENTER}, ${CENTER})`}
/>
</svg>
</AbsoluteFill>
);
};
frame % SPIN_CYCLE は frame が SPIN_CYCLE の倍数になる瞬間に 0 にリセットされます。このとき interpolate の出力も 0° に戻りますが、0° と 360° は視覚的に同じ位置なのでシームレスなループになります。
等速か、それともイージングか
上記の実装は線形(等速)回転です。interpolate の extrapolateLeft/Right を省略しているのは意図的で、cycleFrame が常に [0, SPIN_CYCLE] の範囲に収まるため外挿が発生しないからです。
イージングを加えたい場合は Easing を使います:
import { Easing, interpolate } from "remotion";
const rotation = interpolate(cycleFrame, [0, SPIN_CYCLE], [0, 360], {
easing: Easing.inOut(Easing.cubic),
});
ただしローディングスピナーは等速が正解です。スピナーに「ためる」動きを加えると、読み込み中であることよりもアニメーション自体に注意が向いてしまいます。
インディタミネートスピナー: 弧の呼吸と回転の合成
インディタミネート(不定時間)のローディングスピナーは、弧の長さが呼吸するように変化しながら同時に回転するという複合アニメーションです。Chrome や Material Design のスピナーがこのパターンを採用しています。
実装の核心は 2 つの独立したサイクルを合成することです:
- 回転サイクル
SPIN_CYCLE: リング全体の回転速度 - 弧サイクル
ARC_CYCLE: 弧の長さが短 → 長 → 短と変化する周期
import React from "react";
import { AbsoluteFill, Easing, interpolate, useCurrentFrame } from "remotion";
const RADIUS = 120;
const STROKE_WIDTH = 10;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const CENTER = 150;
const SPIN_CYCLE = 90; // 90 フレーム = 3 秒(30fps)で 1 回転
const ARC_CYCLE = 120; // 120 フレーム = 4 秒(30fps)で弧が 1 呼吸
const ARC_MIN = STROKE_WIDTH * 2; // キャップが重ならない最小長
const ARC_MAX = CIRCUMFERENCE * 0.72; // 72%
export const IndeterminateRing: React.FC = () => {
const frame = useCurrentFrame();
// --- 回転 ---
const rotation = interpolate(frame % SPIN_CYCLE, [0, SPIN_CYCLE], [0, 360]);
// --- 弧の長さ ---
const arcFrame = frame % ARC_CYCLE;
const arcLength = interpolate(
arcFrame,
// 0→45%: 成長, 45%→55%: 頂点を維持, 55%→100%: 収縮
[0, ARC_CYCLE * 0.45, ARC_CYCLE * 0.55, ARC_CYCLE],
[ARC_MIN, ARC_MAX, ARC_MAX, ARC_MIN],
{
easing: Easing.inOut(Easing.sine),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}
);
const dashoffset = CIRCUMFERENCE - arcLength;
return (
<AbsoluteFill
style={{
backgroundColor: "#0f0f0f",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width={300} height={300} viewBox="0 0 300 300">
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke="#1e1e1e"
strokeWidth={STROKE_WIDTH}
/>
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke="#6366f1"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashoffset}
transform={`rotate(${rotation - 90}, ${CENTER}, ${CENTER})`}
/>
</svg>
</AbsoluteFill>
);
};
弧のキーフレームに [0, 0.45, 0.55, 1.0] という 4 点を使っているのは、短→長のフェーズと長→短のフェーズの間に「長い状態を維持する区間」を設けているからです。[0, 0.5, 1.0] の 3 点では弧が最長になった瞬間に折り返し始めるため、視覚的に「走っている」感が出ません。頂点を 10% の幅で平坦化すると自然な呼吸感になります。
落とし穴: strokeLinecap の補正と transform-origin の挙動
strokeLinecap=“round” の端の長さ
strokeLinecap="round" を指定すると弧の両端に半円形のキャップが追加され、見かけ上の長さが arcLength + strokeWidth になります。ARC_MIN が極端に小さい場合、弧がほぼゼロ長でも端のキャップが「点」として残ります。これが意図しない挙動であれば、ARC_MIN をキャップが重ならない長さに設定してください:
// strokeWidth=10 の場合、両端のキャップ直径は 10px
// 最低でも strokeWidth × 2 = 20px 確保すると「点だけ見える」状態を避けられる
const ARC_MIN = STROKE_WIDTH * 2;
SVG の transform-origin と CSS の transform-origin は別物
React で SVG 要素に CSS の style={{ transform: "rotate(45deg)" }} を適用すると、ブラウザによって回転中心の解釈が異なります。SVG 要素には CSS transform ではなく transform 属性を使い、回転中心座標を明示するのが確実です:
// 推奨: SVG attribute で回転中心を明示
<circle transform={`rotate(${angle}, ${cx}, ${cy})`} />
// 非推奨: CSS transform(ブラウザの SVG 実装に依存)
<circle style={{ transform: `rotate(${angle}deg)`, transformOrigin: "center" }} />
SVG transform の rotate(angle, cx, cy) 形式は仕様に明記されており、Chromium・Firefox・Safari で一貫した挙動が保証されています。transformOrigin: "center" は要素の bounding box の中心を意味しますが、パスや非対称な形状ではそれが座標的な中心とずれることがあるため、常に数値で明示する習慣をつけましょう。
spring を使った登場アニメーション
ローディングリングが「画面に現れる」演出を加えたい場合、spring による opacity と scale が有効です。リング全体に spring の scale を直接かけるとオーバーシュートが目立つため、damping を高め(≥ 20)に設定してバウンスを抑えます:
import { spring, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const entrance = spring({
frame,
fps,
config: {
damping: 22, // 高いほどオーバーシュートが小さい
stiffness: 180, // 硬めのバネ — 素早く定常値へ収束
mass: 0.8,
},
});
const scale = interpolate(entrance, [0, 1], [0.6, 1.0]);
const opacity = entrance; // spring の出力 0→1 をそのまま使う
spring の出力は HTML ラッパーの <div> に CSS transform として適用し、SVG の transform 属性には回転のみを担当させる役割分担にすると、2 つの座標系が干渉しません。
完成形: 全要素を組み合わせたコンポーネント
import React from "react";
import {
AbsoluteFill,
Easing,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
const RADIUS = 100;
const STROKE_WIDTH = 10;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const CENTER = 150;
const SPIN_CYCLE = 90;
const ARC_CYCLE = 120;
const ARC_MIN = STROKE_WIDTH * 2;
const ARC_MAX = CIRCUMFERENCE * 0.72;
export const LoadingRing: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 登場アニメーション(HTML レイヤー)
const entrance = spring({ frame, fps, config: { damping: 22, stiffness: 180 } });
const scale = interpolate(entrance, [0, 1], [0.6, 1.0]);
const opacity = entrance;
// 回転(SVG transform レイヤー)
const rotation = interpolate(frame % SPIN_CYCLE, [0, SPIN_CYCLE], [0, 360]);
// 弧の長さ(SVG dashoffset レイヤー)
const arcFrame = frame % ARC_CYCLE;
const arcLength = interpolate(
arcFrame,
[0, ARC_CYCLE * 0.45, ARC_CYCLE * 0.55, ARC_CYCLE],
[ARC_MIN, ARC_MAX, ARC_MAX, ARC_MIN],
{
easing: Easing.inOut(Easing.sine),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}
);
const dashoffset = CIRCUMFERENCE - arcLength;
return (
<AbsoluteFill
style={{
backgroundColor: "#0f0f0f",
alignItems: "center",
justifyContent: "center",
}}
>
{/* spring は HTML ラッパーに適用し SVG 座標系を汚染しない */}
<div style={{ transform: `scale(${scale})`, opacity }}>
<svg width={300} height={300} viewBox="0 0 300 300">
<circle
cx={CENTER} cy={CENTER} r={RADIUS}
fill="none" stroke="#1e1e1e" strokeWidth={STROKE_WIDTH}
/>
<circle
cx={CENTER} cy={CENTER} r={RADIUS}
fill="none"
stroke="#6366f1"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashoffset}
transform={`rotate(${rotation - 90}, ${CENTER}, ${CENTER})`}
/>
</svg>
</div>
</AbsoluteFill>
);
};
まとめ
Remotion でローディングリングを実装するときの要点をまとめます:
- 周長は定数化する —
2 * Math.PI * RADIUSはコンポーネント外で一度だけ計算する - dashoffset = C × (1 − progress) — 弧の描画進捗はこの式一本で制御できる
- 回転ループは
frame % cycle— Remotion に専用 API は不要。等速が原則 - インディタミネートスピナー — SPIN_CYCLE と ARC_CYCLE を独立させ、弧のキーフレームに平坦化区間を入れる
- SVG transform の中心は
rotate(deg, cx, cy)で明示 —viewBoxのオフセットに注意 - strokeLinecap=“round” の端の補正 —
ARC_MIN = STROKE_WIDTH * 2程度が実用的な下限 - spring は HTML ラッパーに適用 — SVG の
transform属性と CSS transform の混在を避ける
RenderComp カタログのテンプレートでも同様のパターンを採用しています。複数のリングを同心円状に重ねたり、アークの色をグラデーションにしたりするには、ここで示した基本実装を Sequence でラップして from プロパティで位相をずらすだけで対応できます。各リングは独立したフレームオフセットで動き、パイプライン側から見ても useCurrentFrame() の純粋性は保たれます。