← Catalog
Edit Props (live)
TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
config
config
Source
462 linesimport 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_chartAnimated Chart — Andy's hand-crafted Remotion scene. 14 props. title:TEXT, subtitle:TEXT.
Workflow Inputs (2)
- Title
titleTEXTRequired - Subtitle
subtitleTEXT
Config Fields (12)
barsmodevaluelineLabelslinePointsaccentColorcounterValuesplitHeadSrccounterPrefixcounterSuffixheadStartFramebackgroundColor
Meta
- Updated
- 4/21/2026