Catalog

Edit Props (live)

TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
config
config

Source

462 lines
import React from "react";
import {
  AbsoluteFill,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  Easing,
  spring,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { SplitHeadBelow } from "./SplitHeadBelow";

loadInter("normal", { weights: ["400", "600", "700", "900"] });

interface AnimatedChartProps {
  mode: "bar" | "line";
  title: string;
  subtitle?: string;
  // For bar mode
  bars?: { label: string; value: number }[];
  // For line mode — array of values to plot
  linePoints?: number[];
  lineLabels?: string[];
  // Final counter value to animate to (shown prominently)
  counterValue?: number;
  counterPrefix?: string;
  counterSuffix?: string;
  backgroundColor?: string;
  accentColor?: string;
  splitHeadSrc?: string | null;
  headStartFrame?: number;
}

const FONT = "'Inter', sans-serif";

export const AnimatedChart: React.FC<AnimatedChartProps> = ({
  mode,
  title,
  subtitle,
  bars = [],
  linePoints = [],
  lineLabels = [],
  counterValue,
  counterPrefix = "",
  counterSuffix = "",
  backgroundColor = "#f6f4ed",
  accentColor = "#c94f3d",
  splitHeadSrc,
  headStartFrame,
}) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames, width, height } = useVideoConfig();

  // Title reveal — clipPath typewriter (ported from HyperFrames)
  const titleReveal = interpolate(frame, [0, 25], [100, 0], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
  const subtitleOpacity = interpolate(frame, [15, 35], [0, 1], {
    extrapolateRight: "clamp",
  });

  // Chart canvas area — fills more of the frame vertically
  const chartX = 100;
  const chartY = 620;
  const chartW = width - 200;
  const chartH = 1120;

  // Gridlines stagger in
  const gridlines = 5;
  const gridAnims = Array.from({ length: gridlines }, (_, i) =>
    interpolate(frame, [20 + i * 3, 40 + i * 3], [0, 1], {
      extrapolateRight: "clamp",
      easing: Easing.out(Easing.cubic),
    })
  );

  // Counter animation
  const counterStart = 40;
  const counterEnd = 100;
  const counterT = interpolate(frame, [counterStart, counterEnd], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
  const counterNow = counterValue ? counterValue * counterT : 0;
  const counterOpacity = interpolate(frame, [counterStart - 10, counterStart + 5], [0, 1], {
    extrapolateRight: "clamp",
  });

  const formatCounter = (v: number) => {
    if (v >= 1000) return (v / 1000).toFixed(1) + "K";
    return Math.round(v).toString();
  };

  const split = Boolean(splitHeadSrc);

  const fullFrame = (
    <AbsoluteFill style={{ backgroundColor }}>
      {/* Paper texture overlay */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          backgroundImage:
            "radial-gradient(ellipse at 30% 20%, rgba(0,0,0,0.04) 0%, transparent 60%)",
          pointerEvents: "none",
        }}
      />

      {/* Accent rule above title */}
      <div
        style={{
          position: "absolute",
          top: 170,
          left: chartX,
          width: interpolate(frame, [5, 25], [0, 140], {
            extrapolateRight: "clamp",
            easing: Easing.out(Easing.cubic),
          }),
          height: 6,
          background: accentColor,
        }}
      />

      {/* Title — stacked above subtitle, full width (no collision with counter now) */}
      <div
        style={{
          position: "absolute",
          top: 200,
          left: chartX,
          right: chartX,
          fontFamily: FONT,
          fontSize: 78,
          fontWeight: 900,
          lineHeight: 1.05,
          color: "#1a1a1a",
          letterSpacing: -2.2,
          whiteSpace: "pre-line",
          clipPath: `inset(0 ${titleReveal}% 0 0)`,
          WebkitClipPath: `inset(0 ${titleReveal}% 0 0)`,
        }}
      >
        {title}
      </div>

      {/* Subtitle — just under the title */}
      {subtitle && (
        <div
          style={{
            position: "absolute",
            top: 310,
            left: chartX,
            right: chartX,
            fontFamily: FONT,
            fontSize: 28,
            fontWeight: 500,
            color: "#707070",
            letterSpacing: 0.4,
            textTransform: "uppercase",
            opacity: subtitleOpacity,
            transform: `translateY(${interpolate(frame, [15, 35], [14, 0], {
              extrapolateRight: "clamp",
              easing: Easing.out(Easing.cubic),
            })}px)`,
          }}
        >
          {subtitle}
        </div>
      )}

      {/* Counter chip — between subtitle and chart, inline pill */}
      {counterValue !== undefined && (
        <div
          style={{
            position: "absolute",
            top: 390,
            left: chartX,
            right: chartX,
            display: "flex",
            justifyContent: "flex-start",
            opacity: counterOpacity,
            transform: `translateY(${interpolate(frame, [30, 50], [14, 0], {
              extrapolateRight: "clamp",
              easing: Easing.out(Easing.cubic),
            })}px)`,
          }}
        >
          <div
            style={{
              background: accentColor,
              color: "#ffffff",
              fontFamily: FONT,
              fontSize: 56,
              fontWeight: 900,
              letterSpacing: -2,
              padding: "14px 28px",
              borderRadius: 14,
              boxShadow: `0 14px 30px ${accentColor}66`,
              fontVariantNumeric: "tabular-nums",
            }}
          >
            {counterPrefix}
            {formatCounter(counterNow)}
            {counterSuffix}
          </div>
        </div>
      )}

      {/* Chart area */}
      <svg
        style={{
          position: "absolute",
          left: chartX,
          top: chartY,
          width: chartW,
          height: chartH,
        }}
        viewBox={`0 0 ${chartW} ${chartH}`}
      >
        {/* Gridlines */}
        {gridAnims.map((anim, i) => {
          const y = (chartH - 80) * (i / (gridlines - 1));
          const lineW = anim * chartW;
          return (
            <g key={i}>
              <line
                x1={0}
                x2={lineW}
                y1={y}
                y2={y}
                stroke="#cfc9bc"
                strokeWidth={1}
                strokeDasharray={i === gridlines - 1 ? "0" : "4 4"}
              />
              <text
                x={-20}
                y={y + 10}
                fontFamily={FONT}
                fontSize={22}
                fill="#888"
                textAnchor="end"
                opacity={anim}
              >
                {Math.round(((gridlines - 1 - i) / (gridlines - 1)) * 100)}
              </text>
            </g>
          );
        })}

        {mode === "bar" && (
          <>
            {bars.map((b, i) => {
              const barStartFrame = 35 + i * 6;
              const barEndFrame = barStartFrame + 22;
              const growth = interpolate(frame, [barStartFrame, barEndFrame], [0, 1], {
                extrapolateLeft: "clamp",
                extrapolateRight: "clamp",
                easing: Easing.out(Easing.cubic),
              });
              const maxBarH = chartH - 180;
              const barH = (b.value / 100) * maxBarH * growth;
              const barW = Math.min(140, (chartW - 80) / bars.length - 20);
              const barX = 20 + i * ((chartW - 40) / bars.length) + ((chartW - 40) / bars.length - barW) / 2;
              const baseY = chartH - 120;
              const barY = baseY - barH;

              // Label appears after bar grows
              const labelOp = interpolate(frame, [barEndFrame - 5, barEndFrame + 8], [0, 1], {
                extrapolateLeft: "clamp",
                extrapolateRight: "clamp",
              });

              return (
                <g key={i}>
                  <rect
                    x={barX}
                    y={barY}
                    width={barW}
                    height={barH}
                    fill={i === bars.length - 1 ? accentColor : "#3a5a7a"}
                    rx={2}
                  />
                  {/* Value above bar */}
                  <text
                    x={barX + barW / 2}
                    y={barY - 14}
                    fontFamily={FONT}
                    fontSize={26}
                    fontWeight={700}
                    fill="#1a1a1a"
                    textAnchor="middle"
                    opacity={labelOp}
                  >
                    {Math.round(b.value * growth)}
                  </text>
                  {/* Label below */}
                  <text
                    x={barX + barW / 2}
                    y={baseY + 40}
                    fontFamily={FONT}
                    fontSize={22}
                    fontWeight={500}
                    fill="#555"
                    textAnchor="middle"
                    opacity={labelOp}
                  >
                    {b.label}
                  </text>
                </g>
              );
            })}
          </>
        )}

        {mode === "line" && linePoints.length > 1 && (() => {
          const maxBarH = chartH - 180;
          const baseY = chartH - 120;
          const stepX = (chartW - 80) / (linePoints.length - 1);
          const maxVal = Math.max(...linePoints);
          const pts = linePoints.map((v, i) => ({
            x: 40 + i * stepX,
            y: baseY - (v / maxVal) * maxBarH,
            v,
          }));
          // Build path string
          const pathD = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
          // Area under line
          const areaD =
            pathD + ` L ${pts[pts.length - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`;

          // Estimate path length for strokeDasharray draw-on
          let pathLength = 0;
          for (let i = 1; i < pts.length; i++) {
            const dx = pts[i].x - pts[i - 1].x;
            const dy = pts[i].y - pts[i - 1].y;
            pathLength += Math.sqrt(dx * dx + dy * dy);
          }

          const drawT = interpolate(frame, [35, 90], [0, 1], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
            easing: Easing.inOut(Easing.cubic),
          });
          const dashOffset = pathLength * (1 - drawT);

          // Moving dot index
          const dotIdx = drawT * (pts.length - 1);
          const dotI = Math.floor(dotIdx);
          const dotFrac = dotIdx - dotI;
          const dot = pts[dotI] || pts[0];
          const nextDot = pts[dotI + 1] || dot;
          const dotX = dot.x + (nextDot.x - dot.x) * dotFrac;
          const dotY = dot.y + (nextDot.y - dot.y) * dotFrac;

          return (
            <>
              {/* Area fill */}
              <path
                d={areaD}
                fill={accentColor}
                opacity={drawT * 0.18}
              />
              {/* Line */}
              <path
                d={pathD}
                fill="none"
                stroke={accentColor}
                strokeWidth={5}
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeDasharray={pathLength}
                strokeDashoffset={dashOffset}
              />
              {/* Moving dot */}
              {drawT > 0 && drawT < 1 && (
                <>
                  <circle cx={dotX} cy={dotY} r={14} fill={accentColor} opacity={0.3} />
                  <circle cx={dotX} cy={dotY} r={8} fill={accentColor} />
                  <circle cx={dotX} cy={dotY} r={4} fill="#fff" />
                </>
              )}
              {/* End dot when complete */}
              {drawT >= 1 && (
                <>
                  <circle
                    cx={pts[pts.length - 1].x}
                    cy={pts[pts.length - 1].y}
                    r={12}
                    fill={accentColor}
                  />
                  <circle
                    cx={pts[pts.length - 1].x}
                    cy={pts[pts.length - 1].y}
                    r={5}
                    fill="#fff"
                  />
                </>
              )}
              {/* X-axis labels */}
              {lineLabels.map((lbl, i) => {
                const op = interpolate(frame, [40 + i * 3, 55 + i * 3], [0, 1], {
                  extrapolateLeft: "clamp",
                  extrapolateRight: "clamp",
                });
                return (
                  <text
                    key={i}
                    x={40 + i * stepX}
                    y={baseY + 40}
                    fontFamily={FONT}
                    fontSize={20}
                    fontWeight={500}
                    fill="#555"
                    textAnchor="middle"
                    opacity={op}
                  >
                    {lbl}
                  </text>
                );
              })}
            </>
          );
        })()}
      </svg>

    </AbsoluteFill>
  );

  if (split && splitHeadSrc) {
    // The chart content is tuned for the full 1080×1920 frame. Render it
    // into a virtual full-size canvas, then non-uniformly scale (scaleY=0.5)
    // so it fits the 1080×960 top half without leaving side gutters. The
    // horizontal axis is preserved; bars compress vertically, which reads
    // fine for charts/counters in a split layout.
    return (
      <SplitHeadBelow
        headSrc={splitHeadSrc}
        backgroundColor={backgroundColor}
        topBackground={backgroundColor}
        headStartFrame={headStartFrame}
      >
        <div
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: 1080,
            height: 1920,
            transform: "scaleY(0.5)",
            transformOrigin: "top left",
          }}
        >
          {fullFrame}
        </div>
      </SplitHeadBelow>
    );
  }

  return fullFrame;
};

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: AnimatedChart
// 14 props detected · 2 port(s)

Animated Chart

animated_chart

Animated Chart — Andy's hand-crafted Remotion scene. 14 props. title:TEXT, subtitle:TEXT.

Workflow Inputs (2)

  • Title
    title
    TEXTRequired
  • Subtitle
    subtitle
    TEXT

Config Fields (12)

  • bars
  • mode
  • value
  • lineLabels
  • linePoints
  • accentColor
  • counterValue
  • splitHeadSrc
  • counterPrefix
  • counterSuffix
  • headStartFrame
  • backgroundColor

Meta

Updated
4/21/2026