Chart
Beautiful charts, built on Recharts — a themed container, a tooltip and a legend you compose with Recharts' own primitives.
- Status
- Since
0.1.0- Accessibility pattern
- Recharts accessibility layer
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Chart before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify chartInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/chartRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
It also adds the sanctioned engine to your package.json: recharts (chart renderer engine).
Usage
import { Bar, BarChart } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
];
const chartConfig = {
desktop: { label: "Desktop", color: "var(--chart-1)" },
mobile: { label: "Mobile", color: "var(--chart-2)" },
} satisfies ChartConfig;
<ChartContainer config={chartConfig} className="min-h-[200px] w-full">
<BarChart accessibilityLayer data={chartData}>
<Bar dataKey="desktop" fill="var(--color-desktop)" radius={4} />
<Bar dataKey="mobile" fill="var(--color-mobile)" radius={4} />
</BarChart>
</ChartContainer>;Keep a height, a min-h-* or an aspect-* on ChartContainer — Recharts' ResponsiveContainer
measures its parent, and a parent with no resolved height renders nothing.
Anatomy
Examples
Component
We use Recharts under the hood, and we do not wrap it. You build the chart
out of Recharts components and bring in ChartTooltip or ChartLegend only where you want them, so
a Recharts upgrade is Recharts' own upgrade path rather than ours. ChartTooltip and ChartLegend
are Recharts' Tooltip and Legend, re-exported unchanged; only the two *Content components
are ours.
import { Bar, BarChart } from "recharts";
import { ChartContainer, ChartTooltipContent } from "@/components/ui/chart";
export function MyChart() {
return (
<ChartContainer config={chartConfig} className="min-h-[200px] w-full">
<BarChart data={data}>
<Bar dataKey="value" />
<ChartTooltip content={<ChartTooltipContent />} />
</BarChart>
</ChartContainer>
);
}Updating to Recharts v3
If you are updating older chart code:
- Use
var(--chart-1)instead ofhsl(var(--chart-1))when you reference chart tokens from your CSS variables. - Use
ChartTooltip.defaultIndexfor initial tooltip state only. Keep persistent active shapes in your own chart state. - Remove
layoutfromBarwhen the parentBarChartalready defines it. - Keep a height,
min-h-*, oraspect-*onChartContainersoResponsiveContainercan measure on first render.
The chart below does all four: a horizontal bar chart whose layout lives on BarChart alone, a
tooltip pinned open with defaultIndex, token colours in var(--chart-N) form, and a min-h-[200px]
container.
Your First Chart
Start from the data — any shape will do, dataKey is what maps it to the chart — then add a config,
then a grid, an axis, a tooltip and a legend, one Recharts component at a time.
<ChartContainer config={chartConfig} className="min-h-[200px] w-full">
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value.slice(0, 3)}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="desktop" fill="var(--color-desktop)" radius={4} />
<Bar dataKey="mobile" fill="var(--color-mobile)" radius={4} />
</BarChart>
</ChartContainer>Chart Config
The chart config holds the human-readable half of a chart — labels, icons and colour tokens. It is deliberately decoupled from the data, so the same config can be shared between charts, or arrive from somewhere else entirely.
import { Monitor } from "lucide-react";
import { type ChartConfig } from "@/components/ui/chart";
const chartConfig = {
desktop: {
label: "Desktop",
icon: Monitor,
// Theme-specific colours; omit `color` when providing `theme`.
theme: {
light: "var(--chart-1)",
dark: "var(--chart-3)",
},
},
} satisfies ChartConfig;An entry's icon replaces the colour swatch in both the tooltip and the legend.
Theming
ChartStyle turns the config into one --color-KEY custom property per entry, emitted for both
themes and scoped to that chart's data-chart id. Reference it from any series:
fill="var(--color-desktop)", stroke="var(--color-desktop)", or
className="fill-(--color-desktop)" from Tailwind.
The palette itself is ours, and it is the one place this component departs from shadcn's token set:
--chart-1 … --chart-8 are eight distinguishable hues rather than a greyscale ramp, because a
greyscale ramp cannot carry multi-series data (MK, 2026-09-18). The names upstream uses —
var(--chart-1) … var(--chart-5) — keep working exactly as upstream's own examples spell them.
A single-series chart is drawn in var(--chart-single), not in hue 1 of 8: --chart-1 …
--chart-8 encode which series is this, a distinction that does not exist when there is only one.
const multiSeries = {
chrome: { label: "Chrome", color: "var(--chart-1)" },
safari: { label: "Safari", color: "var(--chart-2)" },
} satisfies ChartConfig;
const oneSeries = {
desktop: { label: "Desktop", color: "var(--chart-single)" },
} satisfies ChartConfig;color accepts any CSS colour — a hex, an hsl(), an oklch() — but component source in this
repository uses tokens only (COL-20), which is why every example on this page names one.
Five series — var(--chart-1) … var(--chart-5)
One series — var(--chart-single)
Tooltip
A chart tooltip contains a label, a name, an indicator and a value; ChartTooltipContent composes
them and reads every name and colour from the config.
<ChartTooltip content={<ChartTooltipContent />} />| Prop | Type | Description |
|---|---|---|
labelKey | string | The config or data key to use for the label. |
nameKey | string | The config or data key to use for the name. |
indicator | "dot" | "line" | "dashed" | The indicator style for the tooltip. |
hideLabel | boolean | Whether to hide the label. |
hideIndicator | boolean | Whether to hide the indicator. |
Use labelKey and nameKey when the config key lives in the data row rather than in the series key:
const chartData = [
{ browser: "chrome", visitors: 187, fill: "var(--color-chrome)" },
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
];
const chartConfig = {
visitors: { label: "Total Visitors" },
chrome: { label: "Chrome", color: "var(--chart-1)" },
safari: { label: "Safari", color: "var(--chart-2)" },
} satisfies ChartConfig;
<ChartTooltip
content={<ChartTooltipContent labelKey="visitors" nameKey="browser" />}
/>;indicator="dot"
indicator="line"
indicator="dashed"
Legend
ChartLegendContent labels each series from the config, and swaps the swatch for the config's icon
where there is one. nameKey does the same job it does on the tooltip.
<ChartLegend content={<ChartLegendContent nameKey="browser" />} />RTL
The component contributes no direction-aware styling — what flips is the axis. Give the subtree
dir="rtl", then hand Recharts the two props it needs: reversed on the category axis, and the
grid's orientation on the right.
<CartesianGrid vertical={false} orientation="right" />
<XAxis dataKey="month" reversed />API Reference
ChartTooltip and ChartLegend are Recharts' own Tooltip and Legend, re-exported with no
wrapper — they take everything Recharts documents for those components and add nothing.
ChartContainer — a div that provides the config, mounts ChartStyle, and wraps its child in
Recharts' ResponsiveContainer. Own props:
| Prop | Type | Default | Description |
|---|---|---|---|
config | ChartConfig | — | Required. Labels, icons and colours, keyed by series or by data value. |
children | Recharts chart element | — | Required. A single Recharts chart, as ResponsiveContainer accepts. |
id | string | a generated useId | Overrides the generated data-chart id that scopes the emitted custom properties. |
initialDimension | { width: number; height: number } | { width: 320, height: 200 } | The size the chart renders at before ResponsiveContainer's first measurement lands. |
Everything else is passed through to the div.
ChartTooltipContent — the tooltip surface. Own props are the five in the Tooltip table above;
the rest of its props come from Recharts' Tooltip content contract (active, payload, label,
labelFormatter, labelClassName, formatter, color) and from div.
ChartLegendContent — the legend surface. Own props: hideIcon (boolean, default false) and
nameKey (string); payload and verticalAlign ("top" | "bottom", default "bottom") come from
Recharts' legend contract, and the rest is passed to the div.
ChartStyle — takes id (string) and config (ChartConfig) and renders the style element
holding the --color-KEY declarations. ChartContainer mounts it for you; render it yourself only
when you need the same variables outside a container.
ChartConfig — the config type: a record of label, icon, and either color or a theme pair
keyed light and dark.
Accessibility
- Recharts'
accessibilityLayeradds keyboard access and screen-reader support, and is on by default since Recharts 3.9.2. It renders the plotsvgwithrole="application"andtabIndex={0}, so the chart is a tab stop and the arrow keys walk the data points. - Because the plot is a focusable control, it shows the system's one focus affordance: the global 2px
:focus-visibleoutline frombase.css. The outline is drawn inset here, since the plot fills the container edge to edge and an outward offset would be painted over the chart's own frame (FOC-9). Tab into the Accessibility example below to see it. - Upstream's own reset suppressed that outline; the fix is scoped so the reset still applies at rest. See Deviations.
- A chart is not self-describing. Give it a heading or a
CardTitlebeside it, and prefer a realXAxis/YAxisover colour as the only way to tell series apart. - Colour alone never carries meaning: the tooltip and the legend both print the series name next to the swatch.
| Contract | States tested |
|---|---|
| Behaviour | default, active |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test |
| Visual | default, dark |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/chart.patch. Every hunk:
- FOC-1 —
[&_.recharts-surface]:outline-hiddenbecomes[&_.recharts-surface:not(:focus-visible)]:outline-hidden..recharts-surfaceis the element Recharts makes focusable, andoutline-hiddencompiles to--tw-outline-style: noneon that element — which every outline utility,base.css's global:focus-visibleincluded, resolves through. Left unscoped, upstream's reset silently defeats the one focus affordance on the one element the component made keyboard-reachable. The reset still applies at rest, which is all it was ever for. This is an upstream defect fixed under an existing decision row, not a new decision;chart.test.tsxmeasures the focused surface rather than asserting the class string. - FOC-9 —
[&_svg:focus-visible]:-outline-offset-2. The plot fills the surface edge to edge, so the global outwardoutline-offset: 1pxwould be drawn over the chart's own frame. FOC-9 is the one permitted local deviation, and it moves the offset only — width and colour stay inbase.css. - DOC-2 —
import { cn } from "cn"becomes@vegastack/design; our npm layer ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header the three-copy model needs; no decision of its own.
Nothing else. ChartTooltipContent's shadow-xl, border-border/50 and rounded-lg are upstream's
verbatim (BRD-1, BRD-4 and DOC-10 all resolve as shadcn), and the #ccc / #fff attribute
selectors retarget Recharts' own hard-coded defaults — those are Recharts' values being matched in a
selector, not colours being authored here. The --chart-1 … --chart-8 and --chart-single palette
is the token layer's, not this file's.