Remotion Progress Bars: Spring Physics, SVG Rings, and the Indeterminate State Problem
Progress indicators in conventional UI work backwards from async state: a spinner spins because a Promise hasn’t resolved; a bar fills because bytes have arrived. Remotion inverts this entirely. Every frame is a pure function of a frame number — there are no Promises, no network callbacks, no side effects. A progress bar at frame 60 is mathematically identical every time you render it, whether that render happens on your laptop or across 100 Lambda workers in parallel.
This determinism changes the way you should think about progress animations. You’re not reacting to state changes — you’re encoding a trajectory. The question shifts from “how do I update the bar when the download progresses?” to “what is the bar’s exact position at frame N?” That framing makes spring physics natural and SVG ring math tractable.
This article works through three patterns in order of complexity: a spring-driven determinate bar, a looping indeterminate state built on frame modulo arithmetic, and a circular SVG ring that clips based on a spring output. The worked examples match the progress-bar composition used for thumbnail generation in this series.
How spring() Differs from CSS Transitions
Before writing any JSX, it’s worth understanding what Remotion’s spring() function actually computes and where it surprises you.
spring() takes a frame number and runs a physics simulation forward from frame 0. The returned value at any given frame reflects where a simulated mass would be at that moment. Unlike a CSS transition, there’s no concept of “start time” baked in — the reference point is always the absolute frame you pass in. The key config parameters are stiffness, damping, and mass, which form the same triangle that governs real springs.
Two behaviors will bite you if you miss them:
Overshoot is real and intentional. With stiffness: 80 and damping: 15, the spring will momentarily exceed the to value before settling. For a visual fill width this looks organic and satisfying. For an SVG stroke-dashoffset calculation, a progress value above 1.0 produces a negative dashoffset, which reverses the arc direction. Always clamp when feeding spring output into SVG geometry.
durationInFrames is a settling hint, not a hard cutoff. It adjusts internal damping coefficients to aim for settlement around that frame count, but the spring settles when the physics say it settles — sometimes earlier with high stiffness, sometimes later with low damping. If you need an animation to complete exactly at a frame boundary, use interpolate() with an explicit easing function instead.
Determinate Progress Bar
A determinate bar needs to travel smoothly from 0 to a target value. The naive implementation uses interpolate() with linear extrapolation, which works but produces robotic, constant-velocity motion. Swapping in spring() immediately improves the feel:
import {
AbsoluteFill,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
type BarProps = {
targetProgress: number; // 0–1
color?: string;
};
export const DeterminateBar: React.FC<BarProps> = ({
targetProgress,
color = '#7c3aed',
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// stiffness 80 / damping 20: gentle overshoot then settle around frame 50.
// Raise damping to 30+ to eliminate overshoot entirely.
// Lower stiffness to 40 for a slower, bouncier feel.
const progress = spring({
frame,
fps,
from: 0,
to: targetProgress,
config: {
stiffness: 80,
damping: 20,
mass: 1,
},
durationInFrames: 50,
});
return (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
<div
style={{
width: 560,
height: 10,
backgroundColor: '#1e1e2e',
borderRadius: 5,
overflow: 'hidden',
}}
>
<div
style={{
// Use width%, not transform: scaleX().
// scaleX transforms from transform-origin (center by default),
// so the bar would appear to grow from both edges simultaneously.
// overflow: hidden on the track clips any spring overshoot above 100%.
width: `${Math.min(progress, 1) * 100}%`,
height: '100%',
backgroundColor: color,
borderRadius: 5,
}}
/>
</div>
</AbsoluteFill>
);
};
The scaleX vs width distinction is a genuine gotcha. transform: scaleX(0.5) on a 560 px element shrinks it to 280 px anchored at its center — the fill grows from both edges simultaneously, which looks wrong for a progress indicator. width: ...% with overflow: hidden on the track produces correct left-anchored growth.
Multi-Step Progress with Explicit Keyframes
When a composition needs to animate through discrete stages — download, parse, render — chaining separate spring() calls for each phase is error-prone. Each call’s frame reference is the same absolute frame zero, so phase offsets don’t compose naturally without manual delay arithmetic. A single interpolate() call with explicit keyframes is more readable:
import { interpolate, useCurrentFrame, Easing } from 'remotion';
// Stage pauses at 33% (frames 30–40) and 66% (frames 70–80) before continuing.
// Flat input segments produce the hold behavior; easing applies within each moving segment.
const progress = interpolate(
useCurrentFrame(),
[0, 30, 40, 70, 80, 110],
[0, 0.33, 0.33, 0.66, 0.66, 1.0],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
},
);
The flat segments (frames 30–40, 70–80) hold the bar at a fixed percentage. Easing.out(Easing.cubic) decelerates each moving segment into its pause point, reinforcing the sense of work completing in discrete steps.
Indeterminate State: The Frame-Modulo Pattern
An indeterminate indicator conveys “something is happening” without implying measurable progress. In CSS this is a looping @keyframes animation. In Remotion, looping requires a deliberate decision: the loop must be a pure function of the frame number.
The idiomatic solution is the modulo operator:
import {
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
Easing,
} from 'remotion';
export const IndeterminateBar: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 1.4 second loop: lively but not frantic at standard 30 fps.
// Math.round ensures LOOP_FRAMES is an integer — fractional modulo causes drift.
const LOOP_FRAMES = Math.round(fps * 1.4);
const loopFrame = frame % LOOP_FRAMES;
// Bar center travels from -0.2 (offscreen left) to 1.2 (offscreen right).
// The off-screen range ensures no visible pop at the loop boundary.
const x = interpolate(
loopFrame,
[0, LOOP_FRAMES * 0.15, LOOP_FRAMES * 0.85, LOOP_FRAMES],
[-0.2, 0, 1, 1.2],
{
easing: Easing.inOut(Easing.sine),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
},
);
const BAR_WIDTH_FRACTION = 0.3;
return (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
<div
style={{
width: 560,
height: 10,
backgroundColor: '#1e1e2e',
borderRadius: 5,
overflow: 'hidden',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
left: `${(x - BAR_WIDTH_FRACTION / 2) * 100}%`,
width: `${BAR_WIDTH_FRACTION * 100}%`,
height: '100%',
backgroundColor: '#7c3aed',
borderRadius: 5,
}}
/>
</div>
</AbsoluteFill>
);
};
Easing.inOut(Easing.sine) gives the bar a sinusoidal velocity curve — it accelerates smoothly out of the left and decelerates into the right, which closely mimics the Material Design indeterminate animation without any keyframe animation machinery.
Two subtle issues worth noting: Math.round on LOOP_FRAMES is not optional. If fps * 1.4 produces a non-integer (e.g., 42.0 at 30 fps is fine, but 43.75 at 31.25 fps would be problematic), the modulo accumulates fractional remainders and the loop drifts. Also, if your durationInFrames is not an exact multiple of LOOP_FRAMES, the composition ends mid-loop. For a loading indicator this is fine; if you need a clean hold on the last frame, clamp with Math.min(frame, durationInFrames - 1) before the modulo.
SVG Ring Progress
Circular progress rings are common in video dashboards, countdown timers, and onboarding flows. SVG handles them through two properties: stroke-dasharray sets the total dash length of the stroke pattern, and stroke-dashoffset shifts the pattern start. Setting both to the circle’s circumference and varying dashoffset from circumference (0% filled) down to 0 (100% filled) reveals the arc precisely.
import {
AbsoluteFill,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
type RingProps = {
targetProgress: number;
size?: number;
strokeWidth?: number;
trackColor?: string;
fillColor?: string;
};
export const ProgressRing: React.FC<RingProps> = ({
targetProgress,
size = 160,
strokeWidth = 14,
trackColor = '#1e1e2e',
fillColor = '#7c3aed',
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// SVG strokes are centered on the path — they extend strokeWidth/2 inward and outward.
// Using size/2 as radius would clip the outer half of the stroke.
const radius = size / 2 - strokeWidth / 2;
const circumference = 2 * Math.PI * radius;
const rawProgress = spring({
frame,
fps,
from: 0,
to: targetProgress,
config: { stiffness: 60, damping: 18 },
durationInFrames: 90,
});
// Must clamp before computing dashOffset.
// rawProgress > 1.0 produces a negative dashOffset, reversing the arc.
const progress = Math.min(1, Math.max(0, rawProgress));
const dashOffset = circumference * (1 - progress);
return (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
{/*
rotate(-90deg) on the wrapper shifts the arc start from 3 o'clock (SVG default)
to 12 o'clock. Rotating the <svg> element rather than an inner <g> keeps
the viewBox coordinate system intact for any child elements.
*/}
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={trackColor}
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={fillColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
</AbsoluteFill>
);
};
Three non-obvious details deserve emphasis.
The radius calculation. SVG strokes are centered on the path, extending strokeWidth / 2 inward and outward. If you use size / 2 as the radius, half the stroke falls outside the viewBox and gets clipped. Subtracting strokeWidth / 2 keeps the entire stroke inside.
Why rotate the <svg> element. SVG’s coordinate system places 0° at 3 o’clock. You could instead offset the stroke-dashoffset by circumference * 0.25, but this introduces a dependency between the rotation offset and the progress calculation. Rotating the SVG wrapper is self-contained and survives refactoring.
strokeLinecap="round" geometry. The rounded cap at the leading edge of the arc extends approximately strokeWidth / 2 pixels past the mathematical arc endpoint. For most progress indicators this is imperceptible. If you’re aligning the arc tip with a marker element and need pixel-accurate geometry, switch to "butt".
Composing the Full Scene
The progress-bar composition in this series demonstrates all three elements in sequence: an indeterminate spinner while loading, a smooth transition to a determinate fill, and a ring that tracks overall completion. Sequence manages the timing cleanly because it resets useCurrentFrame() to 0 for all descendants at its from point:
import { AbsoluteFill, Sequence, useVideoConfig } from 'remotion';
import { IndeterminateBar } from './IndeterminateBar';
import { DeterminateBar } from './DeterminateBar';
import { ProgressRing } from './ProgressRing';
export const ProgressBarComposition: React.FC = () => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill
style={{
backgroundColor: '#0f0f17',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: 48,
}}
>
{/* 0–2s: indeterminate phase */}
<Sequence durationInFrames={fps * 2}>
<IndeterminateBar />
</Sequence>
{/* 2s onward: determinate bar starts filling from 0 */}
<Sequence from={fps * 2}>
<DeterminateBar targetProgress={1} />
</Sequence>
{/* Ring runs from absolute frame 0 with softer spring — settles last */}
<ProgressRing targetProgress={1} size={120} strokeWidth={10} />
</AbsoluteFill>
);
};
The DeterminateBar inside <Sequence from={fps * 2}> sees frame 0 the moment it appears — its internal spring starts fresh with no manual delay offset. The ProgressRing, unsequenced, starts its stiffness: 60 spring from absolute frame 0 and settles around frame 90. This gives a natural “ring finishes last” cadence: the bar completes quickly, then the ring catches up.
Wrapping Up
Progress indicators in Remotion reward treating animation as geometry: compute the exact value at the exact frame, derive every visual property from that value, and let the rendering pipeline handle the rest. The key takeaways:
- Use
width: ...%overtransform: scaleX()for left-anchored bar fills.scaleXscales fromtransform-origin(center by default), growing the bar from both edges simultaneously. spring()can overshoot itstovalue. Clamp to[0, 1]before feeding the output into SVG dashoffset or any geometry that breaks with out-of-range values.durationInFramesinspring()is a settling hint. For exact frame-boundary completions, useinterpolate()with an explicit easing function.- The frame-modulo pattern (
frame % LOOP_FRAMES) is the idiomatic approach to looping in Remotion. UseMath.round()on the loop period to avoid fractional remainder drift. - Rotate the SVG wrapper element by
-90degto start the arc at 12 o’clock. SubtractstrokeWidth / 2from the radius to keep the stroke inside the viewBox.
These patterns compose directly into dashboard layouts, countdown timers, and onboarding flows — the kinds of motion-heavy components you’ll find throughout production video templates like the ones in the RenderComp catalog.
Ready to ship
Get 1,400+ Remotion Templates
Lifetime license. TypeScript-first. Ship polished video in minutes, not days.
Get RenderComp →