Wellness Stat Animation (Remotion)
A 5-second vertical 1080x1920 Remotion stat card for healthcare teams — features a large teal counter that counts up from 0 to 94% over 90 frames, a synchronized SVG arc progress ring, a subtitle that fades in at frame 60, ambient radial glow and floating particles on a deep clinic dark background, and a branded survey attribution footer in muted teal.
Preview
Code
import React from "react";
import {
AbsoluteFill,
Composition,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Sequence,
} from "remotion";
// ─── Customizable constants ──────────────────────────────────────────────────
const CLINIC_NAME = "Greenfield Medical Center";
const STAT_VALUE = 94; // integer 1–100
const STAT_LABEL = "of patients reported improved health";
const STAT_SUBLABEL = "after 3 months of care";
const ATTRIBUTION = `${CLINIC_NAME} · 2025 Patient Satisfaction Survey`;
const DURATION_FRAMES = 150;
// Color palette
const BG = "#0a1a18";
const TEAL = "#12b5a8";
const TEAL_SOFT = "#e7f5f3";
const WHITE = "#ffffff";
const CORAL = "#ff7a66";
const MUTED = "#6b9e99";
// Ring geometry
const RING_RADIUS = 210;
const RING_STROKE = 14;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
// ─── Spring config helpers ────────────────────────────────────────────────────
const SPRING_STD = { damping: 14, stiffness: 120 };
const clamp = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const };
// ─── Ambient background particles ────────────────────────────────────────────
const PARTICLES: [number, number, number, number, number][] = [
[5, 10, 3, 0.9, 0],
[18, 72, 2, 1.2, 7],
[28, 45, 4, 0.7, 3],
[40, 88, 2, 1.4, 15],
[55, 20, 3, 1.0, 5],
[66, 62, 5, 0.6, 12],
[74, 7, 2, 1.1, 20],
[82, 80, 3, 0.85, 2],
[90, 50, 4, 1.3, 9],
[93, 30, 2, 0.95, 18],
[12, 56, 3, 1.05, 22],
[48, 94, 2, 1.15, 11],
];
const AmbientParticle: React.FC<{
xPct: number; yPct: number; size: number; speed: number; phase: number; frame: number;
}> = ({ xPct, yPct, size, speed, phase, frame }) => {
const f = frame + phase * 4;
const floatY = Math.sin((f * 0.03 * speed) % (Math.PI * 2)) * 14;
const floatX = Math.cos((f * 0.02 * speed) % (Math.PI * 2)) * 8;
const opacity =
interpolate(frame, [0, 20], [0, 0.3], clamp) *
interpolate(frame, [130, 150], [1, 0], clamp);
const color = (xPct + yPct) % 3 === 0 ? CORAL : TEAL;
return (
<div
style={{
position: "absolute",
left: `${xPct}%`,
top: `${yPct}%`,
transform: `translate(${floatX}px, ${floatY}px)`,
width: size,
height: size,
borderRadius: "50%",
background: color,
opacity,
boxShadow: `0 0 ${size * 4}px ${color}`,
}}
/>
);
};
// ─── Ambient glow orb ────────────────────────────────────────────────────────
const GlowOrb: React.FC<{
x: number; y: number; radius: number; color: string; frame: number; startFrame: number;
}> = ({ x, y, radius, color, frame, startFrame }) => {
const { fps } = useVideoConfig();
const prog = spring({ frame: frame - startFrame, fps, config: { damping: 20, stiffness: 40 } });
const pulse = 1 + Math.sin((frame * 0.035) % (Math.PI * 2)) * 0.05;
return (
<div
style={{
position: "absolute",
left: x - radius * pulse,
top: y - radius * pulse,
width: radius * 2 * pulse,
height: radius * 2 * pulse,
borderRadius: "50%",
background: `radial-gradient(circle, ${color}2e 0%, transparent 68%)`,
opacity: prog * 0.85,
pointerEvents: "none",
}}
/>
);
};
// ─── Clinic brand mark ────────────────────────────────────────────────────────
const BrandMark: React.FC<{ frame: number }> = ({ frame }) => {
const { fps } = useVideoConfig();
const prog = spring({ frame: frame - 2, fps, config: SPRING_STD });
const opacity = interpolate(prog, [0, 1], [0, 1]);
const y = interpolate(prog, [0, 1], [-18, 0]);
return (
<div
style={{
position: "absolute",
top: 76,
left: 0,
right: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
transform: `translateY(${y}px)`,
opacity,
}}
>
{/* Medical cross */}
<div style={{ position: "relative", width: 44, height: 44 }}>
<div
style={{
position: "absolute",
top: "38%",
left: 0,
right: 0,
height: "24%",
background: TEAL,
borderRadius: 4,
boxShadow: `0 0 12px ${TEAL}88`,
}}
/>
<div
style={{
position: "absolute",
left: "38%",
top: 0,
bottom: 0,
width: "24%",
background: TEAL,
borderRadius: 4,
boxShadow: `0 0 12px ${TEAL}88`,
}}
/>
</div>
<div
style={{
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif",
fontSize: 22,
fontWeight: 700,
letterSpacing: "0.06em",
color: TEAL_SOFT,
textTransform: "uppercase",
}}
>
{CLINIC_NAME}
</div>
</div>
);
};
// ─── SVG progress ring ────────────────────────────────────────────────────────
const ProgressRing: React.FC<{ frame: number }> = ({ frame }) => {
// Raw progress 0 → STAT_VALUE over frames [0, 90]
const rawPct = interpolate(frame, [0, 90], [0, STAT_VALUE], clamp);
// strokeDashoffset: 0 = full ring, CIRCUMFERENCE = empty ring
const dashOffset = RING_CIRCUMFERENCE * (1 - rawPct / 100);
// Track + arc fade in over first 10 frames
const ringOpacity = interpolate(frame, [5, 20], [0, 1], clamp);
const svgSize = (RING_RADIUS + RING_STROKE) * 2 + 20;
const center = svgSize / 2;
return (
<div
style={{
position: "absolute",
top: 220,
left: "50%",
transform: "translateX(-50%)",
opacity: ringOpacity,
}}
>
<svg
width={svgSize}
height={svgSize}
viewBox={`0 0 ${svgSize} ${svgSize}`}
style={{ overflow: "visible" }}
>
{/* Glow filter */}
<defs>
<filter id="ringGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="8" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Background track */}
<circle
cx={center}
cy={center}
r={RING_RADIUS}
fill="none"
stroke={`${TEAL}1a`}
strokeWidth={RING_STROKE}
/>
{/* Tick marks at 25% increments */}
{[0, 25, 50, 75].map((pct) => {
const angle = (pct / 100) * 360 - 90;
const rad = (angle * Math.PI) / 180;
const outerR = RING_RADIUS + RING_STROKE / 2 + 8;
const innerR = RING_RADIUS - RING_STROKE / 2 - 8;
return (
<line
key={pct}
x1={center + innerR * Math.cos(rad)}
y1={center + innerR * Math.sin(rad)}
x2={center + outerR * Math.cos(rad)}
y2={center + outerR * Math.sin(rad)}
stroke={`${TEAL}44`}
strokeWidth={2}
strokeLinecap="round"
/>
);
})}
{/* Progress arc */}
<circle
cx={center}
cy={center}
r={RING_RADIUS}
fill="none"
stroke={TEAL}
strokeWidth={RING_STROKE}
strokeLinecap="round"
strokeDasharray={RING_CIRCUMFERENCE}
strokeDashoffset={dashOffset}
transform={`rotate(-90, ${center}, ${center})`}
filter="url(#ringGlow)"
/>
{/* Bright leading dot at arc tip */}
{rawPct > 1 && (() => {
const tipAngle = ((rawPct / 100) * 360 - 90) * (Math.PI / 180);
return (
<circle
cx={center + RING_RADIUS * Math.cos(tipAngle)}
cy={center + RING_RADIUS * Math.sin(tipAngle)}
r={RING_STROKE / 2 + 2}
fill={TEAL}
filter="url(#ringGlow)"
/>
);
})()}
</svg>
</div>
);
};
// ─── Animated counter ────────────────────────────────────────────────────────
const StatCounter: React.FC<{ frame: number }> = ({ frame }) => {
const displayed = Math.round(interpolate(frame, [0, 90], [0, STAT_VALUE], clamp));
const fadeIn = interpolate(frame, [8, 22], [0, 1], clamp);
return (
<div
style={{
position: "absolute",
top: 220,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
// Ring SVG is (RING_RADIUS + RING_STROKE) * 2 + 20 tall; center the number inside it
height: (RING_RADIUS + RING_STROKE) * 2 + 20,
opacity: fadeIn,
}}
>
<div
style={{
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif",
fontSize: 128,
fontWeight: 800,
color: TEAL,
letterSpacing: "-0.04em",
textShadow: `0 0 80px ${TEAL}88, 0 0 30px ${TEAL}55`,
lineHeight: 1,
userSelect: "none",
}}
>
{displayed}
<span
style={{
fontSize: 64,
fontWeight: 700,
letterSpacing: "-0.02em",
marginLeft: 4,
}}
>
%
</span>
</div>
</div>
);
};
// ─── Subtitle lines ───────────────────────────────────────────────────────────
const SubtitleLine: React.FC<{
text: string;
startFrame: number;
frame: number;
fontSize?: number;
color?: string;
}> = ({ text, startFrame, frame, fontSize = 38, color = WHITE }) => {
const { fps } = useVideoConfig();
const prog = spring({ frame: frame - startFrame, fps, config: SPRING_STD });
const opacity = interpolate(prog, [0, 1], [0, 1]);
const y = interpolate(prog, [0, 1], [22, 0]);
return (
<div
style={{
transform: `translateY(${y}px)`,
opacity,
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif",
fontSize,
fontWeight: 500,
color,
textAlign: "center",
lineHeight: 1.3,
letterSpacing: "0.01em",
}}
>
{text}
</div>
);
};
// ─── Decorative divider rule ──────────────────────────────────────────────────
const DividerRule: React.FC<{ frame: number; startFrame: number }> = ({ frame, startFrame }) => {
const { fps } = useVideoConfig();
const prog = spring({ frame: frame - startFrame, fps, config: SPRING_STD });
const scaleX = interpolate(prog, [0, 1], [0, 1]);
return (
<div
style={{
marginTop: 12,
height: 2,
width: "60%",
background: `linear-gradient(90deg, transparent, ${TEAL}88, transparent)`,
borderRadius: 1,
transform: `scaleX(${scaleX})`,
}}
/>
);
};
// ─── Subtitle block (label + sublabel) ───────────────────────────────────────
const SubtitleBlock: React.FC<{ frame: number }> = ({ frame }) => {
// Ring SVG top=220, height=(RING_RADIUS+RING_STROKE)*2+20 = (210+14)*2+20 = 468
// So ring bottom = 220 + 468 = 688; add 44px gap
const blockTop = 220 + (RING_RADIUS + RING_STROKE) * 2 + 20 + 44;
return (
<div
style={{
position: "absolute",
top: blockTop,
left: 60,
right: 60,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
}}
>
<SubtitleLine
text={STAT_LABEL}
startFrame={60}
frame={frame}
fontSize={42}
color={TEAL_SOFT}
/>
<SubtitleLine
text={STAT_SUBLABEL}
startFrame={64}
frame={frame}
fontSize={32}
color={MUTED}
/>
{/* Decorative rule under subtitle */}
<DividerRule frame={frame} startFrame={68} />
</div>
);
};
// ─── Attribution footer ───────────────────────────────────────────────────────
const AttributionFooter: React.FC<{ frame: number }> = ({ frame }) => {
const { fps } = useVideoConfig();
const prog = spring({ frame: frame - 80, fps, config: SPRING_STD });
const opacity = interpolate(prog, [0, 1], [0, 1]);
const y = interpolate(prog, [0, 1], [14, 0]);
return (
<div
style={{
position: "absolute",
bottom: 88,
left: 48,
right: 48,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
transform: `translateY(${y}px)`,
opacity,
}}
>
{/* Small teal pill bar */}
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 10,
paddingLeft: 20,
paddingRight: 20,
paddingTop: 10,
paddingBottom: 10,
borderRadius: 40,
background: `${TEAL}14`,
border: `1.5px solid ${TEAL}33`,
}}
>
{/* Small chart-bar icon */}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="2" y="12" width="3" height="6" rx="1" fill={MUTED} />
<rect x="7" y="7" width="3" height="11" rx="1" fill={TEAL} />
<rect x="12" y="4" width="3" height="14" rx="1" fill={TEAL} />
<rect x="17" y="9" width="3" height="9" rx="1" fill={MUTED} />
</svg>
<span
style={{
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif",
fontSize: 24,
fontWeight: 500,
color: MUTED,
letterSpacing: "0.03em",
textAlign: "center",
}}
>
{ATTRIBUTION}
</span>
</div>
</div>
);
};
// ─── Main composition ─────────────────────────────────────────────────────────
export const WellnessStat: React.FC = () => {
const frame = useCurrentFrame();
const fadeIn = interpolate(frame, [0, 10], [0, 1], clamp);
const fadeOut = interpolate(frame, [135, 150], [1, 0], clamp);
return (
<AbsoluteFill
style={{
background: BG,
overflow: "hidden",
opacity: fadeIn * fadeOut,
fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif",
}}
>
{/* Teal glow centred on the stat ring */}
<GlowOrb x={540} y={500} radius={460} color={TEAL} frame={frame} startFrame={0} />
<GlowOrb x={180} y={1600} radius={340} color={CORAL} frame={frame} startFrame={6} />
{/* Ambient particles */}
{PARTICLES.map(([xPct, yPct, size, speed, phase], i) => (
<AmbientParticle
key={i}
xPct={xPct}
yPct={yPct}
size={size}
speed={speed}
phase={phase}
frame={frame}
/>
))}
{/* Edge/top vignette */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 15%, transparent 78%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
{/* Clinic brand mark */}
<Sequence from={0} durationInFrames={DURATION_FRAMES}>
<BrandMark frame={frame} />
</Sequence>
{/* SVG progress ring */}
<Sequence from={5} durationInFrames={DURATION_FRAMES - 5}>
<ProgressRing frame={frame} />
</Sequence>
{/* Animated counter inside ring */}
<Sequence from={8} durationInFrames={DURATION_FRAMES - 8}>
<StatCounter frame={frame} />
</Sequence>
{/* Subtitle label + sublabel */}
<Sequence from={56} durationInFrames={DURATION_FRAMES - 56}>
<SubtitleBlock frame={frame} />
</Sequence>
{/* Attribution footer */}
<Sequence from={76} durationInFrames={DURATION_FRAMES - 76}>
<AttributionFooter frame={frame} />
</Sequence>
{/* Radial edge vignette */}
<div
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(ellipse at 50% 50%, transparent 55%, rgba(0,0,0,0.38) 100%)",
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};
// ─── Remotion Root ────────────────────────────────────────────────────────────
export const RemotionRoot: React.FC = () => (
<Composition
id="WellnessStat"
component={WellnessStat}
durationInFrames={DURATION_FRAMES}
fps={30}
width={1080}
height={1920}
/>
);Wellness Stat Animation
A single-metric wellness stat card built entirely in Remotion with zero external assets. The scene opens on a deep #0a1a18 clinic background layered with two large ambient radial glow orbs and a field of twelve floating dot particles that drift on independent sine paths, establishing a calm, authoritative mood. The clinic cross mark and name fade in from above during the first 12 frames, anchoring the brand before the main metric appears.
The centrepiece animation starts at frame 10: a large teal number counts up from 0% to 94% using Math.round(interpolate(frame, [0, 90], [0, 94])), rendered at 120 px with a soft teal text-shadow. Wrapping it is a circular SVG progress ring — a <circle> with strokeDashoffset driven by the same interpolation — that fills clockwise from empty to 94 % in perfect sync with the counter. At frame 60 the subtitle line “of patients reported improved health” fades up with a spring entrance, and the supporting context line “after 3 months of care” arrives four frames later. A branded survey attribution footer — “Greenfield Medical Center · 2025 Patient Satisfaction Survey” — fades in at the very bottom from frame 80 onward, completing the visual hierarchy.
All text content, target percentage, color tokens, and animation timings are declared as named constants at the top of react.tsx. Swap STAT_VALUE to any integer 1–100 and the ring, counter, and ring track all update automatically. The spring configs SPRING_STD (damping 14, stiffness 120) and the clamp shorthand are shared with the rest of the Remotion clinic library so pacing stays consistent across scenes.
Composition specs
| Property | Value |
|---|---|
| Resolution | 1080 × 1920 |
| FPS | 30 |
| Duration | 5.0 s (150 frames) |
Timeline
| Time | Frames | Action |
|---|---|---|
| 0 s – 0.4 s | 0 – 12 | Fade-in; ambient glow orbs and particles appear |
| 0 s – 0.4 s | 0 – 12 | Clinic brand mark (cross + name) fades down from above |
| 0.3 s – 3.0 s | 10 – 90 | Counter counts up 0 → 94 %; SVG arc ring fills simultaneously |
| 2.0 s | 60 | Subtitle “of patients reported improved health” springs up |
| 2.1 s | 64 | Supporting line “after 3 months of care” springs up |
| 2.7 s | 80 | Survey attribution footer fades in at bottom |
| 4.5 s – 5.0 s | 135 – 150 | Global fade-out to black |
Customization
STAT_VALUE— change the headline percentage (integer 1–100); ring and counter both updateSTAT_LABEL— main subtitle below the number (e.g."patient satisfaction rate")STAT_SUBLABEL— supporting context line (e.g."across all specialties")ATTRIBUTION— footer survey/source credit lineCLINIC_NAME— facility name shown in the brand mark and footerSPRING_STD— tweakdamping/stiffnessto make subtitle entrances snappier or softerRING_RADIUS— adjust SVG circle radius to resize the progress ring independently- Particle count/density — add or remove rows in the
PARTICLESarray
Illustrative UI only — not intended for real medical use.