StealThis .dev
Remotion Medium

Remotion — Animated Area Chart

A cinematic animated area chart rendered with Remotion, tracing 12 weeks of fictional revenue data for Vaultex Commerce. The stroke line draws across the canvas first, then a gradient fill sweeps in behind it, followed by staggered dot markers, a glowing callout badge with a live count-up value at the latest data point, and a bottom summary row of four key metrics — all driven by spring physics and interpolated easing.

Open Remotion
remotion react typescript
Targets: TS React

Preview

Code

import React from "react";
import {
  AbsoluteFill,
  Composition,
  Easing,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";

// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------

interface DataPoint {
  week: string;
  value: number;
}

const DATA: DataPoint[] = [
  { week: "W1", value: 41200 },
  { week: "W2", value: 53800 },
  { week: "W3", value: 48600 },
  { week: "W4", value: 67300 },
  { week: "W5", value: 72100 },
  { week: "W6", value: 61500 },
  { week: "W7", value: 84900 },
  { week: "W8", value: 79400 },
  { week: "W9", value: 93200 },
  { week: "W10", value: 88700 },
  { week: "W11", value: 107500 },
  { week: "W12", value: 124800 },
];

const COMPANY = "Vaultex Commerce";
const PERIOD = "Q1 2026 · Weekly Revenue";

// ---------------------------------------------------------------------------
// Design tokens
// ---------------------------------------------------------------------------

const BG_COLOR = "#0a0a0f";
const ACCENT = "#6366f1";
const ACCENT_SECONDARY = "#8b5cf6";
const CYAN = "#06b6d4";
const WHITE = "#ffffff";
const MUTED = "rgba(255,255,255,0.45)";

const CHART_LEFT = 90;
const CHART_RIGHT = 1160;
const CHART_TOP = 160;
const CHART_BOTTOM = 580;
const CHART_W = CHART_RIGHT - CHART_LEFT;
const CHART_H = CHART_BOTTOM - CHART_TOP;

const GRID_LINES = 5;
const GRADIENT_ID = "areaGradient";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function formatK(v: number): string {
  if (v >= 1000) return `$${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}k`;
  return `$${v}`;
}

function clamp(v: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, v));
}

function mapValue(v: number, min: number, max: number): number {
  // y axis: 0 at bottom (CHART_BOTTOM), max at top (CHART_TOP)
  return CHART_BOTTOM - ((v - min) / (max - min)) * CHART_H;
}

function xForIndex(i: number, total: number): number {
  return CHART_LEFT + (i / (total - 1)) * CHART_W;
}

// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------

const GridLines: React.FC<{ revealProgress: number }> = ({ revealProgress }) => {
  const minVal = 0;
  const maxVal = 140000;
  const step = maxVal / GRID_LINES;

  return (
    <>
      {Array.from({ length: GRID_LINES + 1 }, (_, i) => {
        const v = i * step;
        const y = mapValue(v, minVal, maxVal);
        const opacity = interpolate(revealProgress, [i * 0.12, i * 0.12 + 0.25], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
          easing: Easing.out(Easing.quad),
        });
        return (
          <React.Fragment key={i}>
            {/* Gridline */}
            <line
              x1={CHART_LEFT}
              y1={y}
              x2={CHART_RIGHT}
              y2={y}
              stroke="rgba(255,255,255,0.06)"
              strokeWidth={1}
              strokeDasharray="4 8"
              opacity={opacity}
            />
            {/* Y-axis label */}
            <text
              x={CHART_LEFT - 14}
              y={y + 5}
              textAnchor="end"
              fontFamily="system-ui, -apple-system, sans-serif"
              fontWeight={500}
              fontSize={13}
              fill="rgba(255,255,255,0.35)"
              opacity={opacity}
            >
              {formatK(v)}
            </text>
          </React.Fragment>
        );
      })}
    </>
  );
};

