StealThis .dev
Remotion Medium

Remotion — Animated Line Chart

A 6-second Remotion composition rendering two data series as animated SVG line charts. The polylines draw themselves left-to-right via stroke-dashoffset, data-point dots pop in with spring physics as the line reaches each node, and a semi-transparent area fill layers beneath each series for depth.

Open Remotion
remotion react typescript
Targets: TS React

Preview

Code

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

// ── Config ─────────────────────────────────────────────────────────────────
const BG_COLOR = "#0a0a0f";
const AXIS_COLOR = "rgba(255,255,255,0.12)";
const GRID_COLOR = "rgba(255,255,255,0.06)";
const CHART_TITLE = "Revenue vs. User Growth";
const CHART_SUBTITLE = "Veltrix SaaS — Jan–Dec 2025";

// Chart layout
const PAD = { top: 100, right: 120, bottom: 90, left: 80 };

// ── Data ───────────────────────────────────────────────────────────────────
interface DataPoint {
  month: string;
  revenue: number; // $K
  users: number;   // users (hundreds)
}

const DATA: DataPoint[] = [
  { month: "Jan", revenue: 41,  users: 12 },
  { month: "Feb", revenue: 58,  users: 19 },
  { month: "Mar", revenue: 53,  users: 23 },
  { month: "Apr", revenue: 72,  users: 31 },
  { month: "May", revenue: 88,  users: 38 },
  { month: "Jun", revenue: 81,  users: 44 },
  { month: "Jul", revenue: 95,  users: 52 },
  { month: "Aug", revenue: 107, users: 61 },
  { month: "Sep", revenue: 98,  users: 68 },
  { month: "Oct", revenue: 124, users: 77 },
  { month: "Nov", revenue: 139, users: 86 },
  { month: "Dec", revenue: 152, users: 94 },
];

interface Series {
  key: "revenue" | "users";
  label: string;
  unit: string;
  color: string;
  glowColor: string;
  fillColor: string;
}

const SERIES: Series[] = [
  {
    key: "revenue",
    label: "Revenue",
    unit: "$K",
    color: "#6366f1",
    glowColor: "rgba(99,102,241,0.5)",
    fillColor: "rgba(99,102,241,0.12)",
  },
  {
    key: "users",
    label: "Users (×100)",
    unit: "×100",
    color: "#06b6d4",
    glowColor: "rgba(6,182,212,0.5)",
    fillColor: "rgba(6,182,212,0.10)",
  },
];

const Y_TICKS = 5;

// ── Helpers ────────────────────────────────────────────────────────────────
function buildPoints(
  normalized: number[],
  chartW: number,
  chartH: number
): Array<[number, number]> {
  const n = normalized.length;
  return normalized.map((v, i) => [
    (i / (n - 1)) * chartW,
    chartH - v * chartH * 0.85,
  ]);
}

function pointsToPolyline(pts: Array<[number, number]>): string {
  return pts.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" ");
}

function buildAreaPath(
  pts: Array<[number, number]>,
  chartH: number
): string {
  if (pts.length === 0) return "";
  const poly = pts.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" L ");
  const first = pts[0];
  const last = pts[pts.length - 1];
  return `M ${first[0].toFixed(2)},${chartH} L ${poly} L ${last[0].toFixed(2)},${chartH} Z`;
}

// Total SVG polyline length estimate (straight-line segments)
function approxPolylineLength(pts: Array<[number, number]>): number {
  let len = 0;
  for (let i = 1; i < pts.length; i++) {
    const dx = pts[i][0] - pts[i - 1][0];
    const dy = pts[i][1] - pts[i - 1][1];
    len += Math.sqrt(dx * dx + dy * dy);
  }
  return len;
}

// ── Background glow ────────────────────────────────────────────────────────
const BgGlow: React.FC = () => (
  <>
    <div
      style={{
        position: "absolute",
        top: "30%",
        left: "25%",
        width: 700,
        height: 500,
        borderRadius: "50%",
        background: "radial-gradient(ellipse, rgba(99,102,241,0.07) 0%, transparent 70%)",
        pointerEvents: "none",
      }}
    />
    <div
      style={{
        position: "absolute",
        top: "40%",
        left: "55%",
        width: 500,
        height: 400,
        borderRadius: "50%",
        background: "radial-gradient(ellipse, rgba(6,182,212,0.06) 0%, transparent 70%)",
        pointerEvents: "none",
      }}
    />
  </>
);

