Line chartDesign system
Product

Line chart

Line chart with a filled area, point markers, a pending segment, and a tooltip positioned away from the active point.

npx context-ds add line-chart
In the product

Usage

Use it for

A single trend over time. The redesigned dashboard charts usage as stacked bars (bar-chart).

Avoid

  • Several lines on one chart
  • Vertical gridlines

Dependencies: bar-chart

Example

examples/line-chart.tsx
"use client";import { type ChartBucket, LineChart } from "@/components/product/line-chart";import { Card, CardTitle } from "@/components/ui/card";type K = "scrape" | "search";const SERIES = [  { key: "scrape" as const, label: "Scrape", color: "var(--ds-color-chart-series-1)" },  { key: "search" as const, label: "Search", color: "var(--ds-color-chart-series-2)" },];const BUCKETS: ChartBucket<K>[] = Array.from({ length: 14 }, (_, i) => {  const scrape = Math.round(800 + Math.sin(i / 2) * 300 + i * 40);  const search = Math.round(400 + Math.cos(i / 3) * 150 + i * 20);  return { label: `Oct ${i + 3}`, total: scrape + search, values: { scrape, search } };});export function Product() {  return (    <Card className="h-72 w-full max-w-2xl">      <CardTitle className="text-body-sm">Requests</CardTitle>      <LineChart buckets={BUCKETS} series={SERIES} unit="requests" label="Requests over 14 days" headline="+18% this week" />    </Card>  );}

Source

