Keyloom

Area Chart

A line chart filled with a soft gradient underneath, sweeping up from the baseline as the data reveals.

Preview

Open editor

Usage

When the volume under the curve is the story, not just the line. The area sweeps up from the baseline in a single ease-out, and the line rides on top.

Props

NameTypeDefault
titlestring"Signups"
captionstring"Last 8 weeks"
labelsstring"W1, W2, W3, W4, W5, W6, W7, W8"
valuesstring"120, 145, 132, 168, 195, 224, 270, 312"
showAxesbooleantrue
showGridbooleantrue

Composition

ID
AreaChart
Resolution
1920×1080
FPS
60
Duration
3.0s

Source

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

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

export type AreaChartProps = {
  title: string;
  caption: string;
  labels: string;
  values: string;
  showAxes: boolean;
  showGrid: boolean;
  clipStyle?: ClipStyle;
};

const GRID_FRACTIONS = [0.25, 0.5, 0.75, 1];
const TICK_FRACTIONS = [0, 0.25, 0.5, 0.75, 1];

export const AreaChart: React.FC<AreaChartProps> = ({
  title,
  caption,
  labels,
  values,
  showAxes,
  showGrid,
  clipStyle,
}) => {
  const frame = useDesignFrame();
  const uid = useId().replace(/[^a-zA-Z0-9-]/g, "");
  const gradientId = `area-grad-${uid}`;
  const clipId = `area-clip-${uid}`;
  const s = resolveClipStyle(clipStyle, {
    background: "#0b0c10",
    color: "#ffffff",
    fontFamily:
      "-apple-system, BlinkMacSystemFont, 'SF Pro Display', Inter, sans-serif",
    accent: CHART_PALETTE[0]!,
  });

  const data = parseSeriesString(values);
  const lbls = parseLabels(labels).slice(0, data.length);
  const max = niceMax(Math.max(1, ...data));
  const latest = data[data.length - 1] ?? 0;
  const latestLabel = lbls[data.length - 1];

  const W = 1640;
  const H = 620;
  const padLeft = 96;
  const padRight = 48;
  const padTop = 28;
  const padBottom = 56;
  const innerW = W - padLeft - padRight;
  const innerH = H - padTop - padBottom;
  const baselineY = padTop + innerH;
  const stepX = innerW / Math.max(1, data.length - 1);

  const points = data.map((v, i) => ({
    x: padLeft + i * stepX,
    y: baselineY - (v / max) * innerH,
  }));
  const linePath = smoothPath(points);
  const firstPoint = points[0];
  const endpoint = points[points.length - 1];
  const areaPath =
    firstPoint && endpoint
      ? `${linePath} L ${endpoint.x} ${baselineY} L ${firstPoint.x} ${baselineY} Z`
      : "";

  const drawProgress = chartReveal(frame, 8, 60);
  const revealX = padLeft + innerW * drawProgress;
  const endpointReveal = chartReveal(frame, 60, 14);
  const counter = Math.round(latest * drawProgress);

  return (
    <AbsoluteFill
      style={{
        background: s.background,
        color: s.color,
        fontFamily: s.fontFamily,
        padding: "96px 128px",
        display: "flex",
        flexDirection: "column",
      }}
    >
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "flex-start",
          marginBottom: 32,
        }}
      >
        <div>
          <div
            style={{ fontSize: 30, fontWeight: 600, letterSpacing: "-0.01em" }}
          >
            {title}
          </div>
          {caption && (
            <div style={{ fontSize: 19, marginTop: 6, opacity: 0.6 }}>
              {caption}
            </div>
          )}
        </div>
        <div style={{ textAlign: "right" }}>
          <div
            style={{
              fontSize: 56,
              fontWeight: 650,
              letterSpacing: "-0.02em",
              lineHeight: 1,
              fontVariantNumeric: "tabular-nums",
            }}
          >
            {counter.toLocaleString()}
          </div>
          {latestLabel && (
            <div style={{ fontSize: 16, marginTop: 8, opacity: 0.4 }}>
              {latestLabel}
            </div>
          )}
        </div>
      </div>

      <div style={{ flex: 1, minHeight: 0 }}>
        <svg
          viewBox={`0 0 ${W} ${H}`}
          style={{ width: "100%", height: "100%" }}
        >
          <defs>
            <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor={s.accent} stopOpacity={0.3} />
              <stop offset="100%" stopColor={s.accent} stopOpacity={0} />
            </linearGradient>
            <clipPath id={clipId}>
              <rect x={0} y={0} width={revealX + 6} height={H} />
            </clipPath>
          </defs>

          {showGrid &&
            GRID_FRACTIONS.map((t) => (
              <line
                key={t}
                x1={padLeft}
                x2={W - padRight}
                y1={padTop + innerH * (1 - t)}
                y2={padTop + innerH * (1 - t)}
                stroke={s.color}
                strokeOpacity={0.08}
                strokeWidth={1}
              />
            ))}

          {showAxes && (
            <>
              <line
                x1={padLeft}
                x2={W - padRight}
                y1={baselineY}
                y2={baselineY}
                stroke={s.color}
                strokeOpacity={0.15}
                strokeWidth={1}
              />
              {TICK_FRACTIONS.map((t) => (
                <text
                  key={t}
                  x={padLeft - 18}
                  y={padTop + innerH * (1 - t)}
                  fontSize={15}
                  fill={s.color}
                  fillOpacity={0.45}
                  textAnchor="end"
                  dominantBaseline="middle"
                  style={{ fontVariantNumeric: "tabular-nums" }}
                >
                  {Math.round(max * t).toLocaleString()}
                </text>
              ))}
            </>
          )}

          <g clipPath={`url(#${clipId})`}>
            <path d={areaPath} fill={`url(#${gradientId})`} />
            <path
              d={linePath}
              stroke={s.accent}
              strokeWidth={3}
              fill="none"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </g>

          {endpoint && (
            <g opacity={endpointReveal}>
              <circle
                cx={endpoint.x}
                cy={endpoint.y}
                r={22}
                fill={s.accent}
                fillOpacity={0.15}
              />
              <circle cx={endpoint.x} cy={endpoint.y} r={7} fill={s.accent} />
            </g>
          )}

          {lbls.map((label, i) => {
            const p = points[i];
            if (!p) return null;
            return (
              <text
                key={i}
                x={p.x}
                y={baselineY + 34}
                fontSize={16}
                fill={s.color}
                fillOpacity={0.6}
                textAnchor="middle"
              >
                {label}
              </text>
            );
          })}
        </svg>
      </div>
    </AbsoluteFill>
  );
};

function smoothPath(points: { x: number; y: number }[]): string {
  if (points.length === 0) return "";
  let d = `M ${points[0]!.x} ${points[0]!.y}`;
  for (let i = 1; i < points.length; i++) {
    const prev = points[i - 1]!;
    const curr = points[i]!;
    const cp1x = prev.x + (curr.x - prev.x) / 3;
    const cp2x = prev.x + ((curr.x - prev.x) * 2) / 3;
    d += ` C ${cp1x} ${prev.y}, ${cp2x} ${curr.y}, ${curr.x} ${curr.y}`;
  }
  return d;
}
Save as AreaChart/AreaChart.tsx