Pie Chart

A pie / donut chart that sweeps each slice in clockwise, with a legend and an optional total in the center.

Preview

Open editor

Usage

Set donut to true for a donut chart with the total rendered in the middle, or false for a classic pie. Slices animate in clockwise with a small stagger.

Props

NameTypeDefault
titlestring"Traffic sources"
captionstring"Last 30 days"
labelsstring"Organic, Direct, Referral, Social, Paid"
valuesstring"42, 24, 14, 12, 8"
donutbooleantrue
showLegendbooleantrue

Composition

ID
PieChart
Resolution
1920×1080
FPS
60
Duration
3.3s

Source

Copy or download the React source — drop it into your own Remotion project. The only runtime dependency is remotion.

"use client";
import { AbsoluteFill } from "remotion";
import { type ClipStyle, resolveClipStyle } from "../../clip-style";
import { useDesignFrame } from "../../use-design-frame";
import {
  CHART_PALETTE,
  chartReveal,
  parseLabels,
  parseSeriesString,
} from "../_chart-shared";

export type PieChartProps = {
  title: string;
  caption: string;
  labels: string;
  values: string;
  donut: boolean;
  showLegend: boolean;
  clipStyle?: ClipStyle;
};

export const PieChart: React.FC<PieChartProps> = ({
  title,
  caption,
  labels,
  values,
  donut,
  showLegend,
  clipStyle,
}) => {
  const frame = useDesignFrame();
  const s = resolveClipStyle(clipStyle, {
    background: "#000000",
    color: "#ffffff",
    fontFamily:
      "-apple-system, BlinkMacSystemFont, 'SF Pro Display', Inter, sans-serif",
    accent: CHART_PALETTE[0]!,
  });

  const data = parseSeriesString(values);
  const lbls = parseLabels(labels);
  const total = data.reduce((a, b) => a + b, 0) || 1;

  const headerProgress = chartReveal(frame, 0, 18);
  const wheelProgress = chartReveal(frame, 18, 80);

  const cx = 0;
  const cy = 0;
  const rOuter = 230;
  const rInner = donut ? 130 : 0;

  let cursor = -Math.PI / 2;
  const slices = data.map((v, i) => {
    const angle = (v / total) * Math.PI * 2;
    const sliceProgress = chartReveal(
      frame,
      18 + (i / Math.max(1, data.length)) * 60,
      24,
    );
    const visibleAngle =
      angle * Math.min(wheelProgress, sliceProgress + 0.0001);
    const start = cursor;
    const end = cursor + visibleAngle;
    cursor += angle;
    return {
      start,
      end,
      angle,
      color: CHART_PALETTE[i % CHART_PALETTE.length]!,
      value: v,
      pct: v / total,
    };
  });

  const muted = "rgba(255,255,255,0.55)";

  return (
    <AbsoluteFill
      style={{
        background: s.background,
        color: s.color,
        fontFamily: s.fontFamily,
        padding: 96,
        display: "flex",
        flexDirection: "column",
      }}
    >
      <div style={{ opacity: headerProgress, marginBottom: 28 }}>
        <div
          style={{ fontSize: 38, fontWeight: 600, letterSpacing: "-0.02em" }}
        >
          {title}
        </div>
        {caption && (
          <div style={{ fontSize: 18, color: muted, marginTop: 4 }}>
            {caption}
          </div>
        )}
      </div>
      <div
        style={{
          flex: 1,
          minHeight: 0,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          gap: 80,
        }}
      >
        <svg viewBox="-280 -280 560 560" style={{ height: "100%" }}>
          {slices.map((slice, i) => (
            <path
              key={i}
              d={arcPath(cx, cy, rInner, rOuter, slice.start, slice.end)}
              fill={slice.color}
            />
          ))}
          {donut && (
            <text
              x={0}
              y={6}
              fontSize={56}
              fontWeight={700}
              fill={s.color}
              textAnchor="middle"
              opacity={wheelProgress}
            >
              {total.toLocaleString()}
            </text>
          )}
        </svg>
        {showLegend && (
          <div
            style={{
              display: "flex",
              flexDirection: "column",
              gap: 14,
              opacity: headerProgress,
            }}
          >
            {slices.map((slice, i) => (
              <div
                key={i}
                style={{ display: "flex", alignItems: "center", gap: 12 }}
              >
                <span
                  style={{
                    width: 16,
                    height: 16,
                    borderRadius: 4,
                    background: slice.color,
                  }}
                />
                <span style={{ fontSize: 20, fontWeight: 500 }}>
                  {lbls[i] ?? `Series ${i + 1}`}
                </span>
                <span style={{ fontSize: 18, color: muted, marginLeft: 8 }}>
                  {(slice.pct * 100).toFixed(1)}%
                </span>
              </div>
            ))}
          </div>
        )}
      </div>
    </AbsoluteFill>
  );
};

function arcPath(
  cx: number,
  cy: number,
  rInner: number,
  rOuter: number,
  startAngle: number,
  endAngle: number,
): string {
  const angle = endAngle - startAngle;
  if (angle <= 0.0005) return "";
  const largeArc = angle > Math.PI ? 1 : 0;
  const x1 = cx + Math.cos(startAngle) * rOuter;
  const y1 = cy + Math.sin(startAngle) * rOuter;
  const x2 = cx + Math.cos(endAngle) * rOuter;
  const y2 = cy + Math.sin(endAngle) * rOuter;

  if (rInner <= 0) {
    return `M ${cx} ${cy} L ${x1} ${y1} A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x2} ${y2} Z`;
  }
  const x3 = cx + Math.cos(endAngle) * rInner;
  const y3 = cy + Math.sin(endAngle) * rInner;
  const x4 = cx + Math.cos(startAngle) * rInner;
  const y4 = cy + Math.sin(startAngle) * rInner;
  return `M ${x1} ${y1} A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${rInner} ${rInner} 0 ${largeArc} 0 ${x4} ${y4} Z`;
}
Save as PieChart/PieChart.tsx