data-series-chart.tsx
import {loadFont} from '@remotion/google-fonts/Inter';
import React, {useId} from 'react';
import {Easing, Interactive, interpolate, useCurrentFrame} from 'remotion';
loadFont('normal', {
subsets: ['latin'],
weights: ['400', '500', '600', '700'],
});
const DATA = [
{month: 'Jan', revenue: 8000, conversionRate: 2.1},
{month: 'Feb', revenue: 12000, conversionRate: 2.8},
{month: 'Mar', revenue: 15000, conversionRate: 3.2},
{month: 'Apr', revenue: 11000, conversionRate: 2.9},
{month: 'May', revenue: 18000, conversionRate: 3.8},
{month: 'Jun', revenue: 22000, conversionRate: 4.2},
] as const;
const COLORS = {
accent: '#0B84F3',
barBottom: '#A1A1AA',
barTop: '#F4F4F5',
grid: 'rgba(255, 255, 255, 0.09)',
heading: '#FAFAFA',
label: '#D4D4D8',
muted: '#A1A1AA',
onBar: '#18181B',
} as const;
const VIEWBOX_WIDTH = 1560;
const VIEWBOX_HEIGHT = 864;
const PLOT_LEFT = 165;
const PLOT_RIGHT = 1395;
const PLOT_TOP = 238;
const PLOT_BOTTOM = 716;
const PLOT_WIDTH = PLOT_RIGHT - PLOT_LEFT;
const PLOT_HEIGHT = PLOT_BOTTOM - PLOT_TOP;
const SLOT_WIDTH = PLOT_WIDTH / DATA.length;
const BAR_WIDTH = 122;
const linePoints = DATA.map((item, index) => ({
x: PLOT_LEFT + SLOT_WIDTH * (index + 0.5),
y: PLOT_BOTTOM - (item.conversionRate / 5) * PLOT_HEIGHT,
}));
const linePath = linePoints
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
.join(' ');
const segmentLengths = linePoints.slice(1).map((point, index) => {
const previous = linePoints[index];
return Math.hypot(point.x - previous.x, point.y - previous.y);
});
const totalLineLength = segmentLengths.reduce((sum, length) => sum + length, 0);
const getLineTip = (progress: number) => {
const targetLength = progress * totalLineLength;
let coveredLength = 0;
for (let index = 0; index < segmentLengths.length; index++) {
const segmentLength = segmentLengths[index];
if (coveredLength + segmentLength >= targetLength) {
const segmentProgress = (targetLength - coveredLength) / segmentLength;
const start = linePoints[index];
const end = linePoints[index + 1];
return {
x: start.x + (end.x - start.x) * segmentProgress,
y: start.y + (end.y - start.y) * segmentProgress,
};
}
coveredLength += segmentLength;
}
return linePoints[linePoints.length - 1];
};
export const DataSeriesChart: React.FC = () => {
const frame = useCurrentFrame();
const id = useId().replaceAll(':', '');
const barGradientId = `bar-gradient-${id}`;
const glowFilterId = `line-glow-${id}`;
const lineProgress = interpolate(frame, [32, 88], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.cubic),
});
const lineTip = getLineTip(lineProgress);
const pulse = (Math.sin(frame * 0.32) + 1) / 2;
return (
<Interactive.Div
name="Container"
style={{
backgroundColor: '#18181B',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 24,
boxShadow: '0 12px 30px rgba(24, 24, 27, 0.18)',
boxSizing: 'border-box',
fontFamily: 'Inter, sans-serif',
fontVariantNumeric: 'tabular-nums',
height: '100%',
opacity: interpolate(frame, [0, 10, 104, 119], [0, 1, 1, 0], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
}),
overflow: 'hidden',
position: 'relative',
scale: interpolate(frame, [0, 14, 104, 119], [0.96, 1, 1, 0.97], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
}),
transformOrigin: '50% 50%',
translate: interpolate(
frame,
[0, 18, 104, 119],
['0px 18px', '0px 0px', '0px 0px', '0px -10px'],
{
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
},
),
width: '100%',
}}
>
<svg
aria-label="Monthly revenue and conversion rate from January to June"
role="img"
style={{display: 'block', height: '100%', width: '100%'}}
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
>
<defs>
<linearGradient id={barGradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={COLORS.barTop} />
<stop offset="100%" stopColor={COLORS.barBottom} />
</linearGradient>
<filter
height="220%"
id={glowFilterId}
width="220%"
x="-60%"
y="-60%"
>
<feGaussianBlur stdDeviation="8" />
</filter>
</defs>
<g
opacity={interpolate(frame, [2, 16], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
})}
>
<text
fill={COLORS.muted}
fontSize="20"
fontWeight="600"
x="82"
y="66"
>
Monthly performance
</text>
<text
fill={COLORS.heading}
fontSize="46"
fontWeight="600"
letterSpacing="-1.4"
x="82"
y="120"
>
Revenue & conversion
</text>
<text
fill={COLORS.muted}
fontSize="20"
fontWeight="500"
x="82"
y="158"
>
January — June
</text>
<rect
fill={`url(#${barGradientId})`}
height="20"
rx="6"
width="20"
x="1100"
y="94"
/>
<text
fill={COLORS.label}
fontSize="20"
fontWeight="500"
x="1136"
y="112"
>
Revenue
</text>
<line
stroke={COLORS.accent}
strokeLinecap="round"
strokeWidth="6"
x1="1268"
x2="1296"
y1="104"
y2="104"
/>
<circle cx="1282" cy="104" fill={COLORS.accent} r="7" />
<text
fill={COLORS.label}
fontSize="20"
fontWeight="500"
x="1312"
y="112"
>
Conversion
</text>
</g>
<g
opacity={interpolate(frame, [5, 20], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
})}
>
{[0, 1, 2, 3, 4, 5].map((tick) => {
const y = PLOT_BOTTOM - (tick / 5) * PLOT_HEIGHT;
return (
<g key={tick}>
<line
stroke={COLORS.grid}
strokeWidth="1"
x1={PLOT_LEFT}
x2={PLOT_RIGHT}
y1={y}
y2={y}
/>
<text
dominantBaseline="middle"
fill={COLORS.muted}
fontSize="18"
fontWeight="500"
textAnchor="end"
x={PLOT_LEFT - 22}
y={y}
>
{tick === 0 ? '$0' : `$${tick * 5}K`}
</text>
<text
dominantBaseline="middle"
fill={COLORS.muted}
fontSize="18"
fontWeight="500"
x={PLOT_RIGHT + 22}
y={y}
>
{tick}%
</text>
</g>
);
})}
<text
fill={COLORS.muted}
fontSize="16"
fontWeight="600"
letterSpacing="1.4"
textAnchor="middle"
transform="rotate(-90 50 477)"
x="50"
y="477"
>
Revenue
</text>
<text
fill={COLORS.muted}
fontSize="16"
fontWeight="600"
letterSpacing="1.4"
textAnchor="middle"
transform="rotate(90 1510 477)"
x="1510"
y="477"
>
Conversion rate
</text>
</g>
{DATA.map((item, index) => {
const x = PLOT_LEFT + SLOT_WIDTH * (index + 0.5);
const targetHeight = (item.revenue / 25000) * PLOT_HEIGHT;
const barProgress = interpolate(
frame,
[10 + index * 6, 34 + index * 6],
[0, 1],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.spring({
damping: 16,
mass: 0.65,
stiffness: 105,
}),
},
);
const barHeight = targetHeight * barProgress;
const barTop = PLOT_BOTTOM - barHeight;
return (
<g key={item.month}>
<rect
fill={`url(#${barGradientId})`}
height={Math.max(0, barHeight)}
rx="16"
width={BAR_WIDTH}
x={x - BAR_WIDTH / 2}
y={barTop}
/>
<text
fill={COLORS.onBar}
fontSize="21"
fontWeight="700"
opacity={interpolate(barProgress, [0.55, 0.9], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})}
textAnchor="middle"
x={x}
y={PLOT_BOTTOM - 24}
>
${item.revenue / 1000}K
</text>
<text
fill={COLORS.label}
fontSize="21"
fontWeight="600"
opacity={interpolate(
frame,
[12 + index * 6, 24 + index * 6],
[0, 1],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
},
)}
textAnchor="middle"
x={x}
y={PLOT_BOTTOM + 48}
>
{item.month}
</text>
</g>
);
})}
<path
d={linePath}
fill="none"
filter={`url(#${glowFilterId})`}
opacity="0.55"
pathLength="1"
stroke={COLORS.accent}
strokeDasharray="1"
strokeDashoffset={1 - lineProgress}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<path
d={linePath}
fill="none"
pathLength="1"
stroke={COLORS.accent}
strokeDasharray="1"
strokeDashoffset={1 - lineProgress}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="7"
/>
<g
opacity={interpolate(frame, [32, 38], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})}
>
<circle
cx={lineTip.x}
cy={lineTip.y}
fill={COLORS.accent}
opacity={0.14 + pulse * 0.1}
r={20 + pulse * 7}
/>
<circle
cx={lineTip.x}
cy={lineTip.y}
fill={COLORS.accent}
r={10 + pulse * 1.5}
stroke={COLORS.heading}
strokeWidth="4"
/>
</g>
</svg>
</Interactive.Div>
);
};