StealThis .dev
Remotion Medium

Remotion — Pie Chart Reveal

Animate a donut chart in Remotion where six revenue segments sweep into view one by one with spring physics, percentage labels pop in after each segment finishes, a counting total builds in the center hole, and a slide-in legend ties the whole composition together on a dark cinematic background.

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 CHART_TITLE = "Revenue by Product Line";
const CHART_SUBTITLE = "Fiscal Year 2025  ·  Total $4.82M";
const STAGGER = 15; // frames between each segment reveal
const DONUT_OUTER_R = 210;
const DONUT_INNER_R = 110;
const SVG_SIZE = 500;
const CX = SVG_SIZE / 2;
const CY = SVG_SIZE / 2;

// ── Data ──────────────────────────────────────────────────────────────────
interface SliceDatum {
  label: string;
  value: number; // dollars in thousands
  color: string;
}

const DATA: SliceDatum[] = [
  { label: "CloudCore",    value: 1380, color: "#6366f1" },
  { label: "DataBridge",   value:  960, color: "#06b6d4" },
  { label: "EdgeSync",     value:  740, color: "#10b981" },
  { label: "VaultAPI",     value:  620, color: "#f59e0b" },
  { label: "NovaSuite",    value:  720, color: "#8b5cf6" },
  { label: "StreamKit",    value:  400, color: "#ef4444" },
];

const TOTAL = DATA.reduce((s, d) => s + d.value, 0);

// Precompute start/end angles (in degrees, 0 = top, clockwise)
interface SliceAngles {
  startDeg: number;
  endDeg: number;
  midDeg: number;
  pct: number;
}

const sliceAngles: SliceAngles[] = (() => {
  let cursor = -90; // start at 12-o'clock
  return DATA.map((d) => {
    const sweep = (d.value / TOTAL) * 360;
    const startDeg = cursor;
    const endDeg = cursor + sweep;
    const midDeg = cursor + sweep / 2;
    cursor = endDeg;
    return { startDeg, endDeg, midDeg, pct: d.value / TOTAL };
  });
})();

// ── Helpers ───────────────────────────────────────────────────────────────
const toRad = (deg: number) => (deg * Math.PI) / 180;

const polarToXY = (cx: number, cy: number, r: number, deg: number) => ({
  x: cx + r * Math.cos(toRad(deg)),
  y: cy + r * Math.sin(toRad(deg)),
});

/** SVG arc path for a donut slice, drawn from startDeg to startDeg + sweepDeg */
const arcPath = (
  cx: number,
  cy: number,
  outerR: number,
  innerR: number,
  startDeg: number,
  sweepDeg: number
): string => {
  if (sweepDeg <= 0) return "";
  const clampedSweep = Math.min(sweepDeg, 359.9999);
  const endDeg = startDeg + clampedSweep;
  const largeArc = clampedSweep > 180 ? 1 : 0;

  const o1 = polarToXY(cx, cy, outerR, startDeg);
  const o2 = polarToXY(cx, cy, outerR, endDeg);
  const i1 = polarToXY(cx, cy, innerR, endDeg);
  const i2 = polarToXY(cx, cy, innerR, startDeg);

  return [
    `M ${o1.x} ${o1.y}`,
    `A ${outerR} ${outerR} 0 ${largeArc} 1 ${o2.x} ${o2.y}`,
    `L ${i1.x} ${i1.y}`,
    `A ${innerR} ${innerR} 0 ${largeArc} 0 ${i2.x} ${i2.y}`,
    "Z",
  ].join(" ");
};

// ── Segment component ─────────────────────────────────────────────────────
interface SegmentProps {
  datum: SliceDatum;
  angles: SliceAngles;
  index: number;
  frame: number;
  fps: number;
}

