Remotion — Animated Bar Chart Race
A 10-second Remotion bar chart race following 8 fictional apps across three quarterly snapshots. Bars grow and re-rank in real time using spring physics for width transitions and interpolated Y positions for smooth vertical reordering, with value labels that update continuously and a crossfading period badge in the corner.
Preview
Code
import React from "react";
import {
AbsoluteFill,
Composition,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
// ── Palette & Config ──────────────────────────────────────────────────────────
const BG_COLOR = "#0a0a0f";
const COLORS: Record<string, string> = {
Zephyr: "#6366f1",
Novex: "#06b6d4",
Astrova: "#10b981",
Quilora: "#f59e0b",
Driftex: "#ef4444",
Veloris: "#8b5cf6",
Cambrix: "#38bdf8",
Orynth: "#f97316",
};
// Frames at which each snapshot becomes "active"
const SNAPSHOT_START = [0, 100, 200];
// Each transition takes this many frames
const TRANSITION_FRAMES = 70;
interface AppEntry {
name: string;
value: number; // monthly active users in millions
}
// ── Dataset: 3 time snapshots, 8 competing apps ───────────────────────────────
const SNAPSHOTS: { period: string; data: AppEntry[] }[] = [
{
period: "Q1 2022",
data: [
{ name: "Zephyr", value: 42 },
{ name: "Novex", value: 68 },
{ name: "Astrova", value: 31 },
{ name: "Quilora", value: 55 },
{ name: "Driftex", value: 77 },
{ name: "Veloris", value: 24 },
{ name: "Cambrix", value: 49 },
{ name: "Orynth", value: 36 },
],
},
{
period: "Q1 2023",
data: [
{ name: "Zephyr", value: 91 },
{ name: "Novex", value: 74 },
{ name: "Astrova", value: 58 },
{ name: "Quilora", value: 43 },
{ name: "Driftex", value: 82 },
{ name: "Veloris", value: 67 },
{ name: "Cambrix", value: 39 },
{ name: "Orynth", value: 71 },
],
},
{
period: "Q1 2024",
data: [
{ name: "Zephyr", value: 138 },
{ name: "Novex", value: 79 },
{ name: "Astrova", value: 114 },
{ name: "Quilora", value: 38 },
{ name: "Driftex", value: 96 },
{ name: "Veloris", value: 122 },
{ name: "Cambrix", value: 53 },
{ name: "Orynth", value: 107 },
],
},
];
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Returns the sorted rank-order array for a snapshot (desc by value) */
function sortedRanks(data: AppEntry[]): string[] {
return [...data].sort((a, b) => b.value - a.value).map((d) => d.name);
}
/** Get value for a name in a snapshot */
function getValue(data: AppEntry[], name: string): number {
return data.find((d) => d.name === name)?.value ?? 0;
}
/** Eased progress 0→1 for a snapshot transition */
function transitionProgress(
frame: number,
snapshotIndex: number
): number {
const start = SNAPSHOT_START[snapshotIndex];
const raw = (frame - start) / TRANSITION_FRAMES;
return Math.min(1, Math.max(0, raw));
}
/** Which two snapshots are we interpolating between at a given frame? */
function activePhase(frame: number): { from: number; to: number; t: number } {
if (frame < SNAPSHOT_START[1]) {
const t = transitionProgress(frame, 0);
return { from: 0, to: 0, t };
} else if (frame < SNAPSHOT_START[2]) {
const t = transitionProgress(frame, 1);
return { from: 0, to: 1, t };
} else {
const t = transitionProgress(frame, 2);
return { from: 1, to: 2, t };
}
}
// ── Sub-components ────────────────────────────────────────────────────────────
interface RacingBarProps {
name: string;
interpolatedValue: number;
maxValue: number;
rankY: number; // absolute Y in px for this bar's vertical slot
barWidth: number; // chart draw width in px
rowHeight: number;
frame: number;
fps: number;
entryDelay: number; // stagger delay for initial entrance
}
const RacingBar: React.FC<RacingBarProps> = ({
name,
interpolatedValue,
maxValue,
rankY,
barWidth,
rowHeight,
frame,
fps,
entryDelay,
}) => {
const color = COLORS[name] ?? "#ffffff";
// Entrance spring from left
const entranceF = Math.max(0, frame - entryDelay);
const widthFraction = spring({
frame: entranceF,
fps,
from: 0,
to: interpolatedValue / maxValue,
config: { damping: 18, stiffness: 80, mass: 0.8 },
});
const filledWidth = widthFraction * barWidth * 0.88;
// Value label count-up
const displayValue = Math.round(interpolatedValue);
// Badge opacity on entrance
const badgeOpacity = interpolate(entranceF, [0, 20], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const BAR_H = rowHeight * 0.52;
const BAR_OFFSET_Y = (rowHeight - BAR_H) / 2;
return (
<div
style={{
position: "absolute",
top: rankY + BAR_OFFSET_Y,
left: 0,
width: barWidth,
height: BAR_H,
display: "flex",
alignItems: "center",
// Smooth vertical repositioning via CSS transition would be ideal,
// but we handle it via interpolated rankY in the parent instead.
}}
>
{/* Bar fill */}
<div
style={{
width: filledWidth,
height: "100%",
borderRadius: "0 6px 6px 0",
background: `linear-gradient(90deg, ${color}cc 0%, ${color} 100%)`,
boxShadow: `0 0 18px ${color}55`,
position: "relative",
overflow: "visible",
flexShrink: 0,
}}
>
{/* Shine overlay */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "40%",
borderRadius: "0 6px 0 0",
background: "linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%)",
}}
/>
</div>
{/* Value label (right of bar) */}
<div
style={{
opacity: badgeOpacity,
marginLeft: 10,
fontFamily: "system-ui, -apple-system, sans-serif",
fontWeight: 700,
fontSize: Math.round(BAR_H * 0.52),
color: color,
whiteSpace: "nowrap",
letterSpacing: "-0.5px",
}}
>
{displayValue}M
</div>
{/* Name label (left of bar, outside chart) */}
<div
style={{
position: "absolute",
right: barWidth + 10,
top: "50%",
transform: "translateY(-50%)",
fontFamily: "system-ui, -apple-system, sans-serif",
fontWeight: 600,
fontSize: Math.round(BAR_H * 0.46),
color: "rgba(255,255,255,0.9)",
whiteSpace: "nowrap",
opacity: badgeOpacity,
textAlign: "right",
width: 110,
}}
>
{name}
</div>
{/* Color dot badge */}
<div
style={{
position: "absolute",
right: barWidth - 6,
top: "50%",
transform: "translateY(-50%)",
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: color,
boxShadow: `0 0 8px ${color}`,
opacity: badgeOpacity,
zIndex: 2,
}}
/>
</div>
);
};
// ── Period Label ──────────────────────────────────────────────────────────────
const PeriodLabel: React.FC<{ frame: number }> = ({ frame }) => {
const { from, to, t } = activePhase(frame);
const fromPeriod = SNAPSHOTS[from].period;
const toPeriod = SNAPSHOTS[to].period;
const switchT = interpolate(t, [0.4, 0.85], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const outOpacity = interpolate(switchT, [0, 0.5], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const inOpacity = interpolate(switchT, [0.5, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const outY = interpolate(switchT, [0, 0.5], [0, -14], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const inY = interpolate(switchT, [0.5, 1], [14, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const sharedStyle: React.CSSProperties = {
position: "absolute",
fontFamily: "system-ui, -apple-system, sans-serif",
fontWeight: 700,
fontSize: 42,
letterSpacing: "-1px",
color: "rgba(255,255,255,0.22)",
};
return (
<div style={{ position: "relative", height: 56, width: 240 }}>
<div
style={{
...sharedStyle,
opacity: outOpacity,
transform: `translateY(${outY}px)`,
}}
>
{fromPeriod}
</div>
<div
style={{
...sharedStyle,
opacity: inOpacity,
transform: `translateY(${inY}px)`,
}}
>
{toPeriod}
</div>
</div>
);
};
// ── Title ─────────────────────────────────────────────────────────────────────
const Title: React.FC<{ frame: number }> = ({ frame }) => {
const opacity = interpolate(frame, [0, 25], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const translateY = interpolate(frame, [0, 25], [-16, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<div style={{ opacity, transform: `translateY(${translateY}px)` }}>
<div
style={{
fontFamily: "system-ui, -apple-system, sans-serif",
fontWeight: 700,
fontSize: 28,
color: "#ffffff",
letterSpacing: "-0.5px",
lineHeight: 1,
}}
>
Top Apps by Monthly Active Users
</div>
<div
style={{
fontFamily: "system-ui, -apple-system, sans-serif",
fontWeight: 500,
fontSize: 15,
color: "rgba(255,255,255,0.45)",
marginTop: 6,
letterSpacing: "0.2px",
}}
>
Fictional streaming & productivity apps · millions of users
</div>
</div>
);
};
// ── Main Composition ──────────────────────────────────────────────────────────
export const BarRace: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// Layout constants
const PADDING_LEFT = 160; // space for name labels
const PADDING_RIGHT = 120;
const PADDING_TOP = 110;
const PADDING_BOTTOM = 70;
const chartWidth = width - PADDING_LEFT - PADDING_RIGHT;
const chartHeight = height - PADDING_TOP - PADDING_BOTTOM;
const NUM_BARS = SNAPSHOTS[0].data.length;
const ROW_HEIGHT = chartHeight / NUM_BARS;
// Determine which two snapshots to interpolate
const { from, to, t } = activePhase(frame);
const fromData = SNAPSHOTS[from].data;
const toData = SNAPSHOTS[to].data;
// spring-eased t for smooth cross-fade of values
const easedT = spring({
frame: Math.round(t * TRANSITION_FRAMES),
fps,
from: 0,
to: 1,
config: { damping: 20, stiffness: 90, mass: 1 },
});
// Interpolate each app's value between snapshots
const interpolatedValues: Record<string, number> = {};
SNAPSHOTS[0].data.forEach(({ name }) => {
const vFrom = getValue(fromData, name);
const vTo = getValue(toData, name);
interpolatedValues[name] = vFrom + (vTo - vFrom) * easedT;
});
// Determine current ranking
const sortedNames = Object.keys(interpolatedValues).sort(
(a, b) => interpolatedValues[b] - interpolatedValues[a]
);
const maxValue = Math.max(...Object.values(interpolatedValues));
// Build rank Y positions
const rankPositions: Record<string, number> = {};
sortedNames.forEach((name, i) => {
rankPositions[name] = i * ROW_HEIGHT;
});
// BG glow — shifts color subtly across snapshots
const glowOpacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill style={{ backgroundColor: BG_COLOR, overflow: "hidden" }}>
{/* Background radial glow */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 900,
height: 700,
transform: "translate(-50%, -50%)",
background:
"radial-gradient(ellipse at center, rgba(99,102,241,0.09) 0%, rgba(6,182,212,0.05) 40%, transparent 70%)",
opacity: glowOpacity,
pointerEvents: "none",
}}
/>
{/* Subtle grid lines */}
{[0.25, 0.5, 0.75, 1.0].map((frac) => {
const x = PADDING_LEFT + frac * chartWidth * 0.88;
const lineOpacity = interpolate(frame, [10, 30], [0, 0.08], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
key={frac}
style={{
position: "absolute",
left: x,
top: PADDING_TOP - 10,
width: 1,
height: chartHeight + 10,
backgroundColor: "rgba(255,255,255,0.9)",
opacity: lineOpacity,
}}
/>
);
})}
{/* Header */}
<div
style={{
position: "absolute",
top: 32,
left: PADDING_LEFT,
right: PADDING_RIGHT,
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<Title frame={frame} />
<PeriodLabel frame={frame} />
</div>
{/* Chart area */}
<div
style={{
position: "absolute",
top: PADDING_TOP,
left: PADDING_LEFT,
width: chartWidth,
height: chartHeight,
}}
>
{SNAPSHOTS[0].data.map(({ name }, i) => {
const entryDelay = 15 + i * 8;
return (
<RacingBar
key={name}
name={name}
interpolatedValue={interpolatedValues[name]}
maxValue={maxValue}
rankY={rankPositions[name]}
barWidth={chartWidth}
rowHeight={ROW_HEIGHT}
frame={frame}
fps={fps}
entryDelay={entryDelay}
/>
);
})}
</div>
{/* Bottom axis line */}
<div
style={{
position: "absolute",
left: PADDING_LEFT,
right: PADDING_RIGHT,
bottom: PADDING_BOTTOM - 4,
height: 1,
backgroundColor: "rgba(255,255,255,0.1)",
opacity: glowOpacity,
}}
/>
{/* Source watermark */}
<div
style={{
position: "absolute",
bottom: 22,
right: PADDING_RIGHT,
fontFamily: "system-ui, -apple-system, sans-serif",
fontWeight: 400,
fontSize: 12,
color: "rgba(255,255,255,0.2)",
opacity: glowOpacity,
letterSpacing: "0.5px",
}}
>
FICTIONAL DATA · STEALTHIS
</div>
</AbsoluteFill>
);
};
// ── Remotion Root ─────────────────────────────────────────────────────────────
export const RemotionRoot: React.FC = () => (
<Composition
id="BarRace"
component={BarRace}
durationInFrames={300}
fps={30}
width={1280}
height={720}
/>
);Animated Bar Chart Race
Eight fictional apps — Zephyr, Novex, Astrova, Quilora, Driftex, Veloris, Cambrix, and Orynth — compete for the top monthly-active-user rank across three quarterly snapshots (Q1 2022 → Q1 2023 → Q1 2024). Each snapshot transition spans 70 frames with a spring()-driven width animation so bars accelerate into their new lengths with natural physics rather than a linear tween. Vertical rank order updates continuously: the Y position of each bar is derived from the live interpolated value, so bars visibly overtake one another mid-transition.
Each bar renders a gradient fill with a subtle top-edge shine, a soft colored glow shadow, and an end-label that displays the current interpolated user count in millions. The period indicator in the top-right corner crossfades between labels using staggered opacity and translateY interpolations so the switch feels like a physical card flip. A radial indigo-to-cyan glow behind the chart and faint vertical grid lines at 25 % intervals add depth without cluttering the data.
The composition opens with a staggered entrance — bars slide in from the left with an 8-frame delay between each — so the ranking is revealed progressively rather than all at once. The title and subtitle fade and slide down from above in the first 25 frames, giving the viewer time to read the context before the race begins.
Composition specs
| Property | Value |
|---|---|
| Resolution | 1280 × 720 |
| FPS | 30 |
| Duration | 10 s (300 frames) |
Data format
All data is hardcoded as a SNAPSHOTS constant at the top of the file. Each snapshot has a period string and a data array of { name, value } entries where value is in millions of users:
const SNAPSHOTS = [
{
period: "Q1 2022",
data: [
{ name: "Zephyr", value: 42 },
{ name: "Novex", value: 68 },
// …
],
},
// Q1 2023, Q1 2024 …
];
To customise the race, replace the SNAPSHOTS array with your own periods and values, update the COLORS map to assign a hex colour to each entry name, and adjust SNAPSHOT_START frame offsets if you want longer or shorter holds between transitions. The TRANSITION_FRAMES constant controls how many frames each cross-fade takes (default 70).