Skip to content

Documentation

Charts

Compose accessible, theme-aware charts by wiring recharts into ChartContainer with a typed ChartConfig that maps each series to a Cooud token.

The chart primitives

A chart is a recharts graph composed inside ChartContainer. You give the container a ChartConfig — a map from each series key to a label and a color, usually a var(--cooud-*) token. The container exposes those colors to its recharts children as var(--color-<key>) custom properties, so series, tooltips, and legends all recolor together when the theme changes.

tsx
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@cooud-ui/ui";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
// The config maps each series key to a label and a theme token. The token is
// exposed to recharts children as a `var(--color-<key>)` custom property.
const chartConfig = {
revenue: { label: "Revenue", color: "var(--cooud-primary)" },
profit: { label: "Profit", color: "var(--cooud-info)" },
} satisfies ChartConfig;
export function Chart() {
return (
<ChartContainer config={chartConfig} className="h-64 w-full">
<BarChart accessibilityLayer data={chartData}>
{/* recharts components compose inside the container and read colors via var(--color-*). */}
<Bar dataKey="revenue" fill="var(--color-revenue)" radius={4} />
</BarChart>
</ChartContainer>
);
}

recharts is a peer dependency, imported directly alongside @cooud-ui/ui. The container ships ChartTooltip, ChartTooltipContent, ChartLegend, and ChartLegendContent alongside it. See the Chart component for the full API.

A bar chart

Compose a recharts BarChart inside ChartContainer. The ChartConfig maps each series to a theme token, exposed to the bars as var(--color-revenue) and var(--color-profit). CartesianGrid, XAxis, and the tooltip all live inside the same container.

tsx
const chartConfig = {
revenue: { label: "Revenue", color: "var(--cooud-primary)" },
profit: { label: "Profit", color: "var(--cooud-info)" },
} satisfies ChartConfig;
const chartData = [
{ month: "Jan", revenue: 4200, profit: 1400 },
{ month: "Feb", revenue: 3800, profit: 1100 },
{ month: "Mar", revenue: 5200, profit: 1900 },
{ month: "Apr", revenue: 4900, profit: 1700 },
{ month: "May", revenue: 6100, profit: 2400 },
{ month: "Jun", revenue: 7300, profit: 3100 },
];
return (
<ChartContainer config={chartConfig} className="h-64 w-full">
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} tickMargin={10} axisLine={false} />
<ChartTooltip
cursor={{ fill: "var(--cooud-fg)", fillOpacity: 0.05, radius: 8 }}
content={<ChartTooltipContent />}
/>
<Bar dataKey="revenue" fill="var(--color-revenue)" radius={4} />
<Bar dataKey="profit" fill="var(--color-profit)" radius={4} />
</BarChart>
</ChartContainer>
);

Donut, radar & radial

The same ChartContainer + ChartConfig pattern drives every recharts chart family. Swap the recharts subtree for a PieChart, RadarChart, or RadialBarChart — each slice or shape reads its fill from a var(--color-<key>) token. These three demos wrap the chart in the accessible role='img' summary described below.

Donut chart