const Segment: React.FC<SegmentProps> = ({ datum, angles, index, frame, fps }) => {
  const delay = 20 + index * STAGGER;
  const f = Math.max(0, frame - delay);

  // Spring-driven sweep from 0 → full slice angle
  const fullSweep = angles.endDeg - angles.startDeg;

  const progress = spring({
    frame: f,
    fps,
    from: 0,
    to: 1,
    config: { damping: 18, stiffness: 120, mass: 0.6 },
  });

  const sweepDeg = progress * fullSweep;

  // Percentage label appears after segment is mostly drawn
  const labelDelay = delay + 18;
  const labelOpacity = interpolate(frame, [labelDelay, labelDelay + 12], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.quad),
  });
  const labelScale = interpolate(frame, [labelDelay, labelDelay + 12], [0.5, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.back(1.8)),
  });

  // Position percentage label at midpoint, slightly outside ring
  const labelR = DONUT_OUTER_R + 30;
  const labelPos = polarToXY(CX, CY, labelR, angles.midDeg);

  const pctText = `${Math.round(angles.pct * 100)}%`;

  return (
    <g>
      {/* Glow behind slice */}
      <path
        d={arcPath(CX, CY, DONUT_OUTER_R + 8, DONUT_INNER_R - 8, angles.startDeg, sweepDeg)}
        fill={datum.color}
        opacity={0.18}
        filter="url(#sliceGlow)"
      />

      {/* Main donut slice */}
      <path
        d={arcPath(CX, CY, DONUT_OUTER_R, DONUT_INNER_R, angles.startDeg, sweepDeg)}
        fill={datum.color}
        opacity={0.95}
      />

      {/* Percentage label */}
      {sweepDeg > 10 && (
        <text
          x={labelPos.x}
          y={labelPos.y}
          textAnchor="middle"
          dominantBaseline="middle"
          fill={datum.color}
          fontSize={15}
          fontWeight={700}
          fontFamily="system-ui, -apple-system, sans-serif"
          opacity={labelOpacity}
          style={{ transform: `scale(${labelScale})`, transformOrigin: `${labelPos.x}px ${labelPos.y}px` }}
        >
          {pctText}
        </text>
      )}
    </g>
  );
};

// ── Center label (counting total) ────────────────────────────────────────
interface CenterLabelProps {
  frame: number;
  fps: number;
}

const CenterLabel: React.FC<CenterLabelProps> = ({ frame, fps }) => {
  // Appears when first segment starts, counts up over the reveal window
  const startFrame = 20;
  const endFrame = 20 + DATA.length * STAGGER + 30;
  const f = Math.max(0, frame - startFrame);

  const countProgress = interpolate(frame, [startFrame, endFrame], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  const displayValue = (countProgress * TOTAL) / 1000; // in millions
  const opacity = spring({
    frame: f,
    fps,
    from: 0,
    to: 1,
    config: { damping: 20, stiffness: 130 },
  });

  return (
    <g opacity={opacity}>
      <text
        x={CX}
        y={CY - 14}
        textAnchor="middle"
        dominantBaseline="middle"
        fill="white"
        fontSize={36}
        fontWeight={700}
        fontFamily="system-ui, -apple-system, sans-serif"
      >
        ${displayValue.toFixed(2)}M
      </text>
      <text
        x={CX}
        y={CY + 18}
        textAnchor="middle"
        dominantBaseline="middle"
        fill="rgba(255,255,255,0.45)"
        fontSize={13}
        fontWeight={500}
        fontFamily="system-ui, -apple-system, sans-serif"
        letterSpacing={1}
      >
        TOTAL REVENUE
      </text>
    </g>
  );
};

// ── Legend ─────────────────────────────────────────────────────────────────
interface LegendProps {
  frame: number;
  fps: number;
}

const Legend: React.FC<LegendProps> = ({ frame, fps }) => {
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
        gap: 18,
        paddingLeft: 32,
      }}
    >
      {DATA.map((d, i) => {
        const delay = 30 + i * STAGGER;
        const f = Math.max(0, frame - delay);

        const slideX = interpolate(f, [0, 20], [-28, 0], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
          easing: Easing.out(Easing.cubic),
        });
        const opacity = interpolate(f, [0, 18], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });

        const valueOpacity = interpolate(frame, [delay + 20, delay + 35], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });
        const pctDisplay = `${Math.round((d.value / TOTAL) * 100)}%`;
        const valDisplay = `$${(d.value / 1000).toFixed(2)}M`;

        return (
          <div
            key={d.label}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 12,
              opacity,
              transform: `translateX(${slideX}px)`,
            }}
          >
            {/* Color dot */}
            <div
              style={{
                width: 12,
                height: 12,
                borderRadius: "50%",
                backgroundColor: d.color,
                flexShrink: 0,
                boxShadow: `0 0 8px ${d.color}`,
              }}
            />

            {/* Name */}
            <span
              style={{
                fontFamily: "system-ui, -apple-system, sans-serif",
                fontWeight: 600,
                fontSize: 15,
                color: "rgba(255,255,255,0.9)",
                minWidth: 100,
              }}
            >
              {d.label}
            </span>

            {/* Value + pct */}
            <span
              style={{
                fontFamily: "system-ui, -apple-system, sans-serif",
                fontWeight: 500,
                fontSize: 13,
                color: d.color,
                opacity: valueOpacity,
                letterSpacing: 0.5,
              }}
            >
              {valDisplay} · {pctDisplay}
            </span>
          </div>
        );
      })}
    </div>
  );
};