// ── Title block ────────────────────────────────────────────────────────────
const TitleBlock: React.FC<{ frame: number }> = ({ frame }) => {
  const opacity = interpolate(frame, [0, 18], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.quad),
  });
  const translateY = interpolate(frame, [0, 18], [-12, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  return (
    <div
      style={{
        position: "absolute",
        top: 28,
        left: PAD.left,
        opacity,
        transform: `translateY(${translateY}px)`,
      }}
    >
      <div
        style={{
          fontFamily: "system-ui, -apple-system, sans-serif",
          fontWeight: 700,
          fontSize: 28,
          color: "#ffffff",
          letterSpacing: "-0.5px",
        }}
      >
        {CHART_TITLE}
      </div>
      <div
        style={{
          fontFamily: "system-ui, -apple-system, sans-serif",
          fontWeight: 500,
          fontSize: 14,
          color: "rgba(255,255,255,0.45)",
          marginTop: 4,
        }}
      >
        {CHART_SUBTITLE}
      </div>
    </div>
  );
};

// ── Legend ─────────────────────────────────────────────────────────────────
const Legend: React.FC<{ frame: number }> = ({ frame }) => {
  return (
    <div
      style={{
        position: "absolute",
        top: 32,
        right: PAD.right - 10,
        display: "flex",
        flexDirection: "column",
        gap: 10,
      }}
    >
      {SERIES.map((s, i) => {
        const delay = i * 10;
        const opacity = interpolate(frame, [delay, delay + 20], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
          easing: Easing.out(Easing.quad),
        });
        const translateX = interpolate(frame, [delay, delay + 20], [12, 0], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
          easing: Easing.out(Easing.cubic),
        });
        return (
          <div
            key={s.key}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 8,
              opacity,
              transform: `translateX(${translateX}px)`,
            }}
          >
            <div
              style={{
                width: 28,
                height: 3,
                borderRadius: 2,
                backgroundColor: s.color,
                boxShadow: `0 0 8px ${s.glowColor}`,
              }}
            />
            <div
              style={{
                width: 8,
                height: 8,
                borderRadius: "50%",
                backgroundColor: s.color,
                boxShadow: `0 0 6px ${s.glowColor}`,
              }}
            />
            <span
              style={{
                fontFamily: "system-ui, -apple-system, sans-serif",
                fontWeight: 600,
                fontSize: 13,
                color: "rgba(255,255,255,0.75)",
              }}
            >
              {s.label}
            </span>
          </div>
        );
      })}
    </div>
  );
};

// ── SVG Line Series ────────────────────────────────────────────────────────
interface LineSeriesProps {
  series: Series;
  points: Array<[number, number]>;
  chartH: number;
  drawProgress: number; // 0→1
  dotsProgress: number[]; // per-dot opacity 0→1
  isLast?: boolean;
  lastValue: number;
}

const LineSeries: React.FC<LineSeriesProps> = ({
  series,
  points,
  chartH,
  drawProgress,
  dotsProgress,
  isLast,
  lastValue,
}) => {
  const totalLen = approxPolylineLength(points);
  const dashOffset = totalLen * (1 - drawProgress);

  return (
    <g>
      {/* Area fill */}
      <path
        d={buildAreaPath(points, chartH)}
        fill={series.fillColor}
        opacity={drawProgress}
      />

      {/* Line stroke */}
      <polyline
        points={pointsToPolyline(points)}
        fill="none"
        stroke={series.color}
        strokeWidth={2.5}
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeDasharray={totalLen}
        strokeDashoffset={dashOffset}
        style={{ filter: `drop-shadow(0 0 6px ${series.glowColor})` }}
      />

      {/* Data point dots */}
      {points.map(([x, y], i) => {
        const op = dotsProgress[i] ?? 0;
        const r = 4 + op * 1.5;
        return (
          <g key={i} opacity={op}>
            <circle cx={x} cy={y} r={r + 3} fill={series.fillColor} />
            <circle
              cx={x}
              cy={y}
              r={r}
              fill={series.color}
              style={{ filter: `drop-shadow(0 0 5px ${series.glowColor})` }}
            />
            <circle cx={x} cy={y} r={r * 0.45} fill="white" opacity={0.9} />
          </g>
        );
      })}

      {/* Callout on last point */}
      {isLast && (() => {
        const [lx, ly] = points[points.length - 1];
        const calloutOp = dotsProgress[points.length - 1] ?? 0;
        return (
          <g opacity={calloutOp}>
            <line
              x1={lx}
              y1={ly - 8}
              x2={lx}
              y2={ly - 28}
              stroke={series.color}
              strokeWidth={1.5}
              strokeDasharray="3 3"
              opacity={0.7}
            />
            <rect
              x={lx - 28}
              y={ly - 52}
              width={56}
              height={22}
              rx={4}
              fill={series.color}
              opacity={0.9}
            />
            <text
              x={lx}
              y={ly - 37}
              textAnchor="middle"
              fill="white"
              fontFamily="system-ui, -apple-system, sans-serif"
              fontWeight={700}
              fontSize={12}
            >
              {lastValue}{series.unit}
            </text>
          </g>
        );
      })()}
    </g>
  );
};

// ── Main composition ────────────────────────────────────────────────────────
export const LineChart: React.FC = () => {
  const frame = useCurrentFrame();
  const { width, height } = useVideoConfig();

  const chartW = width - PAD.left - PAD.right;
  const chartH = height - PAD.top - PAD.bottom;

  // Axis + grid fade in (frames 0–20)
  const axisOpacity = interpolate(frame, [0, 20], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.quad),
  });
  const gridOpacity = interpolate(frame, [10, 30], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.quad),
  });

  // Line draw progress (frames 30–130) — series 1 leads by 8 frames
  const line0Draw = interpolate(frame, [30, 130], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });
  const line1Draw = interpolate(frame, [38, 138], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });

  // Precompute normalized points for each series
  const rev = DATA.map((d) => d.revenue);
  const usr = DATA.map((d) => d.users);

  // Use a shared max for both series so they share the same Y-axis scale
  const globalMax = Math.max(...rev, ...usr);
  const revPoints = buildPoints(
    rev.map((v) => v / globalMax),
    chartW,
    chartH
  );
  const usrPoints = buildPoints(
    usr.map((v) => v / globalMax),
    chartW,
    chartH
  );

  // Dots: each dot appears when the line has reached it (based on draw progress fraction)
  const n = DATA.length;
  function dotOpacities(lineStartFrame: number): number[] {
    return DATA.map((_, i) => {
      const dotThreshold = i / (n - 1);
      const dotFrame = lineStartFrame + dotThreshold * 100;
      return interpolate(frame, [dotFrame, dotFrame + 12], [0, 1], {
        extrapolateLeft: "clamp",
        extrapolateRight: "clamp",
        easing: Easing.out(Easing.back(1.5)),
      });
    });
  }

  const rev0Dots = dotOpacities(30);
  const usr0Dots = dotOpacities(38);

  // Y-axis tick labels
  const yTicks = Array.from({ length: Y_TICKS + 1 }, (_, i) => i / Y_TICKS);
  const tickMaxValue = globalMax;

  // X-axis labels fade in staggered
  const xLabelOpacity = interpolate(frame, [20, 45], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill style={{ backgroundColor: BG_COLOR }}>
      <BgGlow />
      <TitleBlock frame={frame} />
      <Legend frame={frame} />

      {/* Chart SVG */}
      <svg
        style={{ position: "absolute", top: PAD.top, left: PAD.left }}
        width={chartW}
        height={chartH}
        overflow="visible"
      >
        {/* Grid lines */}
        {yTicks.map((t, i) => {
          const y = chartH - t * chartH * 0.85;
          return (
            <line
              key={i}
              x1={0}
              y1={y}
              x2={chartW}
              y2={y}
              stroke={GRID_COLOR}
              strokeWidth={1}
              opacity={gridOpacity}
            />
          );
        })}

        {/* Y-axis */}
        <line
          x1={0}
          y1={0}
          x2={0}
          y2={chartH}
          stroke={AXIS_COLOR}
          strokeWidth={1}
          opacity={axisOpacity}
        />

        {/* X-axis */}
        <line
          x1={0}
          y1={chartH}
          x2={chartW}
          y2={chartH}
          stroke={AXIS_COLOR}
          strokeWidth={1}
          opacity={axisOpacity}
        />

        {/* Y-axis tick labels */}
        {yTicks.map((t, i) => {
          const y = chartH - t * chartH * 0.85;
          const value = Math.round(t * tickMaxValue);
          return (
            <text
              key={i}
              x={-10}
              y={y + 4}
              textAnchor="end"
              fill="rgba(255,255,255,0.3)"
              fontFamily="system-ui, -apple-system, sans-serif"
              fontSize={11}
              opacity={axisOpacity}
            >
              {value}
            </text>
          );
        })}

        {/* X-axis month labels */}
        {DATA.map((d, i) => {
          const x = (i / (n - 1)) * chartW;
          return (
            <text
              key={i}
              x={x}
              y={chartH + 22}
              textAnchor="middle"
              fill="rgba(255,255,255,0.4)"
              fontFamily="system-ui, -apple-system, sans-serif"
              fontSize={12}
              fontWeight={500}
              opacity={xLabelOpacity}
            >
              {d.month}
            </text>
          );
        })}

        {/* Revenue series */}
        <LineSeries
          series={SERIES[0]}
          points={revPoints}
          chartH={chartH}
          drawProgress={line0Draw}
          dotsProgress={rev0Dots}
          isLast={true}
          lastValue={DATA[DATA.length - 1].revenue}
        />

        {/* Users series */}
        <LineSeries
          series={SERIES[1]}
          points={usrPoints}
          chartH={chartH}
          drawProgress={line1Draw}
          dotsProgress={usr0Dots}
          isLast={false}
          lastValue={DATA[DATA.length - 1].users}
        />
      </svg>

      {/* Bottom tagline */}
      {(() => {
        const tagOp = interpolate(frame, [150, 170], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });
        return (
          <div
            style={{
              position: "absolute",
              bottom: 20,
              right: PAD.right,
              fontFamily: "system-ui, -apple-system, sans-serif",
              fontSize: 11,
              fontWeight: 500,
              color: "rgba(255,255,255,0.2)",
              opacity: tagOp,
              letterSpacing: "0.08em",
              textTransform: "uppercase",
            }}
          >
            veltrix.io · annual overview
          </div>
        );
      })()}
    </AbsoluteFill>
  );
};

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