tsx
const chartConfig = {
visitors: { label: "Visitors" },
direct: { label: "Direct", color: "var(--cooud-chart-1)" },
organic: { label: "Organic", color: "var(--cooud-chart-2)" },
referral: { label: "Referral", color: "var(--cooud-chart-3)" },
social: { label: "Social", color: "var(--cooud-chart-4)" },
email: { label: "Email", color: "var(--cooud-chart-5)" },
} satisfies ChartConfig;
const chartData = [
{ source: "direct", visitors: 4200, fill: "var(--color-direct)" },
{ source: "organic", visitors: 3100, fill: "var(--color-organic)" },
{ source: "referral", visitors: 1900, fill: "var(--color-referral)" },
{ source: "social", visitors: 1400, fill: "var(--color-social)" },
{ source: "email", visitors: 800, fill: "var(--color-email)" },
];
return (
// Data is exposed to assistive tech via the label; the SVG internals (recharts
// tags each sector role="img" with no name) are hidden so they're not announced.
<div
role="img"
aria-label="Donut chart of traffic sources by visitors: Direct 4,200, Organic 3,100, Referral 1,900, Social 1,400, Email 800."
className="h-64 w-full"
>
<div aria-hidden="true" className="h-full w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Pie
data={chartData}
dataKey="visitors"
nameKey="source"
innerRadius={64}
outerRadius={98}
paddingAngle={3}
cornerRadius={6}
strokeWidth={0}
rootTabIndex={-1}
>
{chartData.map((entry) => (
<Cell key={entry.source} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null;
const { cx, cy } = viewBox;
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={cx} y={cy} className="fill-fg font-semibold text-2xl">
11.4K
</tspan>
<tspan x={cx} y={(cy ?? 0) + 22} className="fill-fg-tertiary text-xs">
Visitors
</tspan>
</text>
);
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="source" />} />
</PieChart>
</ChartContainer>
</div>
</div>
);

Radar chart

tsx
const chartConfig = {
current: { label: "Current", color: "var(--cooud-primary)" },
target: { label: "Target", color: "var(--cooud-info)" },
} satisfies ChartConfig;
const chartData = [
{ metric: "Speed", current: 86, target: 95 },
{ metric: "Reliability", current: 72, target: 90 },
{ metric: "Coverage", current: 91, target: 88 },
{ metric: "Security", current: 78, target: 96 },
{ metric: "Usability", current: 84, target: 80 },
{ metric: "Support", current: 69, target: 85 },
];
return (
<div
role="img"
aria-label="Radar chart comparing Current vs Target across six metrics — Speed 86/95, Reliability 72/90, Coverage 91/88, Security 78/96, Usability 84/80, Support 69/85."
className="h-64 w-full"
>
<div aria-hidden="true" className="h-full w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<RadarChart data={chartData}>
<ChartTooltip content={<ChartTooltipContent />} />
<PolarGrid />
<PolarAngleAxis dataKey="metric" />
<Radar dataKey="current" fill="var(--color-current)" fillOpacity={0.6} stroke="var(--color-current)" />
<Radar dataKey="target" fill="var(--color-target)" fillOpacity={0.15} stroke="var(--color-target)" />
<ChartLegend content={<ChartLegendContent />} />
</RadarChart>
</ChartContainer>
</div>
</div>
);

Radial bar gauge

tsx
const chartConfig = {
visitors: { label: "Visitors" },
desktop: { label: "Desktop", color: "var(--cooud-chart-1)" },
mobile: { label: "Mobile", color: "var(--cooud-chart-2)" },
tablet: { label: "Tablet", color: "var(--cooud-chart-3)" },
other: { label: "Other", color: "var(--cooud-chart-4)" },
} satisfies ChartConfig;
const chartData = [
{ device: "desktop", visitors: 5200, fill: "var(--color-desktop)" },
{ device: "mobile", visitors: 4100, fill: "var(--color-mobile)" },
{ device: "tablet", visitors: 1800, fill: "var(--color-tablet)" },
{ device: "other", visitors: 900, fill: "var(--color-other)" },
];
return (
<div
role="img"
aria-label="Radial bar chart of visitors by device: Desktop 5,200, Mobile 4,100, Tablet 1,800, Other 900."
className="h-64 w-full"
>
<div aria-hidden="true" className="h-full w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<RadialBarChart data={chartData} innerRadius={32} outerRadius={110}>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="device" />} />
<PolarGrid gridType="circle" radialLines={false} stroke="none" />
<RadialBar dataKey="visitors" background cornerRadius={8} />
<ChartLegend content={<ChartLegendContent nameKey="device" />} />
</RadialBarChart>
</ChartContainer>
</div>
</div>
);

Tooltip & legend

ChartTooltip and ChartLegend are recharts' Tooltip and Legend with Cooud-styled content. Pass the provided content components and they read each series' label and color indicator straight from the ChartConfig, so you never duplicate the series metadata.

tsx
// The tooltip and legend read their labels from the same ChartConfig the
// container was given — pass the provided content components and they resolve
// each series key to its config `label` (and color indicator) automatically.
<ChartContainer config={chartConfig} className="h-64 w-full">
<BarChart accessibilityLayer data={chartData}>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="revenue" fill="var(--color-revenue)" radius={4} />
</BarChart>
</ChartContainer>

The config is the single source of truth

Both ChartTooltipContent and ChartLegendContent resolve a series key to its label in the config. Use nameKey when the legend or tooltip should key off a data field (like source or device) instead of the series dataKey.

Accessibility & code-splitting

recharts tags every sector with role='img' and no accessible name, so a screen reader hears a run of nameless images. The demos wrap each chart in a single role='img' element carrying an aria-label that summarizes the data, then mark the recharts SVG subtree aria-hidden so it isn't announced.

tsx
// recharts tags every sector with role="img" and no accessible name, so a screen
// reader would announce a pile of nameless images. Wrap the chart in a single
// role="img" with an aria-label summary, then hide the SVG subtree from a11y.
<div
role="img"
aria-label="Donut chart of traffic sources by visitors: Direct 4,200, Organic 3,100, Referral 1,900, Social 1,400, Email 800."
className="h-64 w-full"
>
<div aria-hidden="true" className="h-full w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
{/* recharts subtree — hidden from assistive tech, summarized above. */}
</ChartContainer>
</div>
</div>

recharts is heavy and purely client-visual, so the demos load it through next/dynamic with ssr: false. The chart stays out of the first-load JS of any route that doesn't draw one, streaming in behind a height-matched skeleton so there's no layout shift.

tsx
import dynamic from "next/dynamic";
// recharts is heavy and purely client-visual, so the chart render is split into
// its own client-only chunk and pulled in via next/dynamic (ssr: false). It
// never enters the first-load JS of routes that don't draw a chart.
const RevenueChart = dynamic(() => import("./revenue-chart"), {
ssr: false,
loading: () => (
<div className="h-64 w-full animate-pulse rounded-lg border border-border bg-surface-inset/50" aria-hidden="true" />
),
});

Summarize the data, hide the SVG

Put the numbers a sighted user reads into the wrapper's aria-label and mark the chart subtree aria-hidden="true". For interactive charts, set rootTabIndex={-1} on the recharts root (for example <Pie>) so the hidden subtree stays out of the tab order.