Countdown Timer Animation in Remotion: Build It from Scratch
Countdown Timer Animation in Remotion: Build It from Scratch
A countdown timer is one of the most universally recognized video elements. Live streams use it to build anticipation before going live. Product launch pages embed it above the fold to create urgency. Sports broadcasts overlay it during commercial breaks to keep viewers engaged. Cinematic title sequences use it as a visual hook.
Building a countdown timer in Remotion is a natural fit because Remotion’s entire animation model is built on an explicit, deterministic frame count — the exact same concept that underlies a countdown. This guide walks you through every component you need, from the simplest numeric display to a circular SVG progress ring and a flip clock animation pattern.
The Core Concept: Frames as Time
Before writing a single line of code, it helps to internalize the relationship between frames, seconds, and your countdown.
In Remotion, useCurrentFrame() returns the current frame number, starting at zero. useVideoConfig() returns durationInFrames — the total number of frames in the composition — along with fps, the frames per second.
From these two values, you can derive everything:
const frame = useCurrentFrame();
const { durationInFrames, fps } = useVideoConfig();
// Total duration in seconds
const totalSeconds = durationInFrames / fps;
// Elapsed seconds since the start
const elapsedSeconds = frame / fps;
// Remaining seconds (what the countdown shows)
const remainingSeconds = totalSeconds - elapsedSeconds;
// Integer seconds for display
const displaySeconds = Math.ceil(remainingSeconds);
// Fractional progress (0 = start, 1 = end)
const progress = frame / durationInFrames;
Math.ceil is used rather than Math.floor for displaySeconds because a countdown at 3.1 seconds remaining should still show “4” — it has not yet reached 3.
Basic Numeric Countdown
The simplest possible countdown: large numbers counting down from the total duration.
import { useCurrentFrame, useVideoConfig } from "remotion";
export const BasicCountdown: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames, fps } = useVideoConfig();
const remainingSeconds = (durationInFrames - frame) / fps;
const displaySeconds = Math.ceil(remainingSeconds);
return (
<div
style={{
width: 1920,
height: 1080,
background: "#0f172a",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}
>
<span
style={{
fontSize: 300,
fontWeight: 900,
color: "#f1f5f9",
fontVariantNumeric: "tabular-nums",
lineHeight: 1,
}}
>
{displaySeconds}
</span>
</div>
);
};
The fontVariantNumeric: "tabular-nums" style is important — it ensures all digit glyphs have the same advance width, preventing the number from shifting horizontally as it changes (which is visually jarring in a countdown).
Displaying Milliseconds
For high-precision timing (motorsports, esports, scientific contexts), you want millisecond display:
const totalFrames = durationInFrames;
const framesRemaining = totalFrames - frame;
const secondsRemaining = Math.floor(framesRemaining / fps);
const msRemaining = Math.floor((framesRemaining % fps) / fps * 1000);
// Format as MM:SS.mmm
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")}`;
At 30 fps, millisecond precision is limited to roughly 33ms per frame. At 60 fps, the granularity is approximately 17ms — noticeably finer. For racing broadcasts or esports overlays, 60 fps rendering is recommended for the most convincing millisecond display.
Circular Progress Ring
A circular SVG ring that depletes as the countdown progresses is one of the most visually compelling timer designs.
import { useCurrentFrame, useVideoConfig, interpolate } 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();
const progress = frame / durationInFrames; // 0 → 1 as video progresses
const strokeDashoffset = CIRCUMFERENCE * progress; // ring depletes
const remainingSeconds = Math.ceil((durationInFrames - frame) / fps);
return (
<div
style={{
width: 1920,
height: 1080,
background: "#0f172a",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}
>
<svg
width={500}
height={500}
viewBox="0 0 500 500"
style={{ transform: "rotate(-90deg)" }}
>
{/* Track ring */}
<circle
cx={250}
cy={250}
r={RADIUS}
fill="none"
stroke="rgba(255,255,255,0.1)"
strokeWidth={STROKE_WIDTH}
/>
{/* Active ring */}
<circle
cx={250}
cy={250}
r={RADIUS}
fill="none"
stroke="#6366f1"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={strokeDashoffset}
style={{ transition: "none" }}
/>
</svg>
{/* Number centered in the ring, rotated back */}
<div
style={{
position: "absolute",
fontSize: 160,
fontWeight: 900,
color: "#f1f5f9",
fontVariantNumeric: "tabular-nums",
}}
>
{remainingSeconds}
</div>
</div>
);
};
Key details:
- The SVG is rotated -90° so the ring starts depleting from the top (12 o’clock position) rather than the right (3 o’clock, which is the default SVG coordinate origin).
- The track ring is a faint background circle for visual reference.
strokeDashoffsetincreases from 0 toCIRCUMFERENCEas the countdown progresses, making the active segment shrink.
Adding Color Tension Near Zero
A powerful psychological effect is changing the ring color as the countdown approaches zero — green for plenty of time, yellow for caution, red for urgency:
import { interpolateColors } from "remotion";
const timeRatio = frame / durationInFrames; // 0 = start, 1 = end
const ringColor = interpolateColors(
timeRatio,
[0, 0.5, 0.75, 1],
["#22c55e", "#eab308", "#f97316", "#ef4444"]
);
interpolateColors smoothly transitions between hex color strings. The palette above moves from green (full time) through yellow (halfway) and orange (75% elapsed) to red (imminent zero). This color progression is immediately readable without any text explanation — viewers instinctively understand what the warming color means.
Flip Clock Animation Pattern
The flip clock is a nostalgic but compelling effect: individual digits housed in a card that appears to split in half and flip to reveal the next number.
The illusion is created by two half-card elements. The top half shows the current digit; the bottom half shows the next digit. When the second changes, an animated element (the “flipping front”) rotates from 0° to 90° (top half flipping down) and simultaneously a second animated element rotates from -90° to 0° (bottom half flipping up). In Remotion, you can drive this with interpolate and Easing.in/Easing.out:
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from "remotion";
const FlipDigit: React.FC<{ value: number; prevValue: number }> = ({
value,
prevValue,
}) => {
const frame = useCurrentFrame();
const FLIP_DURATION = 8; // frames
// Flip happens at frame 0 of this Sequence
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 cardStyle: React.CSSProperties = {
width: 160,
height: 100,
background: "#1e293b",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 96,
fontWeight: 900,
color: "#f1f5f9",
fontVariantNumeric: "tabular-nums",
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
overflow: "hidden",
};
return (
<div style={{ position: "relative", width: 160, height: 200, perspective: 400 }}>
{/* Static top half — shows new value */}
<div
style={{
...cardStyle,
position: "absolute",
top: 0,
clipPath: "inset(0 0 50% 0)",
}}
>
<span style={{ marginTop: 100 }}>{value}</span>
</div>
{/* Static bottom half — shows new value */}
<div
style={{
...cardStyle,
position: "absolute",
top: 0,
clipPath: "inset(50% 0 0 0)",
}}
>
<span style={{ marginTop: 100 }}>{value}</span>
</div>
{/* Animated top flap — shows prev value, rotates down */}
<div
style={{
...cardStyle,
position: "absolute",
top: 0,
clipPath: "inset(0 0 50% 0)",
transformOrigin: "bottom center",
transform: `rotateX(${topFlipAngle}deg)`,
backfaceVisibility: "hidden",
}}
>
<span style={{ marginTop: 100 }}>{prevValue}</span>
</div>
{/* Animated bottom flap — shows prev value, reveals from bottom */}
<div
style={{
...cardStyle,
position: "absolute",
top: 0,
clipPath: "inset(50% 0 0 0)",
transformOrigin: "top center",
transform: `rotateX(${bottomRevealAngle}deg)`,
backfaceVisibility: "hidden",
}}
>
<span style={{ marginTop: 100 }}>{prevValue}</span>
</div>
</div>
);
};
In production, wrap each digit in a <Sequence> that starts at the frame where that digit changes. The caller computes currentDigit and prevDigit from the remaining seconds and passes them as props.
Full Countdown Composition with Multiple Elements
Here is how to assemble a broadcast-ready countdown with a circular ring, a number, and a status line:
import {
useCurrentFrame,
useVideoConfig,
interpolateColors,
interpolate,
spring,
} from "remotion";
const RADIUS = 200;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
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 ringColor = interpolateColors(
progress,
[0, 0.5, 0.8, 1],
["#22c55e", "#eab308", "#f97316", "#ef4444"]
);
const strokeDashoffset = CIRCUMFERENCE * progress;
// Pulse effect in the last 5 seconds
const isLastFive = remainingSeconds <= 5;
const pulseFps = fps;
const pulseScale = isLastFive
? spring({
frame: frame % Math.round(pulseFps * 0.5),
fps,
config: { stiffness: 400, damping: 10 },
from: 1,
to: 1.04,
durationInFrames: Math.round(pulseFps * 0.5),
})
: 1;
return (
<div
style={{
width: 1920,
height: 1080,
background: "#0a0a0a",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 32,
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
color: "rgba(255,255,255,0.5)",
fontSize: 28,
letterSpacing: "0.2em",
textTransform: "uppercase",
}}
>
{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={RADIUS}
fill="none"
stroke="rgba(255,255,255,0.08)"
strokeWidth={12}
/>
<circle
cx={250}
cy={250}
r={RADIUS}
fill="none"
stroke={ringColor}
strokeWidth={12}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
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>
);
};
The pulse effect in the final five seconds uses a spring() with a very fast cycle driven by frame % cycleLength. This creates a repeating heartbeat-like scale animation that builds tension without being distracting.
Use Cases
Live stream pre-roll. A 60-second countdown that plays before a scheduled broadcast starts, keeping viewers engaged in the waiting room. The color progression signals how soon the stream begins.
Product launch. A countdown embedded in a pre-launch landing page video loop, counting down to a specific release date. Programmatically rendered from a target timestamp — the composition’s durationInFrames is calculated from the remaining seconds at render time.
Sports broadcast overlay. A penalty shootout or shot-clock countdown overlay rendered at 60 fps for smooth millisecond precision. The graphic is transparent-background and composited over live video in a broadcast graphics system.
Event opener. A cinematic countdown from 10 or 5 that precedes a conference keynote or product announcement video. Uses the flip clock pattern for a dramatic, high-energy feel.
Get a Head Start with RenderComp Templates
Countdown timers require careful coordination of SVG geometry, color interpolation, timing logic, and optional animation effects — there are many moving parts. RenderComp provides ready-to-use Remotion countdown timer templates in multiple styles: minimal numeric, circular ring, flip clock, and broadcast overlay. Each is fully parameterized and TypeScript-typed. Visit rendercomp.com to browse the template collection and ship your countdown in hours rather than days.
FAQ
Q1: How do I sync the countdown to a real-world deadline rather than the video duration?
Calculate the difference between the current UTC time and your target deadline at render time, convert to seconds, then set durationInFrames = remainingSeconds * fps when calling your Remotion render. The composition renders exactly as long as needed, and the durationInFrames / fps relationship inside the component automatically gives the correct countdown.
Q2: Can I display hours, minutes, and seconds simultaneously?
Yes. Compute each unit from framesRemaining:
const totalSecondsLeft = Math.ceil(framesRemaining / fps);
const hours = Math.floor(totalSecondsLeft / 3600);
const minutes = Math.floor((totalSecondsLeft % 3600) / 60);
const seconds = totalSecondsLeft % 60;
Display them as HH:MM:SS formatted strings with padStart(2, "0").
Q3: Why does my countdown show “0” for one extra frame at the end?
At frame durationInFrames - 1 (the last valid frame), Math.ceil((durationInFrames - frame) / fps) equals Math.ceil(1 / fps), which is 1. The display correctly shows “1”. At frame durationInFrames the composition has already ended — Remotion does not render past durationInFrames. If you are seeing an extra “0” frame, check that you are using Math.ceil rather than Math.floor, and verify your composition’s durationInFrames is set correctly.
Q4: How do I add a sound effect when the countdown reaches zero?
Use Remotion’s <Audio> component inside a <Sequence> that starts at durationInFrames - fps (one second before the end):
<Sequence from={durationInFrames - fps} durationInFrames={fps}>
<Audio src={staticFile("countdown-end.mp3")} />
</Sequence>
For per-second tick sounds, use a loop and place a <Sequence> at each second boundary.
Q5: How do I make the ring count up instead of count down?
Swap the direction of strokeDashoffset:
// Count down (ring depletes)
const strokeDashoffset = CIRCUMFERENCE * progress;
// Count up (ring fills)
const strokeDashoffset = CIRCUMFERENCE * (1 - progress);
Q6: Can I render a countdown timer as a transparent-background overlay for compositing?
Yes. Remotion supports transparent background rendering with the --png-sequence output (a sequence of PNG images with alpha channel) or by using a codec that supports transparency. For broadcast compositing workflows, export as a PNG sequence and import into your compositing software. Set the composition background to transparent (no background color) and ensure your transparent areas use rgba(0,0,0,0) not solid black.
Q7: What frame rate should I use for a countdown timer?
For most use cases, 30 fps is sufficient. For broadcast overlays, esports, or any context where millisecond-level accuracy matters visually, use 60 fps — it provides roughly twice the temporal resolution, making rapidly changing numbers and smooth ring animation noticeably more fluid.
Q8: How do I prevent the countdown number from causing layout shift when it changes from two digits to one digit (e.g., 10 → 9)?
Set a fixed width on the number container and use text-align: center. The fontVariantNumeric: "tabular-nums" style ensures each digit takes up the same horizontal space. For additional stability, set minWidth equal to the width of the widest expected value (e.g., two digits) so the container never resizes.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →