Keyloom

Line Chart

A smooth line chart that draws itself in left-to-right with springy data point dots and optional gridlines.

Preview

Open editor

Usage

A continuous smooth line that draws itself in left to right using SVG pathLength dash animation, then each data dot pops on a beat behind it. Ideal for revealing a trend, a growth curve, or a forecast.

Props

NameTypeDefault
titlestring"Revenue"
captionstring"Last 12 weeks · USD"
labelsstring"W1, W2, W3, W4, W5, W6, W7, W8, W9, W10, W11, W12"
valuesstring"12, 18, 24, 22, 31, 38, 44, 49, 55, 62, 71, 88"
showAxesbooleantrue
showGridbooleantrue
showDotsbooleantrue

Composition

ID
LineChart
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 { 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 LineChartProps = {
  title: string;
  caption: string;
  labels: string;
  values: string;
  showAxes: boolean;
  showGrid: boolean;
  showDots: 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 LineChart: React.FC<LineChartProps> = ({
  title,
  caption,
  labels,
  values,
  showAxes,
  showGrid,
  showDots,
  clipStyle,
}) => {
  const frame = useDesignFrame();
  const uid = useId().replace(/[^a-zA-Z0-9-]/g, "");
  const gradientId = `line-grad-${uid}`;
  const clipId = `line-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, 64);
  const revealX = padLeft + innerW * drawProgress;
  const endpointReveal = chartReveal(frame, 64, 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.25} />
              <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>

          {showDots &&
            points.slice(0, -1).map((p, i) => {
              const dotAt = 8 + (i / Math.max(1, points.length - 1)) * 64;
              const reveal = chartReveal(frame, dotAt, 10);
              return (
                <circle
                  key={i}
                  cx={p.x}
                  cy={p.y}
                  r={5.5 * reveal}
                  fill={s.background}
                  stroke={s.accent}
                  strokeWidth={2.5}
                />
              );
            })}

          {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 LineChart/LineChart.tsx