Animated Line Chart

Two data series — monthly revenue and user growth for a fictional SaaS product — animate across a shared Y-axis over 6 seconds. Axis lines and horizontal gridlines fade in first, establishing the grid. Then each polyline draws itself from left to right using a stroke-dashoffset animation driven by interpolate() with a cubic ease-in-out, staggered 8 frames apart so the lines feel like they chase each other across the chart.

Each data-point dot is revealed independently the moment the drawing line passes its X position, using a spring with a back-overshoot so dots feel like they snap into place. The final data point on the revenue series shows a callout label that fades up from below. A legend with color swatches and series names slides in from the right at the opening frames. The entire composition closes with a subtle brand tagline that fades in at frame 150.

Color and glow are intentional: indigo (#6366f1) for revenue and cyan (#06b6d4) for users give the two series maximum visual contrast on a near-black background. Radial background glows centered on each series’ home quadrant reinforce this spatial separation without adding visual noise.

Composition specs

PropertyValue
Resolution1280 × 720
FPS30
Duration6 s (180 frames)

Data format

All data lives in the DATA constant at the top of the file — an array of DataPoint objects:

interface DataPoint {
  month: string;  // X-axis label
  revenue: number; // first series value (rendered in $K)
  users: number;   // second series value (rendered ×100)
}

Both series share the same Y-axis scale (derived from Math.max(...revenue, ...users)), so they are directly comparable. To swap in different metrics, update the DATA array and adjust CHART_TITLE, CHART_SUBTITLE, and the SERIES config objects (label, unit, color) near the top of the file.