// ── Main composition ──────────────────────────────────────────────────────
export const PieReveal: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Title fade-in
  const titleOpacity = interpolate(frame, [0, 18], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const titleY = interpolate(frame, [0, 18], [-12, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.quad),
  });

  // Subtitle
  const subtitleOpacity = interpolate(frame, [8, 26], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill style={{ backgroundColor: BG_COLOR, overflow: "hidden" }}>

      {/* Ambient background glow */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: "38%",
          width: 680,
          height: 680,
          transform: "translate(-50%, -50%)",
          background:
            "radial-gradient(ellipse, rgba(99,102,241,0.10) 0%, rgba(139,92,246,0.06) 40%, transparent 70%)",
          pointerEvents: "none",
        }}
      />
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: "72%",
          width: 400,
          height: 400,
          transform: "translate(-50%, -50%)",
          background:
            "radial-gradient(ellipse, rgba(6,182,212,0.07) 0%, transparent 65%)",
          pointerEvents: "none",
        }}
      />

      {/* Header */}
      <div
        style={{
          position: "absolute",
          top: 44,
          left: 64,
          right: 64,
        }}
      >
        <div
          style={{
            opacity: titleOpacity,
            transform: `translateY(${titleY}px)`,
            fontFamily: "system-ui, -apple-system, sans-serif",
            fontWeight: 700,
            fontSize: 30,
            color: "#ffffff",
            letterSpacing: -0.5,
          }}
        >
          {CHART_TITLE}
        </div>
        <div
          style={{
            opacity: subtitleOpacity,
            fontFamily: "system-ui, -apple-system, sans-serif",
            fontWeight: 500,
            fontSize: 14,
            color: "rgba(255,255,255,0.40)",
            marginTop: 4,
            letterSpacing: 0.3,
          }}
        >
          {CHART_SUBTITLE}
        </div>
      </div>

      {/* Body: chart + legend side by side */}
      <div
        style={{
          position: "absolute",
          top: 120,
          left: 0,
          right: 0,
          bottom: 40,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        {/* Donut SVG */}
        <svg
          width={SVG_SIZE}
          height={SVG_SIZE}
          viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
          style={{ flexShrink: 0 }}
        >
          <defs>
            <filter id="sliceGlow" x="-40%" y="-40%" width="180%" height="180%">
              <feGaussianBlur stdDeviation="10" result="blur" />
              <feMerge>
                <feMergeNode in="blur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
          </defs>

          {/* Track ring (subtle) */}
          <circle
            cx={CX}
            cy={CY}
            r={(DONUT_OUTER_R + DONUT_INNER_R) / 2}
            fill="none"
            stroke="rgba(255,255,255,0.04)"
            strokeWidth={DONUT_OUTER_R - DONUT_INNER_R}
          />

          {/* Segments */}
          {DATA.map((d, i) => (
            <Segment
              key={d.label}
              datum={d}
              angles={sliceAngles[i]}
              index={i}
              frame={frame}
              fps={fps}
            />
          ))}

          {/* Center label */}
          <CenterLabel frame={frame} fps={fps} />
        </svg>

        {/* Legend */}
        <Legend frame={frame} fps={fps} />
      </div>

      {/* Bottom divider line */}
      <div
        style={{
          position: "absolute",
          bottom: 36,
          left: 64,
          right: 64,
          height: 1,
          background: "rgba(255,255,255,0.06)",
        }}
      />
    </AbsoluteFill>
  );
};

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

Pie Chart Reveal

A 5-second Remotion composition that builds a donut chart segment by segment. Six fictional product lines — CloudCore, DataBridge, EdgeSync, VaultAPI, NovaSuite, and StreamKit — are each assigned a vibrant distinct color (indigo, cyan, emerald, amber, violet, rose). Every segment sweeps clockwise from its starting angle using a spring-driven arc, staggered 15 frames apart so the eye can follow each reveal.

The hollow center holds a live counter that increments in sync with the segment reveals, landing on the formatted total revenue figure once all slices are visible. Percentage labels burst out just beyond the arc edge with a spring overshoot after their parent segment finishes drawing. On the right, a legend slides in from the left — one row per product line, each showing the label, formatted dollar value, and percentage share — arriving in the same staggered order as the segments.

The composition uses a deep #0a0a0f cinema background with two layered radial glows (indigo/violet behind the chart, cyan behind the legend area) to give the dark canvas visual depth without distracting from the data.

Composition specs

PropertyValue
Resolution1280 × 720
FPS30
Duration5 s (150 frames)

Data format

Each slice is defined by a SliceDatum object at the top of the file. Swap in your own categories, values, and hex colors:

interface SliceDatum {
  label: string;  // display name shown in legend
  value: number;  // raw numeric value (same unit across all slices)
  color: string;  // hex color for the segment, label, and legend dot
}

Change STAGGER (frames between each segment) to speed up or slow down the cascade. DONUT_OUTER_R and DONUT_INNER_R control the ring thickness. The center label denominator (/ 1000) and format string ($…M) should match your unit convention.