Skip to content
Component installs need the registry setup
VegaStack Design

Chart

Beautiful charts, built on Recharts — a themed container, a tooltip and a legend you compose with Recharts' own primitives.

Status
stable
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 chart

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/chart

Run 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

ChartContainer — data-slot="chart"
ChartLegendContent
ChartStyle
ChartTooltipContent

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 of hsl(var(--chart-1)) when you reference chart tokens from your CSS variables.
  • Use ChartTooltip.defaultIndex for initial tooltip state only. Keep persistent active shapes in your own chart state.
  • Remove layout from Bar when the parent BarChart already defines it.
  • Keep a height, min-h-*, or aspect-* on ChartContainer so ResponsiveContainer can 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 />} />
PropTypeDescription
labelKeystringThe config or data key to use for the label.
nameKeystringThe config or data key to use for the name.
indicator"dot" | "line" | "dashed"The indicator style for the tooltip.
hideLabelbooleanWhether to hide the label.
hideIndicatorbooleanWhether 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:

PropTypeDefaultDescription
configChartConfig—Required. Labels, icons and colours, keyed by series or by data value.
childrenRecharts chart element—Required. A single Recharts chart, as ResponsiveContainer accepts.
idstringa generated useIdOverrides 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' accessibilityLayer adds keyboard access and screen-reader support, and is on by default since Recharts 3.9.2. It renders the plot svg with role="application" and tabIndex={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-visible outline from base.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 CardTitle beside it, and prefer a real XAxis/YAxis over 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.
ContractStates tested
Behaviourdefault, active
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test
Visualdefault, dark

Do / Don't

Do
Compose Recharts' own primitives inside ChartContainer, keep the series colours in the config, and draw a single-series chart in var(--chart-single).
Don't
Wrap or re-export Recharts components to hide the library, or hard-code a series colour on the element instead of naming a chart token in the config.

Deviations

Upstream's file plus packages/ui/upstream/patches/chart.patch. Every hunk:

  • FOC-1 — [&_.recharts-surface]:outline-hidden becomes [&_.recharts-surface:not(:focus-visible)]:outline-hidden. .recharts-surface is the element Recharts makes focusable, and outline-hidden compiles to --tw-outline-style: none on that element — which every outline utility, base.css's global :focus-visible included, 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.tsx measures 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 outward outline-offset: 1px would 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 in base.css.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance 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.

On this page