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.
Tessina charts are thin, token-driven primitives wrapped around Recharts v3, with the Recharts composition left fully exposed. Reach for these when you need a chart shape beyond the six ready-made components (Area, Line, Bar, Pie, Radar, Radial); otherwise use those.
Playground
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 dims the toggled ones; 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 — chrome stays on tokens.
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.
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. Primer treats "view as data table" as a near-default chart action, and 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.
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/pie/donut/radial/radar). |
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. |
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), 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 verified locally — note 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 (4.5:1 text floor); 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
(Primer's numbers): ≤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
Every colour resolves to a token — series via --chart-1 … --chart-6, and chrome via
border (grid/axis), muted-foreground (ticks/legend), and popover (tooltip). Nothing is
hardcoded. The six series tokens are brand-led and contrast-measured in both light and dark
(ratios recorded in the TES-36 ledger).