Line chart
Several measures over time on one scale, at hairline weight with a dot on the active point.
One scale only — two measures of different magnitude belong in two charts, or indexed to a common base. The line is 2px with round caps and joins; dots are hollow — the series' ring around the surface — so they read as points where lines cross: --chart-surface is the colour the gaps and hollows are cut in, read at the use site with a var(--chart-surface, var(--card)) fallback, so setting it on the chart itself or on any wrapper — [--chart-surface:var(--background)] — takes effect. Series are named beside their own last point rather than in a legend below, so nothing has to be matched by colour; legend='bottom' puts the key back under the plot. The data is also rendered as a visually hidden list, annotations included.
Install
npx shadcn@latest add @vibra/line-chartNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { LineChart } from "@/components/ui/line-chart"
const TRAFFIC = [
{ date: "Aug 6", visitors: 11840, returning: 4620 },
{ date: "Aug 7", visitors: 11210, returning: 4380 },
{ date: "Aug 8", visitors: 6940, returning: 2510 },
{ date: "Aug 9", visitors: 6280, returning: 2340 },
{ date: "Aug 10", visitors: 12480, returning: 4910 },
{ date: "Aug 11", visitors: 12960, returning: 5120 },
{ date: "Aug 12", visitors: 13140, returning: 5240 },
{ date: "Aug 13", visitors: 12720, returning: 5060 },
{ date: "Aug 14", visitors: 11890, returning: 4720 },
{ date: "Aug 15", visitors: 7120, returning: 2680 },
{ date: "Aug 16", visitors: 6510, returning: 2420 },
{ date: "Aug 17", visitors: 13020, returning: 5180 },
{ date: "Aug 18", visitors: 13640, returning: 5440 },
{ date: "Aug 19", visitors: 14180, returning: 5720 },
{ date: "Aug 20", visitors: 13480, returning: 5390 },
{ date: "Aug 21", visitors: 12640, returning: 5020 },
{ date: "Aug 22", visitors: 7480, returning: 2810 },
{ date: "Aug 23", visitors: 6890, returning: 2570 },
{ date: "Aug 24", visitors: 13910, returning: 5560 },
{ date: "Aug 25", visitors: 14620, returning: 5880 },
{ date: "Aug 26", visitors: 15240, returning: 6140 },
{ date: "Aug 27", visitors: 14780, returning: 5960 },
{ date: "Aug 28", visitors: 13820, returning: 5510 },
{ date: "Aug 29", visitors: 8120, returning: 3040 },
{ date: "Aug 30", visitors: 7460, returning: 2790 },
{ date: "Aug 31", visitors: 14980, returning: 6020 },
{ date: "Sep 1", visitors: 15720, returning: 6350 },
{ date: "Sep 2", visitors: 16340, returning: 6610 },
{ date: "Sep 3", visitors: 15880, returning: 6420 },
{ date: "Sep 4", visitors: 14920, returning: 6040 },
]
export default function LineChartDemo() {
return (
<ChartCard
title="Site traffic"
description="Last 30 days, weekends included"
height={240}
className="w-full"
>
<LineChart
data={TRAFFIC}
index="date"
series={[
{ key: "visitors", label: "Visitors" },
{ key: "returning", label: "Returning" },
]}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
height={240}
showYAxis
/>
</ChartCard>
)
}Annotations
A goal line, an event marker and a band, each also printed as text.
"use client"
import type { ChartAnnotation } from "@/components/ui/chart-core"
import { LineChart } from "@/components/ui/line-chart"
const DAYS = [
{ day: "Mon", requests: 612_000 },
{ day: "Tue", requests: 648_000 },
{ day: "Wed", requests: 921_000 },
{ day: "Thu", requests: 1_140_000 },
{ day: "Fri", requests: 1_082_000 },
{ day: "Sat", requests: 704_000 },
{ day: "Sun", requests: 731_000 },
]
const ANNOTATIONS: ChartAnnotation[] = [
// A threshold: the plan's ceiling, not a series. It is drawn once, in the
// faint ink tier, and never takes a colour off the palette.
{ kind: "line", axis: "y", value: 1_000_000, label: "Included" },
// A moment: what happened, on the day it happened — the peak. At 390 the
// plot is 178px wide, 30px a day, and a line drawn one day earlier ran
// through the goal's label above.
{ kind: "event", x: "Thu", label: "Launch post", tone: "brand" },
// A stretch of the window worth naming. It ends where the plot does, so its
// label hangs from its end and stays inside the plot at every width.
{ kind: "band", from: "Sat", to: "Sun", label: "Weekend", tone: "neutral", align: "end" },
]
export default function LineChartAnnotations() {
return (
<div className="flex w-full flex-col gap-3">
<LineChart
data={DAYS}
index="day"
series={[{ key: "requests", label: "Requests" }]}
annotations={ANNOTATIONS}
height={260}
valueFormatter={(value) => `${Math.round(value / 1000)}K`}
/>
<p className="text-sm text-muted-foreground">
Every annotation is appended to the chart's visually hidden rows, so a goal line a
sighted reader can see is a line a screen reader hears.
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data | ChartDatum[] | — | The points to plot; each one holds the index value and a number per series key. |
| index | string | — | The key holding each point's category — the month, the day, the source. |
| series | ChartSeries[] | palette in order | The measures to plot, as { key, label, color?, compareKey?, compareColor? }. A colour is a chart token, any CSS colour, or left out to take the next palette slot. compareKey names the same measure over the period before, drawn when compare is on; compareColor paints that ghost — var(--chart-neutral) keeps it grey under every preset. |
| valueFormatter | (n: number) => string | thousands-separated number | Formats every number the chart prints: axis ticks, tooltip values, and the text summary. |
| indexFormatter | (v: string | number) => string | the value as-is | Formats every index label, e.g. an ISO date into "Sep 4". |
| height | number | 280 | Plot height in pixels, axis band included. |
| legend | "inline-end" | "bottom" | "none" | "inline-end" | Where the series are named: beside their own last point, under the plot, or nowhere. Bar and stacked-bar default to bottom — a column has no last point to write a name beside. |
| annotations | ChartAnnotation[] | — | Goal lines, event markers and bands drawn over the plot in tone tokens, and appended to the visually hidden rows as text. A band's label reads from its start; align: "end" hangs it from the band's end, for a band that runs to the edge of the plot. Each label is haloed in the plane the chart sits on — --chart-surface, else the card — so it reads where it crosses a column or a line; set --chart-surface on a chart drawn on another plane. |
| compare | boolean | false | Draws each series' compareKey as a dashed ghost behind it — in the series' own colour, or in its compareColor when it names one (var(--chart-neutral) keeps the period before grey under every preset) — and prints the delta in the tooltip row. |
| showLegend | boolean | true | Shows the legend. A single-series chart never draws one — there is only one colour, so the card title already names it. |
| showGrid | boolean | true | Hairline reference lines, drawn only across the direction the marks grow in. |
| showTooltip | boolean | true | Shows the hover tooltip. Every value is in the chart's visually hidden list either way. |
| showXAxis | boolean | true | Shows the category labels. The axis rule and the tick marks are always off. |
| showYAxis | boolean | true | Shows the printed value scale — ticks on 1/2/2.5/5 x 10^n through niceTicks, over an explicit domain, formatted with valueFormatter. Dropped anyway when the plot measures under 120px tall or 240px wide. |
| baseline | "zero" | "auto" | "zero" | Where the value axis starts. zero takes zero in, the honest floor for a count, a sum or a share; auto fits the axis to the data's own range on the same 1/2/2.5/5 steps, with a step of air past a peak or trough that sits on the edge, for a level — a rate, a price — whose movement a zero floor would flatten. The chart for a level: an AreaChart's fill would run down to the fitted floor. Kept when the printed scale is dropped from a small plot. |
| curve | "monotone" | "linear" | "step" | "monotone" | How the line between points is drawn. |
| dots | boolean | false | Marks every point, not just the one under the pointer. Best under about 20 points. |
| strokeWidth | number | 2 | Line weight in pixels. |
Dependencies
Source
"use client"
import * as React from "react"
import { CartesianGrid, Line, LineChart as RechartsLineChart, XAxis, YAxis } from "recharts"
import { cn } from "@/lib/utils"
import {
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import {
ACTIVE_DOT,
AXIS_TICK,
axisProps,
buildChartConfig,
chartDefaults,
chartRows,
chartSummary,
type CommonChartProps,
defaultIndexFormatter,
defaultValueFormatter,
GRID_PROPS,
STROKE_WIDTH,
tooltipIndexLabel,
} from "@/components/ui/chart-core"
import { renderAnnotations } from "@/components/ui/chart-annotations"
import { ChartPlot, ChartRows } from "@/components/ui/chart-frame"
import {
annotationHeadroom,
directLabel,
inlineLabelWidth,
legendPlacement,
} from "@/components/ui/chart-labels"
import { COMPARE_STROKE, compareSeries, tooltipRow } from "@/components/ui/chart-tooltip"
import { type ChartBaseline, yAxisScale } from "@/components/ui/chart-scale"
export type LineChartProps = CommonChartProps & {
/**
* Where the value axis starts. `zero` takes zero in, as every chart does by
* default; `auto` fits the axis to the data's own range, for a level — a
* rate, a price — whose movement a zero floor would flatten.
*/
baseline?: ChartBaseline
curve?: "monotone" | "linear" | "step"
/** Marks every point, not just the one under the pointer. Best under ~20 points. */
dots?: boolean
strokeWidth?: number
}
function LineChart({
className,
data,
index,
series,
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
height = chartDefaults.height,
showLegend = chartDefaults.showLegend,
showGrid = chartDefaults.showGrid,
showXAxis = chartDefaults.showXAxis,
showYAxis = chartDefaults.showYAxis,
showTooltip = chartDefaults.showTooltip,
legend = chartDefaults.legend,
annotations,
compare = false,
baseline = "zero",
curve = "monotone",
dots = false,
strokeWidth = STROKE_WIDTH,
...props
}: LineChartProps) {
const ghosts = React.useMemo(() => compareSeries(series, compare), [series, compare])
const config = React.useMemo(() => buildChartConfig([...series, ...ghosts]), [series, ghosts])
const rows = chartRows({ data, index, series, valueFormatter, indexFormatter })
const scale = yAxisScale(data, series, { compare, annotations, baseline })
const summary = chartSummary("Line chart", { data, index, series, indexFormatter })
const inline = showLegend ? legend : "none"
return (
<div
data-slot="line-chart"
data-baseline={baseline}
data-curve={curve}
data-dots={dots || undefined}
className={cn("w-full", className)}
{...props}
>
<ChartPlot
height={height}
config={config}
role="img"
aria-label={summary}
className="aspect-auto w-full"
>
{(frame) => {
const placement = legendPlacement(inline, series, frame.width)
return (
<RechartsLineChart
// Named once, by the container's role="img" and its summary; recharts'
// keyboard layer would add an unnamed role="application" tab stop inside it.
accessibilityLayer={false}
data={data}
margin={{ top: annotationHeadroom(annotations), right: inlineLabelWidth(series, placement), bottom: 0, left: 12 }}
>
{showGrid ? <CartesianGrid vertical={false} {...GRID_PROPS} /> : null}
{showXAxis ? (
<XAxis
tick={AXIS_TICK}
dataKey={index}
{...axisProps}
minTickGap={16}
tickFormatter={(value) => indexFormatter(value)}
/>
) : null}
{frame.yAxis(showYAxis) ? (
<YAxis
tick={AXIS_TICK}
{...axisProps}
width="auto"
domain={scale.domain}
ticks={scale.ticks}
tickFormatter={(value) => valueFormatter(Number(value))}
/>
) : baseline === "auto" ? (
// No printed scale in a box this small, but a fitted plot still
// has to span the data's range rather than recharts' own 0-up one.
<YAxis hide domain={scale.domain} />
) : null}
{showTooltip ? (
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => tooltipIndexLabel(payload, index, indexFormatter)}
formatter={tooltipRow({ valueFormatter, series, compare })}
/>
}
/>
) : null}
{/* The period before this one, behind its own series and dimmed. */}
{ghosts.map((entry) => (
<Line
key={entry.key}
dataKey={entry.key}
name={entry.label}
type={curve}
stroke={`var(--color-${entry.key})`}
{...COMPARE_STROKE}
/>
))}
{series.map((entry) => (
<Line
key={entry.key}
dataKey={entry.key}
name={entry.label}
type={curve}
stroke={`var(--color-${entry.key})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
// A hollow dot: the series' own ring around the surface the
// chart sits on (--chart-surface, falling back to the card), so
// a marker reads as a point on the line rather than a bead.
dot={dots ? { r: 4, strokeWidth: 2, fill: "var(--chart-surface, var(--card))" } : false}
activeDot={ACTIVE_DOT}
isAnimationActive={false}
>
{directLabel(entry, { legend: placement, count: data.length })}
</Line>
))}
{renderAnnotations(annotations)}
{showLegend && placement === "bottom" && series.length > 1 ? (
<ChartLegend content={<ChartLegendContent />} />
) : null}
</RechartsLineChart>
)
}}
</ChartPlot>
<ChartRows
slot="line-chart-data"
label="Line chart data"
rows={rows}
annotations={annotations}
valueFormatter={valueFormatter}
indexFormatter={indexFormatter}
/>
</div>
)
}
export { LineChart }import * as React from "react"
import { formatNumber } from "@/lib/format"
import type { ChartConfig } from "@/components/ui/chart"
import { isChartToken, type ChartToken } from "@/components/ui/percentage-bar"
/** One point of a chart: the index value plus one number per series key. */
export type ChartDatum = Record<string, string | number | null | undefined>
/** One plotted measure — which key to read, what to call it, what to paint it. */
export type ChartSeries = {
key: string
label: string
/** A chart token, or any CSS colour for a brand hue. Defaults to the palette in order. */
color?: ChartToken | string
/**
* The key holding the same measure over the period before this one. Drawn as
* a dashed ghost behind the series and printed in its tooltip row as a delta.
*/
compareKey?: string
/**
* The colour the ghost is drawn in: a chart token or any CSS colour. Left
* out, the ghost wears the series' own colour; `var(--chart-neutral)` keeps
* the period before out of the palette, grey under every preset — including
* one whose first slot follows a coloured accent.
*/
compareColor?: ChartToken | string
}
/** The props every cartesian chart in the set accepts. */
export type CommonChartProps = React.ComponentProps<"div"> & {
data: ChartDatum[]
/** The key holding each point's category — the month, the day, the source. */
index: string
series: ChartSeries[]
/** Formats every number the chart prints: axis ticks, tooltips, and the text summary. */
valueFormatter?: (n: number) => string
/** Formats every index label, e.g. an ISO date into "Sep 4". */
indexFormatter?: (v: string | number) => string
/** Plot height in pixels, axis band included. */
height?: number
showLegend?: boolean
showGrid?: boolean
showXAxis?: boolean
showYAxis?: boolean
showTooltip?: boolean
/** Where the series are named: beside their last point, under the plot, or nowhere. */
legend?: ChartLegendPlacement
/** Goal lines, event markers and bands drawn over the plot and read out as text. */
annotations?: ChartAnnotation[]
/** Draws each series' `compareKey` as a dashed ghost behind it, in the series' `compareColor` if it names one, its own colour if not. */
compare?: boolean
className?: string
}
// The palette wraps rather than inventing a ninth hue: past eight series the
// colours stop being distinguishable, so fold the tail into an "Other" series.
const PALETTE_SIZE = 8
/**
* A series' colour: a chart token becomes its CSS variable, any other string is
* taken as a raw CSS colour, and a series that names none takes the next slot in
* the palette, wrapping past the eighth.
*/
export function seriesColor(series: ChartSeries, i: number): string {
const color = series.color
if (color === undefined) return `var(--chart-${(i % PALETTE_SIZE) + 1})`
return isChartToken(color) ? `var(--${color})` : color
}
/**
* The ChartConfig the chart primitive needs — it turns each entry into a
* `--color-<key>` custom property that recharts marks reference.
*/
export function buildChartConfig(series: ChartSeries[]): ChartConfig {
// fromEntries defines own properties, so a series keyed "__proto__" lands as
// data instead of reassigning the config object's prototype.
return Object.fromEntries(
series.map((entry, i) => [entry.key, { label: entry.label, color: seriesColor(entry, i) }])
)
}
/**
* The shared starting point: a 280px plot with both axes, a grid, direct labels
* at the end of each series, and a tooltip. The value axis is on — a chart
* without a printed scale is a shape, not a measurement — and the charts turn it
* off themselves when the plot is too small to carry one (`fitsYAxis`).
*/
export const chartDefaults = {
height: 280,
showLegend: true,
showGrid: true,
showXAxis: true,
showYAxis: true,
showTooltip: true,
legend: "inline-end",
} as const
/**
* A drawn line: 2px with round caps and joins reads as a stroke at any size
* and holds its own on a white sheet, where 1.5px thinned to a hair on a
* high-density screen.
*/
export const STROKE_WIDTH = 2
/**
* The gridline: the grid token, dashed, so a rule under the data reads as a
* guide rather than as a border. Every cartesian chart spreads this onto its
* CartesianGrid.
*/
export const GRID_PROPS = { stroke: "var(--chart-grid)", strokeDasharray: "3 3" } as const
/**
* The dot under the pointer: 3.5px of the series' own colour, ringed in the
* surface it sits on so it stays legible where two lines cross.
* --chart-surface falls back to the card a chart normally lives on; set it on
* any ancestor when the chart sits on another plane.
*/
export const ACTIVE_DOT = { r: 3.5, strokeWidth: 2, stroke: "var(--chart-surface, var(--card))" } as const
/**
* The corner a column turns, and the gap between two segments of one stack. A
* stack is rounded as one shape — the top corners on the topmost segment, the
* bottom corners on the lowest — so a bar reads as a printed block rather than
* a pile of separately rounded tiles.
*/
export const BAR_RADIUS = 6
export const BAR_GAP = 1
/** Axis chrome: no rule and no tick marks, so only the labels carry the scale. */
export const axisProps = { tickLine: false, axisLine: false, tickMargin: 8, fontSize: 12 } as const
/**
* The paint every axis label takes: --chart-axis, the token §3.8 names for the
* printed scale. recharts writes fill="#666" onto its own
* tick text, and the primitive's `.recharts-cartesian-axis-tick text` rule does
* not reach it — recharts 3 nests labels under
* `.recharts-cartesian-axis-tick-labels` instead — so #666 survived on the card
* at 3.16:1 in dark. Passing the fill as a tick prop puts it on the element
* itself, where nothing has to match a selector.
*/
export const AXIS_TICK = { fill: "var(--chart-axis)" } as const
/** Chart numbers fall back to thousands-separated values when no valueFormatter is given. */
export function defaultValueFormatter(value: number): string {
return formatNumber(value)
}
/** Index labels print as they arrive unless the chart is given an indexFormatter. */
export function defaultIndexFormatter(value: string | number): string {
return String(value)
}
/** What the text helpers need to turn a chart's data back into words. */
export type ChartTextOptions = {
data: ChartDatum[]
index: string
series: ChartSeries[]
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
}
function readIndex(datum: ChartDatum, index: string, format: (v: string | number) => string) {
const raw = datum[index]
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
function readValue(raw: ChartDatum[string], format: (n: number) => string) {
return typeof raw === "number" && Number.isFinite(raw) ? format(raw) : "no data"
}
function joinLabels(labels: string[]): string {
if (labels.length < 2) return labels.join("")
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
}
/**
* The plotted data as text, one row per point. Every chart renders this into a
* visually hidden list, so the numbers are readable without pointing at a tooltip.
*/
export function chartRows(options: ChartTextOptions): { label: string; readings: string }[] {
const {
data,
index,
series,
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
return data.map((datum) => ({
label: readIndex(datum, index, indexFormatter),
readings: series
.map((entry) => `${entry.label} ${readValue(datum[entry.key], valueFormatter)}`)
.join(", "),
}))
}
/**
* The heading for a tooltip: the point's own index value, read straight off the
* datum. The primitive resolves its label through the config, which only works
* when the index is a string — reading the datum keeps numeric indexes intact.
*/
export function tooltipIndexLabel(
payload: ReadonlyArray<{ payload?: unknown }> | undefined,
index: string,
format: (v: string | number) => string = defaultIndexFormatter
): string {
const datum = payload?.[0]?.payload
const raw = datum && typeof datum === "object" ? (datum as ChartDatum)[index] : undefined
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
/** A one-sentence description of what a chart plots, used as its accessible name. */
export function chartSummary(kind: string, options: ChartTextOptions): string {
const { data, index, series, indexFormatter = defaultIndexFormatter } = options
if (series.length === 0) return `${kind} with no series.`
const labels = joinLabels(series.map((entry) => entry.label))
if (data.length === 0) return `${kind} of ${labels} by ${index}. No data.`
const first = readIndex(data[0], index, indexFormatter)
const last = readIndex(data[data.length - 1], index, indexFormatter)
const count = `${data.length} point${data.length === 1 ? "" : "s"}`
return `${kind} of ${labels} by ${index}, ${count} from ${first} to ${last}.`
}
/* -------------------------------------------------------------------------- */
/* Annotations */
/* -------------------------------------------------------------------------- */
/** Where a chart names its series. */
export type ChartLegendPlacement = "inline-end" | "bottom" | "none"
/** The meanings an annotation can carry; each resolves to a token, never a literal. */
export type ChartAnnotationTone = "neutral" | "brand" | "positive" | "negative" | "warning" | "info"
export type ChartAnnotation =
/** A threshold across the plot: a goal, a limit, an included allowance. */
| { kind: "line"; axis?: "x" | "y"; value: number | string; label: string; tone?: ChartAnnotationTone }
/** A moment on the index axis: a launch, a deploy, an incident. */
| { kind: "event"; x: number | string; label: string; tone?: ChartAnnotationTone; href?: string }
/**
* A stretch of the index axis: a freeze, an outage, a campaign. The label
* reads from the band's start; `align: "end"` hangs it from the band's end
* instead, for a band that runs to the edge of the plot — a 59px word over
* a 30px band at the right edge otherwise runs out of the plot.
*/
| {
kind: "band"
from: number | string
to: number | string
label: string
tone?: ChartAnnotationTone
align?: "start" | "end"
}
const ANNOTATION_PAINT: Record<ChartAnnotationTone, string> = {
neutral: "var(--faint-foreground)",
brand: "var(--brand)",
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
warning: "var(--warning)",
info: "var(--info)",
}
/**
* The paint a *meaning* takes, as opposed to a category.
*
* A series that is coded by status — 200/429/500, up/down, paid/overdue — is
* not one of eight interchangeable hues: it has to resolve to the semantic
* tokens, or a reader learns the wrong colour for "failed" on one page and
* carries it to the next. Categories keep the palette; meanings come from here.
*/
export const CHART_TONES = {
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
neutral: "var(--chart-neutral)",
warning: "var(--warning)",
info: "var(--info)",
} as const
export type ChartTone = keyof typeof CHART_TONES
export function chartTone(tone: ChartTone): string {
return CHART_TONES[tone]
}
/** The token an annotation's tone paints in. */
export function annotationPaint(tone: ChartAnnotationTone = "neutral"): string {
return ANNOTATION_PAINT[tone] ?? ANNOTATION_PAINT.neutral
}
/** The numbers an annotation pins to the value axis, so the scale can hold them. */
export function annotationValues(annotation: ChartAnnotation): number[] {
if (annotation.kind === "line" && annotation.axis !== "x" && typeof annotation.value === "number")
return [annotation.value]
return []
}
/**
* Every annotation as a line of text, appended to a chart's visually hidden
* rows: a goal line a sighted reader can see has to be readable too.
*/
export function annotationRows(
annotations: ChartAnnotation[] = [],
options: {
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
} = {}
): string[] {
const {
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
const at = (value: number | string) =>
typeof value === "number" ? valueFormatter(value) : indexFormatter(value)
return annotations.map((annotation) => {
if (annotation.kind === "line")
return `${annotation.label}: ${
annotation.axis === "x" ? indexFormatter(annotation.value) : at(annotation.value)
}`
if (annotation.kind === "event") return `${annotation.label} at ${indexFormatter(annotation.x)}`
return `${annotation.label}: ${indexFormatter(annotation.from)} to ${indexFormatter(annotation.to)}`
})
}import {
annotationValues,
type ChartAnnotation,
type ChartDatum,
type ChartSeries,
} from "@/components/ui/chart-core"
// A printed scale lands on steps a reader can add up in their head. Anything
// else — recharts' own 0/650/1.3K/2K/2.6K — makes the reader do arithmetic to
// place a point between two gridlines.
const NICE_STEPS = [1, 2, 2.5, 5, 10] as const
/** Rounds a rough interval up to the next 1, 2, 2.5 or 5 × 10ⁿ. */
export function niceStep(rough: number): number {
if (!Number.isFinite(rough) || rough <= 0) return 1
const magnitude = 10 ** Math.floor(Math.log10(rough))
const scaled = rough / magnitude
const step = NICE_STEPS.find((candidate) => scaled <= candidate * (1 + 1e-9)) ?? 10
return step * magnitude
}
// Summing a float step accumulates error, so each tick is computed from the
// index and then snapped back to the precision the step itself carries.
const snap = (value: number): number => Number(value.toPrecision(12))
/**
* Where a value axis starts. `zero` — the default everywhere — always takes
* zero in, because a count, a sum or a share is read against nothing, and a
* bar that does not start there lies about its length. `auto` fits the axis
* to the data's own range, on the same nice steps, for a level whose
* movement is the reading — a rate, a price, a temperature — and which a
* zero floor would flatten into a line along the top of the plot.
*/
export type ChartBaseline = "zero" | "auto"
/** Ticks from `bottom` to `top` on `step`, both ends included. */
function ticksBetween(bottom: number, top: number, step: number): number[] {
const ticks: number[] = []
for (let i = 0; bottom + i * step <= top + step / 2; i += 1) ticks.push(snap(bottom + i * step))
return ticks
}
/** The ticks and the domain for a range, on nice steps, both ends included. */
export function niceDomain(
min: number,
max: number,
count = 4,
options: { baseline?: ChartBaseline } = {}
): { domain: [number, number]; ticks: number[] } {
if (options.baseline === "auto") return fittedDomain(min, max, count)
const low = Math.min(0, Number.isFinite(min) ? min : 0)
const high = Math.max(Number.isFinite(max) ? max : 0, low)
// A flat series at zero still needs two gridlines, or the plot has no scale.
if (high === low) return { domain: [low, low + 1], ticks: [low, low + 1] }
const step = niceStep((high - low) / Math.max(1, count))
const bottom = Math.floor(low / step + 1e-9) * step
const top = Math.ceil(high / step - 1e-9) * step
return { domain: [snap(bottom), snap(top)], ticks: ticksBetween(bottom, top, step) }
}
/**
* The `auto` baseline: the data's own range, widened out to the nice steps
* either side of it, so 1.05–1.11 prints 1.04 / 1.06 / … / 1.12 rather than
* 0 / 0.5 / 1 / 1.5. A flat level still gets a scale — a step either side of
* it — and nothing to plot falls back to the zero baseline's 0 / 1.
*
* A peak drawn on the frame's edge reads as clipped, so an extreme within a
* quarter of a step of the edge gets one step more: 1.05–1.12 prints up to
* 1.14. That air never takes the axis across zero, which a level that does
* not cross it has no business reaching past.
*/
function fittedDomain(min: number, max: number, count: number): { domain: [number, number]; ticks: number[] } {
const lowest = Number.isFinite(min) ? min : 0
const highest = Number.isFinite(max) ? Math.max(max, lowest) : lowest
let low = lowest
let high = highest
if (high === low) {
if (low === 0) return niceDomain(0, 0, count)
const pad = niceStep(Math.abs(low) / 10)
low -= pad
high += pad
}
const step = niceStep((high - low) / Math.max(1, count))
let bottom = Math.floor(low / step + 1e-9) * step
let top = Math.ceil(high / step - 1e-9) * step
if (lowest - bottom < step / 4) bottom = lowest >= 0 ? Math.max(0, bottom - step) : bottom - step
if (top - highest < step / 4) top = highest <= 0 ? Math.min(0, top + step) : top + step
return { domain: [snap(bottom), snap(top)], ticks: ticksBetween(bottom, top, step) }
}
/** The printed scale from 0 to `max`: `niceTicks(2_900, 4)` is 0, 1000, 2000, 3000. */
export function niceTicks(max: number, count = 4): number[] {
return niceDomain(0, max, count).ticks
}
/**
* The lowest and highest number a set of series reaches, stacked or laid over
* one another. On the zero baseline the extent always takes zero in, so a
* hole in the data or a series of nothing reads as 0 / 0; on `auto` it is the
* data's own range, and ±Infinity when there is nothing to read.
*/
export function chartExtent(
data: ChartDatum[],
series: ChartSeries[],
options: { stacked?: boolean; compare?: boolean; baseline?: ChartBaseline } = {}
): { min: number; max: number } {
const keys = series.flatMap((entry) =>
options.compare && entry.compareKey ? [entry.key, entry.compareKey] : [entry.key]
)
const fitted = options.baseline === "auto"
let min = fitted ? Infinity : 0
let max = fitted ? -Infinity : 0
for (const datum of data) {
let stack = 0
// A row of holes is no stack at all — not a stack of zero, which a
// fitted axis would otherwise have to reach down to.
let stacked = false
for (const key of keys) {
const raw = datum[key]
if (typeof raw !== "number" || !Number.isFinite(raw)) continue
if (options.stacked) {
stack += raw
stacked = true
} else {
if (raw < min) min = raw
if (raw > max) max = raw
}
}
if (stacked) {
if (stack < min) min = stack
if (stack > max) max = stack
}
}
return { min, max }
}
/** A y axis' explicit domain and ticks for the data it has to hold. */
export function yAxisScale(
data: ChartDatum[],
series: ChartSeries[],
options: {
stacked?: boolean
compare?: boolean
count?: number
annotations?: ChartAnnotation[]
/** Where the axis starts; zero unless the chart asks to fit its data. */
baseline?: ChartBaseline
} = {}
): { domain: [number, number]; ticks: number[] } {
const { min, max } = chartExtent(data, series, options)
// A goal line above every plotted point still has to fit inside the plot.
let low = min
let high = max
for (const annotation of options.annotations ?? []) {
for (const value of annotationValues(annotation)) {
if (value < low) low = value
if (value > high) high = value
}
}
return niceDomain(low, high, options.count ?? 4, { baseline: options.baseline })
}
// Below these the axis band costs more than the scale it prints: the labels
// crowd the plot at 120px tall and eat a third of the width at 240px wide.
const MIN_AXIS_HEIGHT = 120
const MIN_AXIS_WIDTH = 240
/**
* Whether a y axis fits. Width 0 means "not measured yet" — on the server, and
* in a test with no layout — and is never taken as "too narrow".
*/
export function fitsYAxis(height: number, width = 0): boolean {
if (height < MIN_AXIS_HEIGHT) return false
return width === 0 || width >= MIN_AXIS_WIDTH
}"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { ChartContainer } from "@/components/ui/chart"
import { annotationRows, type ChartAnnotation } from "@/components/ui/chart-core"
import { fitsYAxis } from "@/components/ui/chart-scale"
import { ChartSkeleton } from "@/components/ui/loading-skeletons"
/* -------------------------------------------------------------------------- */
/* The frame: measure, then draw */
/* -------------------------------------------------------------------------- */
// A ChartSkeleton is a legend, a plot and a row of tick labels; the two text
// rows and their gaps take about this much, so the skeleton reserves the same
// box the finished chart will fill.
const SKELETON_CHROME = 56
/** What a chart's body is told about the box it is being drawn into. */
export type ChartFrame = {
/** The plot's own width in px, or 0 before it has been measured. */
width: number
/** False until one layout has happened — the skeleton holds the space until then. */
measured: boolean
reduced: boolean
/** Whether the value axis both was asked for and fits in the box. */
yAxis: (asked: boolean) => boolean
}
export type ChartPlotProps = Omit<React.ComponentProps<typeof ChartContainer>, "children"> & {
height: number
/** The recharts chart to draw, given what is known about the box. */
children: (frame: ChartFrame) => React.ComponentProps<typeof ChartContainer>["children"]
}
/**
* A chart's box: measured first, drawn second.
*
* Nothing can be measured on the server, so the skeleton holds the chart's
* space until the first client layout, and the ChartContainer inside then
* measures its own box and draws at that size — which is also what makes the
* mount fade a fade of the finished chart rather than of a mis-sized one. The
* width measured here is what decides whether a value axis fits, so that
* decision is handed to the chart body rather than guessed at.
*/
function ChartPlot({ height, className, style, children, ...props }: ChartPlotProps) {
const [width, setWidth] = React.useState(0)
const [measured, setMeasured] = React.useState(false)
const plot = React.useRef<HTMLDivElement | null>(null)
// Asked of the plot itself, so a frame that asked for less motion on its own
// wrapper is heard; the document root would not have carried it.
const reduced = useReducedMotion(plot)
const ref = React.useCallback((node: HTMLDivElement | null) => {
plot.current = node
if (!node) return
const read = () => setWidth(Math.round(node.getBoundingClientRect().width))
read()
// One layout has now happened, whether or not it produced a number: an
// environment with no layout engine reports 0 forever, and holding a
// skeleton there would mean no chart is ever drawn at all.
setMeasured(true)
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(read)
observer.observe(node)
return () => observer.disconnect()
}, [])
return (
<div ref={ref} data-slot="chart-plot" className="w-full">
{measured ? (
<ChartContainer
// The one piece of motion a chart has: it fades in when it first has a
// size, and never again — a re-render on new data leaves the element
// mounted, so the animation does not restart. Duration and easing are
// the theme's tokens, which are zeroed under reduced motion.
className={cn(
!reduced && "animate-in fade-in-0 duration-(--duration-base) ease-(--ease-standard)",
className
)}
style={{ height, ...style }}
{...props}
>
{children({
width,
measured,
reduced,
yAxis: (asked: boolean) => asked && fitsYAxis(height, width),
})}
</ChartContainer>
) : (
<ChartSkeleton height={Math.max(80, height - SKELETON_CHROME)} />
)}
</div>
)
}
export type ChartRowsProps = {
slot: string
label: string
rows: { label: string; readings: string }[]
annotations?: ChartAnnotation[]
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
}
/**
* A chart's numbers as text. Every plotted point, then every annotation drawn
* over them — a goal line a sighted reader can see has to be readable too.
*/
function ChartRows({
slot,
label,
rows,
annotations,
valueFormatter,
indexFormatter,
}: ChartRowsProps) {
const notes = annotationRows(annotations, { valueFormatter, indexFormatter })
return (
<ul data-slot={slot} aria-label={label} className="sr-only">
{rows.map((row, i) => (
<li key={`row-${i}`}>{`${row.label}: ${row.readings}`}</li>
))}
{notes.map((note, i) => (
<li key={`note-${i}`} data-slot="chart-annotation-row">
{note}
</li>
))}
</ul>
)
}
export { ChartPlot, ChartRows }"use client"
import * as React from "react"
import { ReferenceArea, ReferenceLine } from "recharts"
import { annotationPaint, type ChartAnnotation } from "@/components/ui/chart-core"
/* -------------------------------------------------------------------------- */
/* Annotations */
/* -------------------------------------------------------------------------- */
// The eyebrow register, drawn in SVG: 11px, 600, the tone's own ink.
const EYEBROW = {
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.06em",
} as const
/** The one dash on a chart: reference lines and the compare ghost share it. */
export const DASH = "4 4"
/**
* A halo round a label in the plane the chart sits on: the stroke is painted
* under the fill, so the words stay legible where they cross a column, a
* line or the wash under an area. --chart-surface falls back to the card, as
* the active dot's ring does.
*/
export const LABEL_HALO = {
stroke: "var(--chart-surface, var(--card))",
strokeWidth: 4,
strokeLinejoin: "round",
paintOrder: "stroke",
} as const
function annotationLabel(
text: string,
paint: string,
position: "insideTopLeft" | "insideTopRight" | "top"
) {
return {
value: text.toUpperCase(),
position,
fill: paint,
offset: 6,
...EYEBROW,
...LABEL_HALO,
}
}
/**
* Goal lines, event markers and bands as recharts elements.
*
* An array rather than a fragment: recharts reads its children by type, and
* `React.Children` flattens an array into that list while a fragment arrives as
* one opaque child it does not look inside.
*/
export function renderAnnotations(
annotations: ChartAnnotation[] = [],
options: { yAxisId?: string } = {}
): React.ReactElement[] {
const { yAxisId } = options
return annotations.map((annotation, i) => {
const paint = annotationPaint(annotation.tone)
if (annotation.kind === "band") {
return (
<ReferenceArea
key={`band-${i}`}
yAxisId={yAxisId}
x1={annotation.from}
x2={annotation.to}
fill={paint}
fillOpacity={0.1}
stroke="none"
aria-label={annotation.label}
label={annotationLabel(
annotation.label,
paint,
annotation.align === "end" ? "insideTopRight" : "insideTopLeft"
)}
/>
)
}
if (annotation.kind === "event") {
return (
<ReferenceLine
key={`event-${i}`}
yAxisId={yAxisId}
x={annotation.x}
stroke={paint}
strokeWidth={1}
strokeDasharray={DASH}
label={annotationLabel(annotation.label, paint, "top")}
/>
)
}
const onX = annotation.axis === "x"
return (
<ReferenceLine
key={`line-${i}`}
yAxisId={yAxisId}
x={onX ? annotation.value : undefined}
y={onX ? undefined : annotation.value}
stroke={paint}
strokeWidth={1}
strokeDasharray={DASH}
label={annotationLabel(annotation.label, paint, onX ? "top" : "insideTopLeft")}
/>
)
})
}"use client"
import * as React from "react"
import { LabelList } from "recharts"
import {
type ChartAnnotation,
type ChartLegendPlacement,
type ChartSeries,
} from "@/components/ui/chart-core"
/* -------------------------------------------------------------------------- */
/* Direct labels */
/* -------------------------------------------------------------------------- */
// An 8px swatch and 12px text: the series colour carries the identity and the
// ink carries the reading, because slots 5–8 as 13px coloured text miss AA.
const SWATCH = 8
const LABEL_GAP = 6
const CHAR_WIDTH = 6.4
type DirectLabelProps = {
x?: number
y?: number
index?: number
last?: number
text?: string
paint?: string
}
function DirectLabelMark({ x = 0, y = 0, index, last, text = "", paint }: DirectLabelProps) {
if (index !== last) return null
return (
<g transform={`translate(${x + LABEL_GAP}, ${y})`} data-slot="chart-direct-label">
<rect y={-SWATCH / 2} width={SWATCH} height={SWATCH} rx={2} fill={paint} />
<text
x={SWATCH + 4}
dy="0.32em"
fill="var(--foreground)"
fontSize={12}
data-slot="chart-direct-label-text"
>
{text}
</text>
</g>
)
}
/**
* A series' name set beside its own last point, so a reader never has to match a
* swatch in a legend to a line in a plot. Returns null unless the chart is
* drawing its legend inline, which is what makes it safe to write into every mark.
*/
export function directLabel(
entry: ChartSeries,
options: { legend: ChartLegendPlacement; count: number }
): React.ReactElement | null {
if (options.legend !== "inline-end" || options.count === 0) return null
return (
<LabelList
key={`label-${entry.key}`}
dataKey={entry.key}
content={
<DirectLabelMark
last={options.count - 1}
text={entry.label}
paint={`var(--color-${entry.key})`}
/>
}
/>
)
}
/**
* The headroom an annotation label needs. A marker on the index axis writes its
* eyebrow above the plot, and 12px of margin clips the ascender off it.
*/
export function annotationHeadroom(annotations: ChartAnnotation[] = []): number {
return annotations.some((a) => a.kind === "event" || (a.kind === "line" && a.axis === "x"))
? 24
: 12
}
/** The right margin the inline labels need, so the longest one is not clipped. */
export function inlineLabelWidth(series: ChartSeries[], legend: ChartLegendPlacement): number {
if (legend !== "inline-end") return 12
const longest = series.reduce((most, entry) => Math.max(most, entry.label.length), 0)
return Math.min(180, LABEL_GAP + SWATCH + 4 + Math.ceil(longest * CHAR_WIDTH) + 8)
}
/**
* Where the legend goes once the box is known. Inline labels that would take
* more than a quarter of the plot — a 390px phone gave billing-usage 84px of a
* 318px one — go under it instead; before the first measurement the answer is
* what was asked for, so the server render and the client's first one agree.
*/
export function legendPlacement(
asked: ChartLegendPlacement,
series: ChartSeries[],
width: number
): ChartLegendPlacement {
// A lone series has no legend to fall back to — the charts draw one for two
// or more — so it keeps its label, which is the cheaper of the two anyway.
if (asked !== "inline-end" || width === 0 || series.length < 2) return asked
return inlineLabelWidth(series, asked) > width / 4 ? "bottom" : asked
}
export { DirectLabelMark }"use client"
import * as React from "react"
import { DASH } from "@/components/ui/chart-annotations"
import {
defaultValueFormatter,
seriesColor,
type ChartDatum,
type ChartSeries,
} from "@/components/ui/chart-core"
import { MetricDelta } from "@/components/ui/metric-delta"
/* -------------------------------------------------------------------------- */
/* Tooltip rows */
/* -------------------------------------------------------------------------- */
export type TooltipRowOptions = {
valueFormatter?: (n: number) => string
/** The chart's series, so a row can find its own compare key. */
series?: ChartSeries[]
compare?: boolean
}
/**
* One tooltip row: swatch, series name, the value through the chart's own
* formatter, and — when a compare period is drawn — how far it is from the same
* point a period ago.
*/
export function tooltipRow(options: TooltipRowOptions = {}) {
const { valueFormatter = defaultValueFormatter, series = [], compare = false } = options
return function renderRow(value: unknown, name: unknown, item: unknown) {
// recharts types the payload entry as widely as its own generics allow, so
// the two fields this row reads are narrowed here rather than in the signature.
const point = item as { color?: string; dataKey?: unknown; payload?: ChartDatum }
const entry = series.find((candidate) => candidate.key === point.dataKey)
const previous =
compare && entry?.compareKey ? point.payload?.[entry.compareKey] : undefined
const current = Number(value)
const delta =
typeof previous === "number" && previous !== 0 && Number.isFinite(current)
? current / previous - 1
: undefined
return (
<>
<span
className="size-2.5 shrink-0 rounded-[2px]"
style={{ backgroundColor: point.color }}
/>
<div className="flex flex-1 items-center justify-between gap-3 leading-none">
<span className="text-muted-foreground">{String(name)}</span>
<span className="flex items-center gap-2">
<span className="font-medium tabular-nums">{valueFormatter(current)}</span>
{delta === undefined ? null : (
<MetricDelta value={delta} size="sm" showIcon={false} className="text-2xs" />
)}
</span>
</div>
</>
)
}
}
/**
* The ghost's stroke: dashed and dimmed, in the series' own colour — or in
* its compareColor when it names one, as a page does to keep the period
* before out of the palette with var(--chart-neutral). 0.75 is measured
* rather than chosen — 0.65 of the default theme's first chart hue
* composites to 2.73:1 on a white card and 2.96:1 on a dark one, both under
* the 3:1 a line has to clear; 0.75 reads 3.24 light and 3.53 dark. The dash
* is what carries the meaning anyway, so the opacity only has to stay out of
* the way.
*/
export const COMPARE_STROKE = {
strokeDasharray: DASH,
strokeOpacity: 0.75,
strokeWidth: 1.5,
legendType: "none",
tooltipType: "none",
dot: false,
activeDot: false,
isAnimationActive: false,
} as const
/** The compare series a chart should draw behind its own: each in its series' compareColor, or the series' own colour when it names none. */
export function compareSeries(series: ChartSeries[], compare: boolean): ChartSeries[] {
if (!compare) return []
return series.flatMap((entry, i) =>
entry.compareKey
? [{ key: entry.compareKey, label: `${entry.label} (prev)`, color: entry.compareColor ?? seriesColor(entry, i) }]
: []
)
}