Chart
Low-level charting primitives — ChartContainer, ChartTooltip, ChartLegend, ChartMessage, ChartSkeleton and a typed ChartConfig — that wrap Recharts v3 with Tessina tokens. Compose them with raw Recharts, or use the ready-made typed charts.
pnpm add @tessinaui/uiWhen to use
- A pattern a table hides. Trends over time, comparisons across categories and distributions are what a chart shows at a glance. Reach for these primitives when you need a chart shape beyond the seven ready-made components (Area, Line, Bar, Pie, Funnel, Radar, Radial); otherwise use those.
- One message per chart. Name the insight in
labelso the chart carries its point with zero interaction. - Raw Recharts, themed. Tessina charts are thin, token-driven primitives around Recharts v3, with the Recharts composition left fully exposed, so any Recharts child drops in and recolours with the theme.
- Few series. Keep to about five line series, ten bars or five slices; past that, split the chart or fold the rest into "Other".
- Direct labels where possible. Prefer
LabelListon the marks to a long legend, and keep a legend for the series that need one. - A text route to the data. Offer a data-table view for anything colour or shape cannot carry alone.
When not to use
- When people need exact values. Use a Table or a DataGrid, which can be sorted, scanned and read cell by cell.
- For a single number. Use Meter or Progress for a value against a range, or a plain stat in a Card.
- For activity over a calendar. Use ActivityHeatmap.
- For many series on one plot. Split into small multiples or group the tail into "Other" instead of adding colours.
- When colour is the only cue. Pair hue with dash pattern, shape or direct labels, or the chart fails people who cannot tell the hues apart.
- As decoration. A chart with no stated insight adds weight without meaning; leave it out.
Installation
pnpm add @tessinaui/ui rechartsUsage
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@tessinaui/ui";
import { CartesianGrid, Line, LineChart, XAxis } from "recharts";
const config: ChartConfig = {
desktop: { label: "Desktop", color: "var(--chart-1)" },
mobile: { label: "Mobile", color: "var(--chart-2)" },
};
<ChartContainer
config={config}
className="aspect-[2/1]"
label="Mobile overtook desktop in April"
>
<LineChart data={data} accessibilityLayer>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Line dataKey="desktop" stroke="var(--color-desktop)" />
<Line dataKey="mobile" stroke="var(--color-mobile)" />
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>ChartContainer reads config and injects each series colour as a
--color-<key> CSS variable scoped to the chart, so raw Recharts children
reference var(--color-desktop) in stroke / fill / stopColor. Because the
underlying --chart-N tokens flip with the .dark class, charts recolour on
theme change with no React re-render. Colour values are sanitised before
injection — a value that could escape the style declaration (;, braces,
url(...)) is dropped with a dev warning; var(), hsl(), oklch() and
calc() compositions all pass.
Examples
Default
A single-series area chart composed from ChartContainer and raw Recharts primitives,
named as a figure via label.
Accessibility
label names the chart (role="figure") — state the insight, not the axes.
description adds a visually hidden summary, because a static read must carry
the message without requiring interaction. The chart itself is keyboard-operable.
Tooltip Indicators
ChartTooltipContent supports three indicator styles — dot, line, and dashed.
Legend
A multi-series chart with a token-styled ChartLegend labelling each series. The
legend wraps on narrow screens instead of overflowing the plot.
Interactive legend
onItemClick turns legend items into real toggle buttons (aria-pressed,
keyboard-operable); inactiveKeys strikes the toggled ones through and dims their
swatch, keeping the label readable; the series' own hide prop does the hiding. State stays in your component — the legend is controlled.
Legend with values
The mobile-analytics staple: the legend doubles as the summary list, one row per
series with the value right-aligned. A composition over the same config, not a prop.
Reference lines and bands
Goal lines, target ranges and a current-period highlight via Recharts
ReferenceLine / ReferenceArea. A reference line keeps the stroke you give
it; one without a stroke is drawn as a token hairline.
Comparison and projection
Prior period and projections never rely on hue alone: lighter dashed strokes for the previous range, a dashed continuation for the forecast, and legend swatches that mirror each line's style.
Value labels
Direct labels on marks via LabelList, with token-driven fill.
Axis formatting
tickFormatter for currency (durations and qualitative scales are the same
move), plus values crossing zero with an explicit baseline.
Stat header
The universal dashboard-card anatomy: KPI number + delta badge + time-range switcher above the plot. Pure composition with house parts.
On a coloured plate
Inside a coloured Surface the chart's chrome takes the plate's ink with no
prop: axis text and lines, grid lines, the hover cursor, a reference line or
label with no ink of its own, the legend and the focus ring. Series colours are
yours. The --chart-N tokens are measured against the page, not a plate, so
give each series the plate's ink (hsl(var(--surface-ink))) and tell series
apart by dash pattern or shape. A second weight may drop to 80% of the ink, which
still clears 3:1 on every plate; 60% does not. The tooltip is a page panel, so
what you render inside it keeps the page's look.
Choosing a mark
A chart that lets a reader pick a mark does it one way across the family: a
click, a tap, or Enter or Space on the item the arrow keys are on chooses it,
and choosing it again clears it. The choice stays until it is cleared; moving
the pointer or the arrow keys never moves it. Turn it on with
selectionMode="single", then read or drive it with selected and
onSelectedChange. A page clears it with selected={null}, from its own
button or chip.
The chosen mark keeps its fill and wears a ring of the focus ring's geometry in the page's ink. Every other mark steps back to a light wash inside a full-ink outline, so each one still clears 3:1 against the page; a lowered opacity would not. While nothing is chosen, pointing at a mark dims the rest for as long as you point. PieChart adopts it first; the other charts follow.
Small multiples
One mini chart per metric, no axes, a title per card — the grid stays legible without interaction.
Data table fallback
A toggleable table of the plotted rows. Treat "view as data table" as a near-default chart action: it is the WCAG escape hatch when a series colour sits below the 3:1 marks floor.
Loading
ChartSkeleton renders a shape-aware loading placeholder per chart type — a
loading pie looks like a pie. label localises the announcement.
Empty & Error
ChartMessage covers empty and error states. By default it replaces the
chart (assistive tech should never traverse an empty shell behind a message);
overlay floats a pill over decorative, aria-hidden chrome instead. It takes
the §1 intent vocabulary. For a rich empty state with illustration and
actions, compose EmptyState.
API Reference
Primitives
| Export | Purpose |
|---|---|
ChartContainer | Responsive wrapper; injects --color-<key> vars, token-styles axes/grid/cursor, draws the keyboard focus ring. |
ChartConfig | Typed map of series key → { label, icon?, color | theme: { light, dark } }. |
ChartTooltip / ChartTooltipContent | Recharts Tooltip + token-styled content (dot | line | dashed), polite live region. |
ChartLegend / ChartLegendContent | Recharts Legend + token-styled content; optional controlled series toggling. |
ChartSkeleton | Shape-aware loading placeholder (area/line/bar/composed/pie/donut/radial/radar/scatter/candlestick/sankey/sunburst/funnel). |
ChartMessage | Empty / error placeholder with intent and overlay. |
useChart | Access the active ChartConfig from a custom child. |
useReducedMotion | prefers-reduced-motion hook used to gate animation. |
ChartContainer
| Prop | Type | Default | Notes |
|---|---|---|---|
config | ChartConfig | — | required |
label | string | — | Accessible name; renders role="figure". State the insight. |
description | string | — | Visually hidden summary for assistive tech. |
initialDimension | { width, height } | { 320, 200 } | Pre-measurement size — no 0×0 SSR flash. |
debounce | number | 0 | Debounce (ms) for resize handling. |
announcement | string | — | Text of a polite status region, mounted empty whenever this is a string. A selecting chart passes useChartSelection's announcement. |
Selection
| Export | Description |
|---|---|
ChartSelectionProps | selectionMode, selected, defaultSelected, onSelectedChange(key, { datum, index, source }), active, onActiveChange(key) — the props an adopting chart takes. |
useChartSelection | Controlled or uncontrolled choice, the active item (controlled or the chart's own) and the announcement. Renders nothing. |
ChartActiveItem | Child of a Recharts chart; reports the item under the pointer or the arrow keys. |
chartSelectionKeyDown | Capture-phase key handler for the chart's container: Enter and Space choose the item the arrow keys are on. |
ChartSelectionRing | The chosen mark's ring, drawn above every other mark. |
CHART_SELECTION_PAINT | Container classes for the step-back and the transient dim. Marks carry data-chart-item, data-selected, data-active. |
CHART_SELECTION_STRINGS | The announcement's words, English and Arabic. |
ChartTooltipContent
indicator ("dot" \| "line" \| "dashed"), hideLabel, hideIndicator,
nameKey, labelKey, labelFormatter, formatter, labelClassName, color,
and locale (BCP-47, for the default toLocaleString number formatting).
Arbitrary data-*/aria-* reach the DOM; the props Recharts injects when
cloning content are stripped by a closed list.
Native Recharts Tooltip props pass through ChartTooltip untouched — notably
trigger="click" (touch-friendly pinning), defaultIndex, shared, and
portal for escaping clipped or scrolling containers.
ChartLegendContent
verticalAlign ("top" \| "bottom" — "middle" is deprecated and renders as
bottom with a dev warning), hideIcon, nameKey, onItemClick(key),
inactiveKeys. With onItemClick set, items are <button aria-pressed>
toggles; without it they stay inert. portal and the item handlers on the
Recharts Legend itself also pass through.
ChartMessage
intent (none \| error \| warning \| success \| info — ink uses the
*-tinted-foreground tokens so every pairing stays AA on the wash) and
overlay (centered pill over aria-hidden chrome instead of a replacing box).
ChartSkeleton
variant, orientation, ticks, showLegend, legendItems, barRounded
(mirrors bar-chart's rounded scale), rings / hole / bandFraction (the
radial and sunburst silhouettes), label (default "Loading chart").
Accessibility
Recharts v3 enables its accessibilityLayer by default: the chart's <svg>
is a single tab stop (tabIndex=0, role="application").
| Key | Action |
|---|---|
Tab | Focus the chart (one stop — data points are not individual tab stops) |
← / → | Step the active tooltip through the data (RTL-aware, no wrap) |
Enter | Pin / unpin the tooltip |
Esc | Dismiss the visible tooltip |
There is no Home/End or series switching upstream. The visible focus ring is
drawn by ChartContainer (Recharts ships no focus styling; Safari would show
nothing at all). ChartTooltipContent is a polite live region, so keyboard
stepping announces values — Recharts' own live region only exists in its default
tooltip and is assertive.
AT matrix: VoiceOver + Safari passed 2026-09-14 — the figure reads its name and summary, Tab lands on the plot ("…, figure, web application") and arrows read each point ("Feb, Visitors, 305"). VoiceOver users must toggle QuickNav off or the arrow keys are consumed before the chart sees them (upstream limitation, documented by Recharts). NVDA and JAWS are UNTESTED on this machine and not claimed.
Colour: series tokens are measured against both themes — every
--chart-N/theme pair clears the 3:1 marks floor (WCAG 1.4.11; light amber was
stepped from 2.17:1 to 3.30:1 in this release). Never encode by colour alone:
pair hue with dash pattern, shape or direct labels (see Comparison), and offer
the data-table fallback for anything colour cannot carry. Axis tick text rides
muted-foreground on the page and the plate's muted text tier on a coloured
plate, clearing the 4.5:1 text floor on both; gridlines are decorative and exempt.
Static readability: the chart must carry its message with zero interaction —
that is what label/description are for. Density guardrails worth honouring,
past which the marks stop being tellable apart: ≤5 line series, ≤10 bars, ≤5
slices; beyond that, split the
chart or fold into "Other". Don't interpolate across data gaps — render the gap.
Agent notes
Stable hooks for automation: data-slot="chart" (container, plus
data-chart="<id>"), chart-tooltip, chart-legend, chart-legend-item
(buttons when interactive, aria-pressed reflects visibility),
chart-message, chart-skeleton (aria-busy="true" while loading). Chart
state is deterministic: a hidden series is hide on the Recharts element plus
the key in inactiveKeys.
Migration
| Was | Now |
|---|---|
ChartLegendContent verticalAlign="middle" | Deprecated — type is "top" | "bottom"; runtime renders bottom + dev warning |
Light --chart-3 hsl(36 100% 49.7%) | hsl(36 100% 40%) — clears the 3:1 marks floor; dark theme unchanged |
Theming
Series colours come from --chart-1 … --chart-6, brand-led and contrast-measured
against the page in both light and dark. The chrome ChartContainer paints resolves to
tokens: muted-foreground for axis text, axis lines, tick marks, labels and the legend, the
same token at low opacity for grid lines, the hover cursor and the radial track, border for
a reference line with no stroke of its own, and popover for the tooltip. Line dots, the
hover dot's ring and pie seams take the colour behind the chart. On a coloured plate the
chrome takes the plate's ink instead. A reference line, axis line or label you give an ink
keeps it.