const XAxisLabels: React.FC<{ revealProgress: number }> = ({ revealProgress }) => {
  const total = DATA.length;
  return (
    <>
      {DATA.map((d, i) => {
        const x = xForIndex(i, total);
        const opacity = interpolate(revealProgress, [i / total, i / total + 0.15], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });
        return (
          <text
            key={i}
            x={x}
            y={CHART_BOTTOM + 26}
            textAnchor="middle"
            fontFamily="system-ui, -apple-system, sans-serif"
            fontWeight={500}
            fontSize={13}
            fill="rgba(255,255,255,0.35)"
            opacity={opacity}
          >
            {d.week}
          </text>
        );
      })}
    </>
  );
};

// ---------------------------------------------------------------------------
// Main composition
// ---------------------------------------------------------------------------

export const AreaChart: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const total = DATA.length;
  const minVal = 0;
  const maxVal = 140000;

  // --- Phase timing ---
  // 0–25  : title fade in
  // 10–50 : grid lines + axis labels reveal
  // 30–110: line stroke draw (clip-rect slides right)
  // 60–130: area fill reveal (clip-rect slides right, slightly offset)
  // 80–160: dot markers stagger in
  // 120–170: callout badge slides in
  // 130–180: value label of last point counts up

  // Title
  const titleOpacity = interpolate(frame, [0, 22], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.quad),
  });
  const titleY = spring({ frame, fps, from: -28, to: 0, config: { damping: 14, stiffness: 90 } });

  const subtitleOpacity = interpolate(frame, [14, 36], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // Grid reveal progress (0→1)
  const gridReveal = interpolate(frame, [10, 55], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Line stroke draw: clipRect x2 sweeps from CHART_LEFT to CHART_RIGHT
  const lineProgress = interpolate(frame, [30, 110], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });
  const lineClipX2 = CHART_LEFT + lineProgress * CHART_W;

  // Area fill reveal (slightly delayed behind line)
  const fillProgress = interpolate(frame, [58, 138], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });
  const fillClipX2 = CHART_LEFT + fillProgress * CHART_W;

  // Build SVG path points
  const points: { x: number; y: number }[] = DATA.map((d, i) => ({
    x: xForIndex(i, total),
    y: mapValue(d.value, minVal, maxVal),
  }));

  // Smooth line using cubic bezier via catmull-rom tangents
  function buildSmoothPath(pts: { x: number; y: number }[]): string {
    if (pts.length === 0) return "";
    let d = `M ${pts[0].x},${pts[0].y}`;
    for (let i = 0; i < pts.length - 1; i++) {
      const p0 = pts[Math.max(0, i - 1)];
      const p1 = pts[i];
      const p2 = pts[i + 1];
      const p3 = pts[Math.min(pts.length - 1, i + 2)];
      const cp1x = p1.x + (p2.x - p0.x) / 6;
      const cp1y = p1.y + (p2.y - p0.y) / 6;
      const cp2x = p2.x - (p3.x - p1.x) / 6;
      const cp2y = p2.y - (p3.y - p1.y) / 6;
      d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
    }
    return d;
  }

  const linePath = buildSmoothPath(points);

  // Area path = line path + close down to baseline
  const lastPt = points[points.length - 1];
  const firstPt = points[0];
  const areaPath = `${linePath} L ${lastPt.x},${CHART_BOTTOM} L ${firstPt.x},${CHART_BOTTOM} Z`;

  // Dot markers (staggered spring entrance)
  const dotMarkers = points.map((pt, i) => {
    const dotDelay = 80 + i * 6;
    const f = Math.max(0, frame - dotDelay);
    const scale = spring({ frame: f, fps, from: 0, to: 1, config: { damping: 10, stiffness: 160 } });
    const opacity = interpolate(f, [0, 8], [0, 1], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
    });
    return { pt, scale, opacity };
  });

  // Callout badge (last point)
  const calloutDelay = 118;
  const calloutF = Math.max(0, frame - calloutDelay);
  const calloutScale = spring({
    frame: calloutF,
    fps,
    from: 0.5,
    to: 1,
    config: { damping: 11, stiffness: 130 },
  });
  const calloutOpacity = interpolate(calloutF, [0, 12], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const calloutX = lastPt.x;
  const calloutY = lastPt.y;

  // Animated "current value" count-up at callout
  const countStart = 128;
  const countProgress = interpolate(frame, [countStart, countStart + 44], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
  const displayValue = Math.round(countProgress * DATA[DATA.length - 1].value);

  // Period label reveal
  const periodOpacity = interpolate(frame, [20, 42], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const periodX = interpolate(frame, [20, 46], [-20, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.quad),
  });

  // Background glow
  const glowOpacity = interpolate(frame, [0, 50], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  return (
    <AbsoluteFill style={{ backgroundColor: BG_COLOR, overflow: "hidden" }}>
      {/* Background radial glow */}
      <div
        style={{
          position: "absolute",
          left: "40%",
          top: "55%",
          width: 760,
          height: 400,
          borderRadius: "50%",
          background: `radial-gradient(ellipse, ${ACCENT}18 0%, transparent 68%)`,
          transform: "translate(-50%, -50%)",
          opacity: glowOpacity,
          pointerEvents: "none",
        }}
      />
      <div
        style={{
          position: "absolute",
          left: "75%",
          top: "30%",
          width: 300,
          height: 200,
          borderRadius: "50%",
          background: `radial-gradient(ellipse, ${CYAN}0f 0%, transparent 70%)`,
          transform: "translate(-50%, -50%)",
          opacity: glowOpacity * 0.7,
          pointerEvents: "none",
        }}
      />

      {/* Header */}
      <div
        style={{
          position: "absolute",
          top: 52,
          left: CHART_LEFT,
          right: 60,
          display: "flex",
          flexDirection: "row",
          alignItems: "flex-end",
          justifyContent: "space-between",
          opacity: titleOpacity,
          transform: `translateY(${titleY}px)`,
        }}
      >
        <div>
          <div
            style={{
              fontFamily: "system-ui, -apple-system, sans-serif",
              fontWeight: 700,
              fontSize: 28,
              color: WHITE,
              letterSpacing: -0.5,
            }}
          >
            {COMPANY}
          </div>
          <div
            style={{
              fontFamily: "system-ui, -apple-system, sans-serif",
              fontWeight: 500,
              fontSize: 15,
              color: MUTED,
              marginTop: 4,
              opacity: subtitleOpacity,
            }}
          >
            Weekly Revenue
          </div>
        </div>

        {/* Period tag */}
        <div
          style={{
            fontFamily: "system-ui, -apple-system, sans-serif",
            fontWeight: 600,
            fontSize: 13,
            color: ACCENT,
            backgroundColor: `${ACCENT}18`,
            border: `1px solid ${ACCENT}40`,
            borderRadius: 8,
            padding: "5px 14px",
            letterSpacing: 0.3,
            opacity: periodOpacity,
            transform: `translateX(${periodX}px)`,
          }}
        >
          {PERIOD}
        </div>
      </div>

      {/* SVG chart */}
      <svg
        width={1280}
        height={720}
        style={{ position: "absolute", top: 0, left: 0 }}
      >
        <defs>
          <linearGradient id={GRADIENT_ID} x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor={ACCENT} stopOpacity={0.42} />
            <stop offset="55%" stopColor={ACCENT_SECONDARY} stopOpacity={0.14} />
            <stop offset="100%" stopColor={ACCENT} stopOpacity={0} />
          </linearGradient>

          {/* Clip rect for line stroke draw */}
          <clipPath id="lineClip">
            <rect
              x={CHART_LEFT - 2}
              y={CHART_TOP - 20}
              width={Math.max(0, lineClipX2 - CHART_LEFT + 4)}
              height={CHART_H + 60}
            />
          </clipPath>

          {/* Clip rect for fill reveal */}
          <clipPath id="fillClip">
            <rect
              x={CHART_LEFT - 2}
              y={CHART_TOP - 20}
              width={Math.max(0, fillClipX2 - CHART_LEFT + 4)}
              height={CHART_H + 80}
            />
          </clipPath>
        </defs>

        {/* Grid lines + axis labels */}
        <GridLines revealProgress={gridReveal} />
        <XAxisLabels revealProgress={gridReveal} />

        {/* Axes */}
        <line
          x1={CHART_LEFT}
          y1={CHART_TOP - 10}
          x2={CHART_LEFT}
          y2={CHART_BOTTOM}
          stroke="rgba(255,255,255,0.12)"
          strokeWidth={1}
          opacity={gridReveal}
        />
        <line
          x1={CHART_LEFT}
          y1={CHART_BOTTOM}
          x2={CHART_RIGHT}
          y2={CHART_BOTTOM}
          stroke="rgba(255,255,255,0.12)"
          strokeWidth={1}
          opacity={gridReveal}
        />

        {/* Area fill — revealed with clip rect */}
        <path
          d={areaPath}
          fill={`url(#${GRADIENT_ID})`}
          clipPath="url(#fillClip)"
        />

        {/* Stroke line — drawn first with its own clip rect */}
        <path
          d={linePath}
          fill="none"
          stroke={`url(#lineStroke)`}
          strokeWidth={3}
          strokeLinecap="round"
          strokeLinejoin="round"
          clipPath="url(#lineClip)"
        />

        {/* Stroke gradient definition */}
        <defs>
          <linearGradient id="lineStroke" x1="0" y1="0" x2="1" y2="0">
            <stop offset="0%" stopColor={ACCENT} />
            <stop offset="60%" stopColor={ACCENT_SECONDARY} />
            <stop offset="100%" stopColor={CYAN} />
          </linearGradient>
        </defs>

        {/* Dot markers */}
        {dotMarkers.map(({ pt, scale, opacity }, i) => (
          <g key={i} opacity={opacity} transform={`translate(${pt.x}, ${pt.y})`}>
            {/* Outer ring */}
            <circle
              r={8 * scale}
              fill={`${ACCENT}22`}
              stroke={i === total - 1 ? CYAN : ACCENT}
              strokeWidth={2}
            />
            {/* Inner dot */}
            <circle
              r={4 * scale}
              fill={i === total - 1 ? CYAN : ACCENT_SECONDARY}
            />
          </g>
        ))}

        {/* Callout badge at last point */}
        <g
          opacity={calloutOpacity}
          transform={`translate(${calloutX}, ${calloutY}) scale(${calloutScale})`}
        >
          {/* Arrow stem */}
          <line
            x1={0}
            y1={-10}
            x2={0}
            y2={-44}
            stroke={CYAN}
            strokeWidth={1.5}
            strokeDasharray="3 3"
          />
          {/* Badge background */}
          <rect
            x={-62}
            y={-88}
            width={124}
            height={40}
            rx={10}
            fill="#0e1020"
            stroke={CYAN}
            strokeWidth={1.5}
          />
          {/* Badge inner glow */}
          <rect
            x={-60}
            y={-86}
            width={120}
            height={36}
            rx={9}
            fill={`${CYAN}12`}
          />
          {/* Value text */}
          <text
            x={0}
            y={-61}
            textAnchor="middle"
            fontFamily="system-ui, -apple-system, sans-serif"
            fontWeight={700}
            fontSize={18}
            fill={WHITE}
          >
            {`$${displayValue.toLocaleString()}`}
          </text>
          {/* Label */}
          <text
            x={0}
            y={-100}
            textAnchor="middle"
            fontFamily="system-ui, -apple-system, sans-serif"
            fontWeight={500}
            fontSize={12}
            fill={CYAN}
            letterSpacing={1}
          >
            CURRENT WEEK
          </text>
        </g>

        {/* Vertical hover line at last point (subtle) */}
        <line
          x1={calloutX}
          y1={CHART_TOP}
          x2={calloutX}
          y2={CHART_BOTTOM}
          stroke={CYAN}
          strokeWidth={1}
          strokeDasharray="4 6"
          opacity={calloutOpacity * 0.35}
        />
      </svg>

      {/* Bottom summary row */}
      <div
        style={{
          position: "absolute",
          bottom: 36,
          left: CHART_LEFT,
          display: "flex",
          flexDirection: "row",
          gap: 32,
          opacity: interpolate(frame, [140, 165], [0, 1], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
          }),
        }}
      >
        {[
          { label: "Peak week", value: "$124.8k", color: CYAN },
          { label: "Average", value: "$76.9k", color: ACCENT },
          { label: "Growth", value: "+203%", color: "#10b981" },
          { label: "Total Q1", value: "$922.5k", color: "#f59e0b" },
        ].map((stat, i) => {
          const statDelay = i * 8;
          const statOpacity = interpolate(
            frame,
            [140 + statDelay, 165 + statDelay],
            [0, 1],
            { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
          );
          const statY = spring({
            frame: Math.max(0, frame - 140 - statDelay),
            fps,
            from: 14,
            to: 0,
            config: { damping: 14, stiffness: 120 },
          });
          return (
            <div
              key={i}
              style={{
                opacity: statOpacity,
                transform: `translateY(${statY}px)`,
              }}
            >
              <div
                style={{
                  fontFamily: "system-ui, -apple-system, sans-serif",
                  fontWeight: 700,
                  fontSize: 20,
                  color: stat.color,
                  letterSpacing: -0.3,
                }}
              >
                {stat.value}
              </div>
              <div
                style={{
                  fontFamily: "system-ui, -apple-system, sans-serif",
                  fontWeight: 500,
                  fontSize: 12,
                  color: MUTED,
                  marginTop: 2,
                  textTransform: "uppercase",
                  letterSpacing: 0.8,
                }}
              >
                {stat.label}
              </div>
            </div>
          );
        })}
      </div>
    </AbsoluteFill>
  );
};

export const RemotionRoot: React.FC = () => (
  <Composition
    id="AreaChart"
    component={AreaChart}
    durationInFrames={180}
    fps={30}
    width={1280}
    height={720}
  />
);

Animated Area Chart

A Remotion composition that visualizes weekly revenue data as a smooth area chart with cinematic sequencing. The stroke line is drawn first using a horizontal clip-rect that sweeps from left to right over 80 frames, tracing a catmull-rom cubic-bezier path through 12 data points. A split second later, a second clip-rect follows and reveals the gradient fill beneath — transitioning from indigo at 42% opacity at the crest down to fully transparent at the baseline.

Twelve dot markers spring in one by one along the finished line, each bouncing into place with configurable damping. The final data point receives a special callout badge: a cyan-outlined pill connected to the dot by a dashed stem, displaying the current week’s revenue as a count-up number that animates from zero to $124,800. A subtle dashed vertical rule highlights the position of that final point. The composition closes with four summary statistics — peak week, average, quarter-over-quarter growth, and total Q1 revenue — sliding up from the bottom in a staggered spring sequence.

The dark background (#0a0a0f) pairs with a soft indigo radial glow centered on the chart body and a secondary cyan accent near the top-right, giving the composition the feel of a live dashboard rather than a static slide. Grid lines and axis labels fade in alongside the title header to orient the viewer before the data motion begins.

Composition specs

PropertyValue
Resolution1280 × 720
FPS30
Duration6 s (180 frames)

Data format

The chart reads from the DATA constant at the top of the file — an array of { week: string; value: number } objects. Swap in any 6–12 data points and adjust maxVal in the layout constants to match your data ceiling. The summary stats row at the bottom is also hardcoded; update those four objects (label, value, color) to reflect the derived numbers from your dataset. The COMPANY and PERIOD strings at the top control the header copy.