Charts

Multi-Series Line Chart

A four-state multi-series line chart whose shared crosshair reads every series at the hovered x, ranked by value, with null gaps left broken.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"

import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import type { ChartLineMultiData } from "./chart-line-multi.contract"

export interface ChartLineMultiProps extends ChartLineMultiData {
  onRetry?: () => void
  className?: string
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-line-multi.json

Prompt

Build a React + TypeScript + Tailwind "ChartLineMulti" dashboard card on the
shadcn chart primitives (ChartContainer/ChartTooltip over recharts) with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    unit?: string;
    series: { key, label, points: { x: string, value: number | null }[] }[] }.
- At most 5 series — one per chart token. value === null means "no reading",
  never zero. x is a label rendered as-is, so callers keep control of
  granularity (hours, ISO days, week numbers).
- The schema refines that every series carries the same x sequence point for
  point: a shared crosshair reads one row per x, so a series with nothing to
  report at some x carries null there instead of a shorter array.
- Component props = z.infer of the schema, plus onRetry?: () => void and
  className. No hand-written parallel interface.

Behavior
- Pivot the series-major contract into row-major rows ({ x, [key]: value })
  in a memo; plot one <Line dataKey={key}> per series over those rows.
- Gaps: connectNulls={false}, so a null splits the path into separate
  subpaths rather than drawing a straight line across missing readings, and
  no active dot appears at a missing point.
- Shared crosshair tooltip: hovering anywhere in the plot lists every series
  at that x in one panel, ranked by value descending, with missing readings
  sunk to the bottom as "—". The panel reads the whole pivoted row from
  payload[0].payload, so a null series is still listed instead of quietly
  dropping out of the tooltip payload.
- Highlight: legend chips are real <button type="button">. Hover or focus
  highlights that series (the rest drop to strokeOpacity 0.25, the chosen one
  thickens); click pins it (aria-pressed), clicking again unpins. The
  effective key is hovered ?? pinned, re-derived against the current series
  every render, so a key left over from a previous payload simply stops
  matching instead of dimming the whole chart.
- Y domain is fitted to the data and rounded outward to a 1/2/5x10^n step, so
  crossings stay legible and ticks stay round; an all-equal series gets a +/-1
  window instead of a zero-height domain.
- The four states are first-class branches inside one bg-card panel:
  - loading: skeleton title + chips over a pulsing two-polyline silhouette at
    the same plot height, aria-hidden.
  - empty: outline polyline + "No data yet" + one explanatory line; a ready
    payload with nothing plottable falls into this branch too.
  - error: message + a "Try again" button rendered only when onRetry exists.
  - ready: header + legend + chart + sr-only data table.

Rendering & styling
- Colors come only from the chart tokens: series i uses
  var(--chart-{(i % 5) + 1}) for its Line stroke, its active dot, its legend
  swatch and its tooltip swatch — one formula, four consumers.
- Series identity carries a second, color-independent channel: index maps to a
  dash pattern (solid, "6 4", "2 5", "12 5 2 5", "1 5") echoed in the legend
  and tooltip swatches, so the chart still reads when the host palette is a
  monochrome ramp or unsafe for color-blind readers — five thin strokes are
  the worst case for telling neighbouring hues apart.
- Accessibility: the plot area is role="img" with a generated aria-label
  (title, series count, span, each series' first-to-last value and how many
  readings are missing). recharts' accessibilityLayer is switched off so no
  focusable role="application" svg sits inside that image subtree, and the
  exact numbers live in a sibling data table hidden by an sr-only *wrapper
  div* — a bare sr-only table keeps auto table layout, ignores width:1px and
  pushes ~300px of horizontal document overflow on a 375px screen.
- Animation: the line draw-in runs unless prefers-reduced-motion is set, read
  through useSyncExternalStore over matchMedia with a false server snapshot.
- Axes: XAxis on the x key, tickLine/axisLine off, minTickGap ~24 so labels
  thin out instead of overlapping at 375px; YAxis width 40 with compact
  notation only at >= 10,000 (below that "1,050" and "1,100" would both
  collapse to "1.1K").
- Panel: rounded-xl border bg-card; values are tabular-nums; cn() merges
  className.

Customization levers
- Series count: 1–5. A single series degrades to a plain trend line with the
  readout intact; past 5 the token cycle repeats, so split the chart or make
  the extra series toggleable rather than inventing a sixth color.
- Ranking: the tooltip sorts by value descending. Swap the comparator for
  contract order when the series have a fixed reading order (p50/p90/p99).
- Baseline: the fitted y domain is the line-chart default; pass [0, "auto"]
  when absolute magnitude matters more than the shape of the crossings.
- Density: a ~260px plot with p-6 suits a dashboard grid; ~180px with p-4 for
  a compact card, and drop CartesianGrid for a sparkline feel.
- Curve: type="monotone" smooths between readings; use "linear" when every
  point is a real measurement and nothing should be implied in between.
- Palette: replace the color formula to pin a fixed token per key (brand vs
  competitor); keep the dash channel if the host palette is monochrome.

Concepts

  • Shared crosshair — the readout is keyed to the x position, not to the nearest line: one hover answers "what is every series doing at this moment", which is the whole reason to stack series on one axis instead of small multiples.
  • Ranked readout — rows are ordered by value at that x, so the panel itself shows who is on top; the order changes as you sweep across crossings, which is exactly the information a legend-ordered tooltip hides.
  • Null is a gap, not a zero — a missing reading breaks the stroke and is listed as in the readout. Interpolating across it (connectNulls) would invent a trend the source never reported; charting a null as 0 would invent an outage.
  • Highlight vs pin — hover and focus are transient highlights, a click pins one series until it is clicked again; the effective series is hovered ?? pinned, so pointer and keyboard reach the same state without a second control.
  • Redundant encoding — every series owns both a chart token and a dash pattern, and both appear in its legend swatch, so identity survives a monochrome palette, a color-blind reader and a grayscale print.
  • Graphic plus data table — the plot is one labelled role="img" graphic (no focusable nodes inside it), and the numbers are repeated in a visually hidden table, so assistive tech gets the summary and the exact values instead of a pile of <path> elements.

On This Page