components/product/line-chart.tsx
"use client";import * as React from "react";import { cn } from "@/lib/utils";import { type ChartBucket, type ChartSeries, niceMax, useChartSize, Y_INTERVALS } from "@/components/product/bar-chart";// Usage over time as one smooth line over a soft fill, in brand blue for the total (or the series it follows). The last// stretch is dotted: that bucket (today) is still filling. Horizontal gridlines only, 11px subtle labels, a hover// marker and a tooltip placed away from the active point. The line draws itself in from the left on mount (900ms, as the redesign's// chart-line-in), the fill fades in after it and the markers pop in along it. The redesigned dashboard charts usage// as stacked bars (bar-chart); use this where one trend over time is the whole story.export type { ChartBucket, ChartSeries };const PAD = { top: 10, right: 12, bottom: 30, left: 40 };const X_LABELS = 7;const X_LABEL_PX = 64;const X_LABEL_CLEARANCE = 44;/** Which buckets get a label under the axis: up to seven, as many as fit, always the first and last. */export function axisLabelIndexes(count: number, plotWidth = Infinity) {  const labels = Math.min(X_LABELS, count, Math.max(2, Math.floor(plotWidth / X_LABEL_PX)));  return Array.from(new Set(Array.from({ length: labels }, (_, s) => Math.round((s * (count - 1)) / Math.max(1, labels - 1)))));}/** A rounded curve through the points (Catmull-Rom as cubic Beziers), one segment per gap, never dipping below zero. */function smoothSegments(points: Array<[number, number]>, floorY: number) {  return points.slice(1).map(([x2, y2], i) => {    const [x0, y0] = points[Math.max(0, i - 1)];    const [x1, y1] = points[i];    const [x3, y3] = points[Math.min(points.length - 1, i + 2)];    return `C${x1 + (x2 - x0) / 6},${Math.min(floorY, y1 + (y2 - y0) / 6)} ${x2 - (x3 - x1) / 6},${Math.min(floorY, y2 - (y3 - y1) / 6)} ${x2},${y2}`;  });}export function LineChart<K extends string>({  buckets,  series,  unit,  label,  headline,  lineKey,  className,}: {  buckets: ChartBucket<K>[];  series: ChartSeries<K>[];  unit: string;  label: string;  headline?: string;  lineKey?: K;  className?: string;}) {  const gradientId = React.useId();  const { ref, size } = useChartSize();  const [activeIndex, setActiveIndex] = React.useState<number | null>(null);  const plotWidth = Math.max(0, size.width - PAD.left - PAD.right);  const plotHeight = Math.max(0, size.height - PAD.top - PAD.bottom);  const valueOf = (b: ChartBucket<K>) => (lineKey ? (b.values[lineKey] ?? 0) : b.total);  const top = niceMax(Math.max(0, ...buckets.map(valueOf)));  const xAt = (i: number) => PAD.left + (buckets.length === 1 ? plotWidth / 2 : (i / (buckets.length - 1)) * plotWidth);  const yAt = (v: number) => PAD.top + plotHeight - (v / top) * plotHeight;  const lineColor = series.find((s) => s.key === lineKey)?.color ?? "var(--ds-color-brand)";  const points = buckets.map((b, i): [number, number] => [xAt(i), yAt(valueOf(b))]);  const baseline = PAD.top + plotHeight;  const segments = smoothSegments(points, baseline);  const start = points.length ? `M${points[0][0]},${points[0][1]}` : "";  const settled = `${start} ${segments.slice(0, -1).join(" ")}`;  const pending = points.length > 1 ? `M${points[points.length - 2][0]},${points[points.length - 2][1]} ${segments[segments.length - 1]}` : "";  const area = points.length > 1 ? `${start} ${segments.join(" ")} L${xAt(buckets.length - 1)},${baseline} L${xAt(0)},${baseline} Z` : "";  const labelIndexes = axisLabelIndexes(buckets.length, plotWidth || undefined);  const active = activeIndex === null ? null : buckets[activeIndex];  const activeX = activeIndex === null ? 0 : xAt(activeIndex);  const tooltipSide = activeX > PAD.left + plotWidth / 2 ? "left" : "right";  const tooltipLow = activeIndex !== null && points[activeIndex]?.[1] < PAD.top + plotHeight / 2;  const ready = size.width > 0 && size.height > 0;  return (    <div      ref={ref}      role="img"      aria-label={label}      data-slot="line-chart"      onMouseMove={(e) => {        if (!plotWidth || !buckets.length) return;        const x = e.clientX - e.currentTarget.getBoundingClientRect().left - PAD.left;        setActiveIndex(Math.min(buckets.length - 1, Math.max(0, Math.round((x / plotWidth) * (buckets.length - 1)))));      }}      onMouseLeave={() => setActiveIndex(null)}      className={cn("relative min-h-48 flex-1", className)}    >      {headline && ready && (        <span          className="pointer-events-none absolute z-10 flex h-6 -translate-1/2 items-center rounded-tag border border-line bg-surface px-2.5 text-caption text-fg-brand tabular-nums"          style={{ left: PAD.left + plotWidth / 2, top: yAt(top / Y_INTERVALS) }}        >          {headline}        </span>      )}      {ready && (        <svg width={size.width} height={size.height} className="absolute inset-0 overflow-visible" aria-hidden>          <defs>            <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">              <stop offset="0%" stopColor={lineColor} stopOpacity={0.12} />              <stop offset="100%" stopColor={lineColor} stopOpacity={0} />            </linearGradient>          </defs>          {Array.from({ length: Y_INTERVALS + 1 }, (_, s) => {            const v = (top * s) / Y_INTERVALS;            return (              <g key={s}>                <line x1={PAD.left} x2={size.width - PAD.right} y1={yAt(v)} y2={yAt(v)} stroke="var(--ds-color-border-subtle)" />                <text x={PAD.left - 10} y={yAt(v)} dy="0.32em" textAnchor="end" className="fill-fg-subtle text-axis tabular-nums">                  {v.toLocaleString("en-US", { notation: "compact", maximumFractionDigits: 1 })}                </text>              </g>            );          })}          {labelIndexes.map((i, s) =>            activeIndex !== null && Math.abs(xAt(i) - activeX) < X_LABEL_CLEARANCE ? null : (              <text key={i} x={xAt(i)} y={size.height - 8} textAnchor={s === 0 ? "start" : s === labelIndexes.length - 1 ? "end" : "middle"} className="fill-fg-subtle text-axis">                {buckets[i].label}              </text>            ),          )}          {area && <path d={area} fill={`url(#${gradientId})`} className="animate-chart-area motion-reduce:animate-none" />}          <path d={settled} pathLength={1} strokeDasharray={1} fill="none" stroke={lineColor} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="animate-chart-line motion-reduce:animate-none" />          {pending && <path d={pending} fill="none" stroke={lineColor} strokeWidth={2} strokeLinecap="round" strokeDasharray="0 5" className="animate-chart-area motion-reduce:animate-none" />}          {labelIndexes.map((i) =>            i === activeIndex ? null : (              <circle                key={i}                cx={points[i][0]}                cy={points[i][1]}                r={4.5}                fill="var(--ds-color-bg-default)"                stroke={lineColor}                strokeWidth={2}                className="origin-center animate-chart-tip [transform-box:fill-box] motion-reduce:animate-none"                style={{ animationDelay: `${Math.round(150 + (i / Math.max(1, buckets.length - 1)) * 650)}ms` }}              />            ),          )}          {activeIndex !== null && (            <g>              <line x1={activeX} x2={activeX} y1={PAD.top} y2={baseline} stroke="var(--ds-color-neutral-40)" strokeDasharray="3 4" />              <circle cx={activeX} cy={points[activeIndex][1]} r={4.5} fill="var(--ds-color-bg-default)" stroke={lineColor} strokeWidth={2} />            </g>          )}        </svg>      )}      {active && activeIndex !== null && (        <>          <span className="pointer-events-none absolute bottom-[3px] flex h-5 -translate-x-1/2 items-center rounded-tag bg-brand px-2 text-axis whitespace-nowrap text-on-brand" style={{ left: activeX }}>            {active.label}          </span>          <div            role="tooltip"            className={cn("pointer-events-none absolute z-10 w-max min-w-52 rounded-shell bg-surface p-(--ds-space-frame) shadow-floating", tooltipSide === "left" && "-translate-x-full")}            style={{ left: activeX + (tooltipSide === "left" ? -16 : 16), ...(tooltipLow ? { bottom: PAD.bottom } : { top: PAD.top }) }}          >            <div className="rounded-card bg-surface p-3 text-caption">              <p className="text-fg-subtle">{active.label}</p>              <p className="mt-1.5 flex items-baseline gap-1.5">                <span className="text-metric leading-none font-medium tracking-tight text-fg-brand tabular-nums">{active.total.toLocaleString("en-US")}</span>                <span className="text-fg-muted">{active.total === 1 ? unit.replace(/s$/, "") : unit}</span>              </p>              <ul className="mt-3 flex flex-col gap-1.5 border-t border-line-subtle pt-2.5">                {series.map((entry) => {                  const value = active.values[entry.key] ?? 0;                  return (                    <li key={entry.key} className={cn("flex items-center gap-2", value === 0 && "text-fg-subtle")}>                      <span aria-hidden className={cn("size-2 rounded-pill", value === 0 && "opacity-30")} style={{ backgroundColor: entry.color }} />                      <span className={cn("flex-1", value > 0 && "text-fg-muted")}>{entry.label}</span>                      <span className="tabular-nums">{value.toLocaleString("en-US")}</span>                    </li>                  );                })}              </ul>            </div>          </div>        </>      )}    </div>  );}

Reference: context-dashboard c0ddf7a src/components/overview/line-chart.tsx.