Composed Chart
Bars, lines and areas on one shared axis, built on Recharts v3 — one hue, with the mark telling the series apart, and a second value axis for a series in another unit.
pnpm add @tessinaui/uiWhen to use
- Two readings of the same thing belong in one plot. A total as bars with its average, target or previous period as a line: the reader compares the two at each category without looking back and forth.
- A level and the flow that moves it. Tickets opened and resolved against the open backlog; money in and out against the balance.
- A measured past and a projected future. Bars for what happened, a dashed line for what is expected, a band for how sure you are.
- Two units, when the relationship is the point. Revenue with a conversion rate. Bind the second series to the second axis and title both.
When not to use
- For one kind of mark. Use BarChart, LineChart or AreaChart. Each has options this chart leaves out: horizontal bars, value labels, stacked areas, a fitted axis.
- For two unrelated measures. Two scales invite a reader to compare heights that mean nothing against each other. Stack two charts that share a category axis instead. On a phone this is nearly always the better answer.
- For more than two lines. In one hue, a third line cannot be told from the first. Split the chart.
- For parts of one whole with no time axis. Use PieChart.
- For a handful of exact figures. A Table reads faster than marks when the reader needs the numbers, not the shape.
Installation
pnpm add @tessinaui/ui rechartsUsage
import { ComposedChart } from "@tessinaui/ui";
import type { ComposedChartConfig } from "@tessinaui/ui";
const config: ComposedChartConfig = {
revenue: { label: "Revenue", type: "bar" },
average: { label: "3-month average", type: "line" },
};
const data = [
{ month: "Jan", revenue: 42, average: 41 },
{ month: "Feb", revenue: 38, average: 40 },
];
<ComposedChart
data={data}
config={config}
categoryKey="month"
label="Revenue has held at or above its three-month average"
/>Each series names its mark with type. A series with no color takes one hue,
the first chart colour, stepped in lightness for bars. Pass color to choose
your own.
Examples
Default
Bars with a line in the same unit, on one axis. The line is drawn over a hairline in the colour behind the chart, so it stays a line where it crosses the bars, and the legend keys each series with the shape of its mark.
A second value axis
axis: "secondary" measures a series on a second scale at the far side of the
plot. Both axes start at zero, so their ticks share the grid lines. One
valueFormatter spells the tooltip and both axes, and the axis titles tell a
reader which scale belongs to which series.
Bars, a line and an area
All three marks on one axis. The paint order is fixed: areas behind bars, bars behind lines, whatever order the config lists them in.
Stacked bars with a line
stacked stacks the bar series. Lines and areas never stack, so a line is read
against the top of the stack. Only the outermost segment rounds.
Grouped bars with a line
Two or more bar series sit side by side by default. maxBarWidth keeps a chart
with few categories from drawing slabs.
Two lines
Two tones of one hue do not tell two lines apart, so the second line dashes
itself, on the plot and in the legend. dashedSeries chooses which series
dash; pass [] for none.
Curve and markers
curve sets the interpolation for lines and areas. dots puts a marker on
every reading, on the latest one, or on none.
Forecast
Bars for what was measured, a dashed line for what is projected, and a band for
the likely range. An area whose value is a [low, high] pair draws the band. A
null leaves a gap.
Negative values
Bars below zero grow downward and round their lower end. A ReferenceLine at
zero draws the baseline they cross.
Rounded
rounded sets the bars' corners on the same scale as the rest of the library.
The legend key follows.
Reference lines and bands
Annotations compose as children. One measured on the second axis names it with
yAxisId="secondary"; without it, an annotation binds to the primary axis.
Range selector
A Brush composes as a child and takes the chart's inks. Reach for it when a
reader needs an arbitrary window over a long series. For a few fixed windows
(a week, a month, a year), a row of range buttons above the chart is quicker
to use and easier on a phone.
Interactive legend
Legend interactivity is controlled: the chart never hides a series on its own. A hidden series stays in the legend as an unpressed toggle, so it can be switched back on.
In a narrow card
The legend and the category axis take their height out of the chart's box. In a narrow column, give the box the tall ratio, keep two ticks and drop the value axis.
Accessibility
Choosing a month
With selectionMode="single" a click or a tap over a month, or Enter or Space
on the month the arrow keys are on, chooses it; choosing it again clears it.
The chosen period keeps the hover band, each line's dot on it wears a ring, and its tick becomes the inverse
chip, so the choice reads without colour. The chip takes the
bars' corner from rounded. Here the readout above the chart follows the
chosen month.
On a coloured plate
Inside a coloured Surface the chart follows the plate with no prop. Series
with no color take the plate's ink, the line's casing takes the plate's
colour, and the ticks, grid, legend and focus ring follow. The tooltip is a
page panel, so it keeps the page's look.
States
Loading, empty and error side by side.
- With
selectionMode="single"the periods become a list to choose from. The chart's one tab stop is a namedlistbox("Revenue: Periods"), each period anoptionwitharia-selectedand a name that carries its readings, and the period the arrow keys are on is itsaria-activedescendant. Enter or Space chooses that period, so Enter no longer toggles the tooltip;Escapestill hides it. A polite status region says "Feb selected" or "Selection cleared" once per change. The screen reader pass of this mode is still to be run.
API Reference
ComposedChart
| Prop | Type | Default | Description |
|---|---|---|---|
data | ComposedChartDatum[] | — | Row-per-category data. An area's value may be a [low, high] pair. |
config | ComposedChartConfig | — | Series settings keyed by data key: label, type, and optionally axis, color or theme, icon. Declaration order is legend order. |
categoryKey | string | — | Datum key for the category axis. Warns in development if it is not a key of the first row. |
series | string[] | all config keys | Which series to plot, and in what order. |
label | string | — | Accessible name; renders the chart as a role="figure". |
description | string | — | Visually hidden summary for assistive tech. |
size | "sm" | "md" | "lg" | "md" | Aspect ratio (3/1, 2/1, 16/9). |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner rounding of each bar's growing end. full is a pill. |
stacked | boolean | false | Stack the bar series. Lines and areas never stack. |
maxBarWidth | number | — | Cap on bar thickness in px. |
barGap | number | 4 | Gap between bars within a category group. |
categoryGap | number | string | "10%" | Gap between category groups. |
curve | "linear" | "natural" | "monotone" | "step" | "stepBefore" | "stepAfter" | "monotone" | Interpolation for lines and areas. |
dots | "all" | "none" | "last" | "all" | Point markers on lines. |
dashedSeries | string[] | second line, second area | Series drawn with a dashed stroke. [] for none. |
connectNulls | boolean | false | Draw across null values instead of leaving a gap. |
fillOpacity | number | 0.25 | Opacity of an area's wash. |
showGrid | boolean | true | Horizontal grid lines. |
showXAxis | boolean | true | The category axis. |
showYAxis | boolean | true | The value axes. |
showLegend | boolean | true | The legend. |
legendPosition | "top" | "bottom" | "bottom" | Legend placement. |
showTooltip | boolean | true | The tooltip. |
hiddenSeries | string[] | — | Series to hide. They stay in the legend as unpressed toggles. |
onSeriesToggle | (key: string) => void | — | Called when a legend item is pressed. |
xAxisLabel | string | — | Title of the category axis. |
yAxisLabel | string | — | Title of the primary value axis. |
secondaryYAxisLabel | string | — | Title of the second value axis. |
valueFormatter | (value, seriesKey) => string | number | — | Formats values in the tooltip and on both value axes. |
xTickFormatter | (value, index) => string | — | Formats category tick labels. |
startEndOnly | boolean | false | Draw only the first and last category ticks. |
animate | boolean | true | Animate the marks. prefers-reduced-motion turns it off regardless. |
dir | "ltr" | "rtl" | inherited | Reading direction. Unset, read off the chart's position in the page once it mounts. |
loading | boolean | false | Render the skeleton, sized to the data. |
error | ReactNode | — | Render an error message with the error intent. |
emptyState | ReactNode | "No data to display" | Shown when data is empty. |
selectionMode | "none" | "single" | "none" | "single" lets a reader choose one period by click, tap, Enter or Space; choosing it again clears it. |
selected | string | null | — | The categoryKey value of the chosen period, controlled. null chooses nothing. |
defaultSelected | string | null | null | The categoryKey value of the period chosen at first render, uncontrolled. |
onSelectedChange | (key, { datum, index, source }) => void | — | Called on every choice and clear; source is "pointer" or "keyboard". |
active | string | null | — | The categoryKey value of the lit period, controlled: opens the tooltip on it. |
onActiveChange | (key: string | null) => void | — | Called when the chart's own pointer or arrow keys move to another period. |
strings | Partial<ComposedChartStrings> | English, Arabic under dir="rtl" | Selection's words per entry: selected, cleared, items, period. |
children | ReactNode | — | Extra Recharts elements drawn in the plot (ReferenceLine, ReferenceArea, Brush). |
Series config
| Field | Type | Default | Description |
|---|---|---|---|
type | "bar" | "line" | "area" | — | The mark. Required. A series with any other value is not drawn, and warns in development. |
axis | "primary" | "secondary" | "primary" | Which value axis the series is measured on. |
label | ReactNode | — | Name in the legend and the tooltip. |
color | string | the house ramp | Any CSS colour. Unset, bars step through three tones of the first chart colour and every line and area takes its base. |
theme | { light: string; dark: string } | — | A colour per theme, instead of color. |
icon | ComponentType | the mark's shape | Replaces the legend and tooltip key. |
Data attributes
| Attribute | On | Value |
|---|---|---|
data-component="composed-chart" | root | Always. The base keeps data-slot="chart" on the same element, so target this to tell a composed chart from the other chart types. |
data-chart | root | Generated chart id, used to scope the per-series CSS variables. |
data-slot="composed-chart-key" | legend, tooltip | The key drawn for a series. data-type is its mark. |
data-slot="composed-chart-casing" | plot | The hairline under a line that crosses bars. |
data-slot="composed-chart-last-dot" | plot | The marker dots="last" draws. |
data-slot="chart-tooltip" | tooltip | While a tooltip is shown. |
data-slot="chart-legend" / chart-legend-item | legend | Legend items are real aria-pressed buttons when onSeriesToggle is set. |
data-slot="chart-skeleton" | root | While loading. |
data-slot="chart-message" | root | While error or empty. |
Accessibility
Screen readers: untested. No VoiceOver, NVDA or JAWS pass has been recorded for this component yet.
labelis the only way to name the chart. It renders the container as arole="figure"region with that accessible name. Anaria-labelon the root is silently ignored, because the root carries no role for a label to attach to.- Say what the chart shows, not what it is. "Revenue has held above its average since March" beats "Bar and line chart of revenue by month".
descriptionrenders a visually hidden summary inside the figure, so a static read carries the numbers without stepping through the plot.- Offer the rows as a table beside a chart with two axes. It is the reading that works at any zoom and with any assistive technology.
- Keyboard: Tab moves to the chart,
←/→step through categories and move the tooltip, and Escape dismisses the tooltip without moving focus (WCAG 1.4.13). Legend toggles are buttons in the tab order before the plot. ABrushhandle is a slider: Tab to it and the arrow keys move it. - Colour is never the only channel (WCAG 1.4.1). Series differ by mark, by dash, and by the shape of their legend key.
- Contrast (WCAG 1.4.11): each mark is held to 3:1 against the surface. Two tones of one hue cannot also reach 3:1 against each other, which is why a line carries a casing and a stack carries a seam.
- Touch: legend toggles take a 44px target on a touch pointer.
Do / Don't
- Do title both axes when you use the second one. Without a hue to tie a series to its scale, the title is the link.
- Do keep the two axes starting at zero. A fitted axis beside a zero-based one exaggerates whichever series it holds.
- Do list series in the order a reader should meet them: the legend follows the config.
- Don't plot a ratio that spikes on the same chart as the total it is a ratio of: one outlier flattens every other reading on its axis.
- Don't add a third line. Split the chart, or move a series to bars.
- Don't use the second axis to line two series up so they look related. If the scales are yours to choose, so is the story.
Agent notes
typegoes inconfig, per series. There is nobarSeries/lineSeriesprop and no child components:config={{ revenue: { label: "Revenue", type: "bar" } }}.- Name the chart with
label, notaria-label.aria-labelreaches the root element, but the root has no role, so assistive tech ignores it. - Leave
colorout unless the design calls for a specific colour. The default is one hue in tones, and it follows a colouredSurfaceon its own. - The second axis is
axis: "secondary"on the series, not a prop on the chart. An annotation on that axis needsyAxisId="secondary". stackedis a boolean and stacks bars only. There is no normalized stack here; use BarChart for one.roundedis a token scale (none…full), not a pixel number.- There is no horizontal orientation and no value labels. Use BarChart or LineChart when you need either.
categoryKeymust be a key of the data rows. A typo renders a chart with a blank category axis and warns in development.
Theming
| Element | Token |
|---|---|
Bars, with no color | --chart-1 in three tones: lighter, base, darker |
Lines and areas, with no color | --chart-1 |
Series on a coloured plate, with no color | --surface-ink; the first bar series at 80% of it |
| Line casing, marker ring, stacked segment seam | --surface-fill, the colour behind the chart |
| Area wash | the series colour at fillOpacity |
| Grid + axis lines | muted-foreground/20 |
| Axis tick labels and titles | muted-foreground |
| Brush frame, range and handles | the grid and tick inks |
| Tooltip surface | popover / popover-foreground / border |