Skip to contentVibraUI
Charts

Area chart

A trend over time with the area beneath it filled, stacked into bands or laid over itself.

Stacked bands keep most of their colour so they read through one another; an overlapping area is a flat tenth of its own stroke, so the series behind still shows — gradient is off by default, because the look prints its fills. The stroke is 1.5px with round caps and the active dot 3.5px, ringed in the surface colour: --chart-surface is the colour the gaps and rings 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. The data is also rendered as a visually hidden list, so every value is readable without a pointer, and every annotation is appended to it. Nothing draws itself in: the plot fades once when it first has a size, on the theme's own duration token, and never again.

Install

npx shadcn@latest add @vibra/area-chart

Needs the @vibra registry in your components.json — set it up once.

Examples

Stacked

Three regions as bands that add up to total revenue.

Compare

The period before as a dashed ghost, behind a Compare toggle bound to c.

Props

PropTypeDefaultDescription
dataChartDatum[]—The points to plot; each one holds the index value and a number per series key.
indexstring—The key holding each point's category — the month, the day, the source.
seriesChartSeries[]palette in orderThe 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) => stringthousands-separated numberFormats every number the chart prints: axis ticks, tooltip values, and the text summary.
indexFormatter(v: string | number) => stringthe value as-isFormats every index label, e.g. an ISO date into "Sep 4".
heightnumber280Plot 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.
annotationsChartAnnotation[]—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.
comparebooleanfalseDraws 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.
showLegendbooleantrueShows the legend. A single-series chart never draws one — there is only one colour, so the card title already names it.
showGridbooleantrueHairline reference lines, drawn only across the direction the marks grow in.
showTooltipbooleantrueShows the hover tooltip. Every value is in the chart's visually hidden list either way.
showXAxisbooleantrueShows the category labels. The axis rule and the tick marks are always off.
showYAxisbooleantrueShows 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.
stackedbooleanfalseBands summing to a total instead of curves laid over one another.
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. For a level, prefer LineChart: on auto the fill runs down to the plot's floor, not to zero, so an area's size is no magnitude. Ignored when stacked — a stack is read from zero. The fitted range is kept when the printed scale is dropped from a small plot.
curve"monotone" | "linear" | "step""monotone"How the line between points is drawn.
gradientbooleanfalseFades each fill toward the baseline instead of the flat wash the look prints.

Dependencies

Source

components/ui/area-chart.tsx
"use client"

import * as React from "react"
import { Area, CartesianGrid, AreaChart as RechartsAreaChart, 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 AreaChartProps = CommonChartProps & {
  /** Bands summing to a total instead of curves laid over one another. */
  stacked?: boolean
  /**
   * 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. For a level,
   * prefer `LineChart`: on `auto` an area's fill runs down to the plot's
   * floor, not to zero, so its size is no magnitude. Ignored when `stacked`:
   * a stack is read from zero.
   */
  baseline?: ChartBaseline
  curve?: "monotone" | "linear" | "step"
  /** Fades each fill toward the baseline. Off by default: the look prints flat washes. */
  gradient?: boolean
}

// A wash, not a block: an overlaid area is a tenth of its stroke's colour, so
// the curve behind it still reads. A stacked band is the mark itself, so it
// carries enough paint to be told from its neighbour.
const FILL_OPACITY = { stacked: 0.65, overlaid: 0.1 } as const
const GRADIENT_STOPS = {
  stacked: { top: 0.85, bottom: 0.45 },
  overlaid: { top: 0.3, bottom: 0.02 },
} as const

function AreaChart({
  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,
  stacked = false,
  baseline = "zero",
  curve = "monotone",
  gradient = false,
  ...props
}: AreaChartProps) {
  const ghosts = React.useMemo(() => compareSeries(series, compare), [series, compare])
  const config = React.useMemo(
    () => buildChartConfig([...series, ...ghosts]),
    [series, ghosts]
  )
  // useId's colons are not valid in a url(#...) reference.
  const uid = React.useId().replace(/:/g, "")
  const stops = stacked ? GRADIENT_STOPS.stacked : GRADIENT_STOPS.overlaid
  const rows = chartRows({ data, index, series, valueFormatter, indexFormatter })
  // A stack is read from zero: fitted to its totals, every band below the
  // smallest total would be drawn with no scale beneath it.
  const floor: ChartBaseline = stacked ? "zero" : baseline
  const scale = yAxisScale(data, series, { stacked, compare, annotations, baseline: floor })
  const summary = chartSummary("Area chart", { data, index, series, indexFormatter })
  const inline = showLegend ? legend : "none"

  return (
    <div
      data-slot="area-chart"
      data-baseline={floor}
      data-curve={curve}
      data-stacked={stacked || undefined}
      data-gradient={gradient || 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 (
          <RechartsAreaChart
            // 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 }}
          >
            {gradient ? (
              <defs>
                {series.map((entry) => (
                  <linearGradient
                    key={entry.key}
                    id={`${uid}-${entry.key}`}
                    x1="0"
                    y1="0"
                    x2="0"
                    y2="1"
                  >
                    <stop offset="0%" stopColor={`var(--color-${entry.key})`} stopOpacity={stops.top} />
                    <stop
                      offset="100%"
                      stopColor={`var(--color-${entry.key})`}
                      stopOpacity={stops.bottom}
                    />
                  </linearGradient>
                ))}
              </defs>
            ) : null}

            {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))}
              />
            ) : floor === "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) => (
              <Area
                key={entry.key}
                dataKey={entry.key}
                name={entry.label}
                type={curve}
                stroke={`var(--color-${entry.key})`}
                fill="none"
                {...COMPARE_STROKE}
              />
            ))}

            {series.map((entry) => (
              <Area
                key={entry.key}
                dataKey={entry.key}
                name={entry.label}
                type={curve}
                stackId={stacked ? "total" : undefined}
                stroke={`var(--color-${entry.key})`}
                strokeWidth={STROKE_WIDTH}
                strokeLinecap="round"
                strokeLinejoin="round"
                fill={gradient ? `url(#${uid}-${entry.key})` : `var(--color-${entry.key})`}
                fillOpacity={gradient ? 1 : FILL_OPACITY[stacked ? "stacked" : "overlaid"]}
                dot={false}
                activeDot={ACTIVE_DOT}
                isAnimationActive={false}
              >
                {directLabel(entry, { legend: placement, count: data.length })}
              </Area>
            ))}

            {renderAnnotations(annotations)}

            {showLegend && placement === "bottom" && series.length > 1 ? (
              <ChartLegend content={<ChartLegendContent />} />
            ) : null}
          </RechartsAreaChart>
          )
        }}
      </ChartPlot>

      <ChartRows
        slot="area-chart-data"
        label="Area chart data"
        rows={rows}
        annotations={annotations}
        valueFormatter={valueFormatter}
        indexFormatter={indexFormatter}
      />
    </div>
  )
}

export { AreaChart }
components/ui/chart-core.ts
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)}`
  })
}
components/ui/chart-scale.ts
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
}
components/ui/chart-frame.tsx
"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 }
components/ui/chart-annotations.tsx
"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")}
      />
    )
  })
}
components/ui/chart-labels.tsx
"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 }
components/ui/chart-tooltip.tsx
"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) }]
      : []
  )
}