Chart
Recharts is the official chart library of the Yuno Dashboard — every chart in production uses it. This atom is the kit's Recharts wrapper: it pipes Yuno theme tokens into your BarChart / LineChart / ComposedChart via a config-driven CSS-var contract. ChartContainer scopes each series to a --color-{key} variable so recharts primitives can reference tokens like var(--color-live) — light + dark modes recolor automatically. Bring your own primitives from recharts. For charts with 4+ series where semantic tokens (primary / success / foreground / muted-foreground) run out, use the dedicated chart palette --chart-1 through --chart-20 (extracted from the production Dashboard monorepo) plus --chart-stack-1..3 for stacked-bar status distributions. ChartTooltip renders with the Yuno tooltip chrome by default (bg-background, bold title + border-b, colored dot + label + tabular value per series) — no config needed. Never swap Recharts for Chart.js, Victory, Nivo, Highcharts, ECharts, or any other library — Recharts is canonical.
Anatomy
Three moving parts: (1) a ChartConfig object that maps each series key to { label, color }. Series colors flow from the Yuno chart palette (--chart-1 through --chart-20, cycled by series index — extracted from the production Dashboard monorepo) plus --chart-stack-1..3 for stacked-bar status distributions. Semantic tokens (--primary / --success) are the exception, reserved for single-series charts OR dual-series pairs where the semantics genuinely match (e.g. Payments = primary + Conversion rate = success). (2) ChartContainer, which reads that config and injects a --color-{key} CSS variable per series scoped to the chart's data-chart id. (3) any recharts primitive as children — BarChart, LineChart, ComposedChart, PieChart, RadarChart — referencing fill / stroke via var(--color-{key}). ChartContainer wraps its children in a ResponsiveContainer automatically, so the chart size follows the parent's height (which you MUST set — the container has no default height).
- 1ChartConfig
The map from dataKey to { label, color }. label is used by ChartTooltip / ChartLegend for accessible naming; color is any CSS-color value or a theme token like var(--primary). Prefer tokens so light / dark modes resolve automatically.
- 2ChartContainer
Reads the config and generates a scoped <style> block with --color-{key} CSS variables. Renders a ResponsiveContainer around your chart element. Requires an explicit height on the outer div (h-64 / h-80 / …) or the chart collapses to 0.
- 3Recharts primitives
BarChart, LineChart, ComposedChart, etc. — bring your own from recharts. Reference series colors via fill="var(--color-live)" or stroke="var(--color-today)". CartesianGrid stroke, XAxis / YAxis tick fills should also point at Yuno tokens (var(--border), var(--muted-foreground)).
- 4ChartTooltip / ChartLegend
Re-exports of Recharts Tooltip and Legend so consumers do not double-import. They inherit the same var(--color-{key}) plumbing without extra config.
Recipes
Ready-to-copy compositions covering the most common Yuno usages of this atom.
Two-series BarChart (Live vs Sandbox) with rounded corners and a muted-fill cursor on hover. The default pattern for a monthly / weekly comparison of a countable metric — transactions, users, events. Series colors come from the Yuno chart palette — Live → var(--chart-1) (green), Sandbox → var(--chart-2) (blue). Axis text uses text-muted-foreground and the horizontal grid uses var(--border) so both themes recolor cleanly.
const data = [
{ month: "Jan", live: 186, sandbox: 80 },
{ month: "Feb", live: 205, sandbox: 92 },
{ month: "Mar", live: 237, sandbox: 105 },
{ month: "Apr", live: 273, sandbox: 128 },
];
const config: ChartConfig = {
live: { label: "Live", color: "var(--chart-1)" },
sandbox: { label: "Sandbox", color: "var(--chart-2)" },
};
<ChartContainer config={config} className="h-64 w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip cursor={{ fill: "var(--muted)" }} />
<Bar dataKey="live" fill="var(--color-live)" />
<Bar dataKey="sandbox" fill="var(--color-sandbox)" />
</BarChart>
</ChartContainer>The canonical Home Sales volume chart. Two series on a ComposedChart: today (solid line, primary color, fillOpacity 0.18) and yesterday (dashed line, success color, fillOpacity 0.08). Today is null past the 'Now' cutoff (e.g. 12 PM), and connectNulls={false} makes the today line stop there. X-axis shows just three ticks (12 AM, Now, 11 PM) via the `ticks` prop; the Now tick gets highlighted copy. Y-axis uses a K formatter and explicit ticks so the scale reads at a glance.
// Today values are null past the "Now" cutoff so the line stops
// where the current hour is. Yesterday runs the full 24 hours.
const data = [
{ hour: "12 AM", today: 32_000, yesterday: 28_500 },
// …
{ hour: "12 PM", today: 132_900, yesterday: 122_100 }, // Now
{ hour: "1 PM", today: null, yesterday: 128_400 },
// …
{ hour: "11 PM", today: null, yesterday: 44_200 },
];
const config: ChartConfig = {
today: { label: "Today", color: "var(--primary)" },
yesterday: { label: "Yesterday", color: "var(--success)" },
};
<ChartContainer config={config} className="h-80 w-full">
<ComposedChart data={data} margin={{ top: 30, right: 24, bottom: 8, left: 8 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="4 4" vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
interval={0}
ticks={["12 AM", "12 PM", "11 PM"]}
/>
<YAxis
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v) => (v >= 1000 ? `${Math.round(v / 1000)}K` : `${v}`)}
domain={[0, 150_000]}
ticks={[0, 15_000, 50_000, 100_000, 150_000]}
/>
<ChartTooltip cursor={{ stroke: "var(--primary)", strokeDasharray: "4 4" }} />
{/* Yesterday — dashed reference line + soft fill */}
<Area dataKey="yesterday" fill="var(--color-yesterday)" fillOpacity={0.08} stroke="transparent" type="monotone" />
<Line dataKey="yesterday" stroke="var(--color-yesterday)" strokeWidth={1.5} strokeDasharray="5 5" dot={false} type="monotone" />
{/* Today — solid line + fill, connectNulls={false} so it cuts at "Now" */}
<Area dataKey="today" fill="var(--color-today)" fillOpacity={0.18} stroke="transparent" type="monotone" />
<Line dataKey="today" stroke="var(--color-today)" strokeWidth={2} dot={false} type="monotone" connectNulls={false} />
</ComposedChart>
</ChartContainer>A LineChart with one series over 14 days. The default shape for a KPI trend header where the numeric value lives above / next to it (in a KpiCard). No dots — a single continuous line reads as movement.
const data = Array.from({ length: 14 }, (_, i) => ({
day: `D${i + 1}`,
rate: 90 + Math.sin(i / 2) * 3 + i * 0.15,
}));
const config: ChartConfig = {
rate: { label: "Conversion rate", color: "var(--primary)" },
};
<ChartContainer config={config} className="h-56 w-full">
<LineChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="4 4" />
<XAxis dataKey="day" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} domain={["dataMin - 1", "dataMax + 1"]} />
<ChartTooltip />
<Line
dataKey="rate"
stroke="var(--color-rate)"
strokeWidth={2}
dot={false}
type="monotone"
/>
</LineChart>
</ChartContainer>A ComposedChart with three stacked Areas — Cards / Wallets / PIX over 6 months. Uses primary / success / warning for the three segments so the colors are semantic (not decorative). fillOpacity 0.6 keeps the stack readable in both themes.
const data = [
{ month: "Jan", cards: 240, wallets: 130, pix: 60 },
{ month: "Feb", cards: 258, wallets: 148, pix: 74 },
// …
{ month: "Jun", cards: 342, wallets: 224, pix: 152 },
];
const config: ChartConfig = {
cards: { label: "Cards", color: "var(--primary)" },
wallets: { label: "Wallets", color: "var(--success)" },
pix: { label: "PIX", color: "var(--foreground)" },
};
<ChartContainer config={config} className="h-64 w-full">
<ComposedChart data={data}>
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="4 4" />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip />
<Area dataKey="cards" stackId="1" stroke="var(--color-cards)" fill="var(--color-cards)" fillOpacity={0.6} type="monotone" />
<Area dataKey="wallets" stackId="1" stroke="var(--color-wallets)" fill="var(--color-wallets)" fillOpacity={0.6} type="monotone" />
<Area dataKey="pix" stackId="1" stroke="var(--color-pix)" fill="var(--color-pix)" fillOpacity={0.6} type="monotone" />
</ComposedChart>
</ChartContainer>A PieChart with one slice per payment method (Cards / Wallets / PIX / Cash) sized by value. Colors flow from ChartConfig via var(--color-{key}) — primary / success / foreground / muted-foreground (all theme-aware). Default use: current-period composition where the total is meaningful (100% = whole). For 6+ slices, drop to a stacked bar instead — the pie gets unreadable. (The kit doesn't ship a --warning token yet; foreground is the token-fidelity fallback for a 3rd distinct slice.)
const data = [
{ method: "cards", label: "Cards", value: 62 },
{ method: "wallets", label: "Wallets", value: 22 },
{ method: "pix", label: "PIX", value: 12 },
{ method: "cash", label: "Cash", value: 4 },
];
const config: ChartConfig = {
cards: { label: "Cards", color: "var(--primary)" },
wallets: { label: "Wallets", color: "var(--success)" },
pix: { label: "PIX", color: "var(--foreground)" },
cash: { label: "Cash", color: "var(--muted-foreground)" },
};
<ChartContainer config={config} className="h-72 w-full">
<PieChart>
<ChartTooltip />
<Pie data={data} dataKey="value" nameKey="label" outerRadius={110}>
{data.map((entry) => (
<Cell key={entry.method} fill={`var(--color-${entry.method})`} />
))}
</Pie>
</PieChart>
</ChartContainer>Same data + config as the Pie recipe, but with innerRadius carving a hole in the middle. The hole is used for a centered total ('Total 100%') via an absolutely-positioned overlay above the PieChart. Use when the total value is as important as the composition — a KpiCard-like tile that also shows its parts.
// Same data + config as the Pie recipe. Two changes:
// 1. innerRadius on the Pie carves the hole.
// 2. An absolutely-positioned overlay shows the total in the middle.
const total = data.reduce((sum, d) => sum + d.value, 0); // 100
<div className="relative">
<ChartContainer config={config} className="h-72 w-full">
<PieChart>
<ChartTooltip />
<Pie
data={data}
dataKey="value"
nameKey="label"
innerRadius={70}
outerRadius={110}
strokeWidth={2}
>
{data.map((entry) => (
<Cell key={entry.method} fill={`var(--color-${entry.method})`} />
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xs text-muted-foreground">Total</span>
<span className="text-3xl font-semibold tracking-tight text-foreground">
{total}%
</span>
</div>
</div>A LineChart shrunk to h-12 w-32 with no axes, no grid, no tooltip — just the line. Sits inline next to a KPI value or in a table cell to give a lightweight visual trace of movement without stealing focus. Keep it short (10-14 points); anything longer makes the compressed line lie.
const data = [
{ i: 0, rate: 92 }, { i: 1, rate: 93 }, { i: 2, rate: 91 },
{ i: 3, rate: 94 }, { i: 4, rate: 92 }, { i: 5, rate: 95 },
{ i: 6, rate: 93 }, { i: 7, rate: 96 }, { i: 8, rate: 94 },
{ i: 9, rate: 97 }, { i: 10, rate: 95 }, { i: 11, rate: 98 },
];
const config: ChartConfig = {
rate: { label: "Rate", color: "var(--primary)" },
};
// Inline next to a KPI value. No axes, no grid, no tooltip — just the line.
<div className="flex items-center gap-3">
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Conversion rate</span>
<span className="text-2xl font-semibold tracking-tight text-foreground">
94.2%
</span>
</div>
<ChartContainer config={config} className="h-12 w-32">
<LineChart data={data}>
<Line
type="monotone"
dataKey="rate"
stroke="var(--color-rate)"
strokeWidth={1.5}
dot={false}
/>
</LineChart>
</ChartContainer>
</div>The Yuno canonical shape for 'daily total X by status' (Succeeded / Declined / Fraud). One BarChart with N Bars sharing a stackId. Colors: success (bottom, dominant), primary (middle), muted-foreground (top). The legend row sits ABOVE the chart per the production layout — colored dot + label per series. The top bar carries the rounded top corners (radius={[4, 4, 0, 0]}) so the whole stack reads as one rounded pill. Different from the 'stacked-area' recipe: bars are for categorical / daily buckets, areas for continuous time series.
const data = [
{ day: "Jun 30", succeeded: 265_000, declined: 130_000, fraud: 0 },
{ day: "Jul 1", succeeded: 195_000, declined: 110_000, fraud: 0 },
// …
];
const config: ChartConfig = {
succeeded: { label: "Succeeded / authorized", color: "var(--success)" },
declined: { label: "Declined / error", color: "var(--primary)" },
fraud: { label: "Fraud or 3DS declined / error", color: "var(--muted-foreground)" },
};
<div className="flex flex-col gap-4">
{/* Legend row on top — the canonical Yuno layout */}
<div className="flex flex-wrap items-center gap-6">
{Object.entries(config).map(([key, cfg]) => (
<div key={key} className="flex items-center gap-2">
<span className="size-2 rounded-full" style={{ backgroundColor: cfg.color as string }} />
<span className="text-sm text-foreground">{cfg.label}</span>
</div>
))}
</div>
<ChartContainer config={config} className="h-72 w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="4 4" />
<XAxis dataKey="day" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip cursor={{ fill: "var(--muted)" }} />
<Bar dataKey="succeeded" stackId="a" fill="var(--color-succeeded)" />
<Bar dataKey="declined" stackId="a" fill="var(--color-declined)" />
<Bar dataKey="fraud" stackId="a" fill="var(--color-fraud)" />
</BarChart>
</ChartContainer>
</div>The canonical Yuno shape for 'daily payments + conversion rate side by side'. ComposedChart with one Bar (payments count) on the LEFT Y-axis and one Line (rate %) on the RIGHT Y-axis. Left axis uses the natural payments range; right axis is fixed to [0, 100] with a % tickFormatter. Wire yAxisId="left" / yAxisId="right" on both YAxis elements AND the corresponding series — Recharts routes each series to its axis via that id.
const data = [
{ day: "Jun 30", payments: 380_000, rate: 62 },
{ day: "Jul 1", payments: 300_000, rate: 68 },
// …
];
const config: ChartConfig = {
payments: { label: "Payments", color: "var(--primary)" },
rate: { label: "Conversion rate", color: "var(--success)" },
};
<ChartContainer config={config} className="h-72 w-full">
<ComposedChart data={data}>
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="4 4" />
<XAxis dataKey="day" tickLine={false} axisLine={false} />
{/* Left Y = payments count (0-400K). Right Y = rate % (0-100). */}
<YAxis yAxisId="left" tickLine={false} axisLine={false} />
<YAxis yAxisId="right" orientation="right" tickLine={false} axisLine={false}
domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
<ChartTooltip cursor={{ fill: "var(--muted)" }} />
<Bar yAxisId="left" dataKey="payments" fill="var(--color-payments)" />
<Line yAxisId="right" type="monotone" dataKey="rate" stroke="var(--color-rate)"
strokeWidth={2} dot={false} />
</ComposedChart>
</ChartContainer>The canonical Yuno 'Top X' pattern (Top payment methods / Top providers / Top card brands / Top issuers). A donut with a centered label showing the leading slice, PLUS a legend table underneath with rows: colored dot + label (left) + tabular count + share % (right). The rows are visually separated by dividers. Add 'View more' at the top-right of the card if the list exceeds 4-6 entries. Do not use the plain 'donut' recipe when the composition is business-facing — always pair with this legend layout so the exact counts + shares are readable.
const data = [
{ method: "card", label: "Card", count: 1_460_000, share: 94.54 },
{ method: "wallets", label: "Wallets", count: 72_100, share: 4.66 },
{ method: "applepay", label: "Apple Pay", count: 11_800, share: 0.76 },
{ method: "others", label: "Others", count: 340, share: 0.02 },
];
const config: ChartConfig = {
card: { label: "Card", color: "var(--success)" },
wallets: { label: "Wallets", color: "var(--primary)" },
applepay: { label: "Apple Pay", color: "var(--foreground)" },
others: { label: "Others", color: "var(--muted-foreground)" },
};
<div className="flex flex-col gap-4">
{/* Chart with a centered label = the top slice */}
<div className="relative">
<ChartContainer config={config} className="h-64 w-full">
<PieChart>
<ChartTooltip />
<Pie data={data} dataKey="share" nameKey="label" innerRadius={70} outerRadius={110} strokeWidth={2}>
{data.map((entry) => (
<Cell key={entry.method} fill={`var(--color-${entry.method})`} />
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xs text-muted-foreground">{data[0].label}</span>
<span className="text-3xl font-semibold tracking-tight text-foreground">{data[0].share}%</span>
</div>
</div>
{/* Legend row — colored dot + label (left) + count + % (right) */}
<div className="flex flex-col divide-y divide-border rounded-md border border-border">
{data.map((entry) => (
<div key={entry.method} className="flex items-center justify-between px-4 py-2 text-sm">
<div className="flex items-center gap-2">
<span className="size-2 rounded-full" style={{ backgroundColor: config[entry.method].color as string }} />
<span className="text-foreground">{entry.label}</span>
</div>
<div className="flex items-center gap-6 text-muted-foreground">
<span className="tabular-nums">{formatCount(entry.count)}</span>
<span className="tabular-nums font-medium text-foreground">{entry.share}%</span>
</div>
</div>
))}
</div>
</div>The reusable <Heatmap> primitive (a CSS-grid matrix, not Recharts) — the canonical Yuno shape for a rows × columns rate/health matrix. Here: rows are providers (label + per-row rate '100%'); columns are days; cells are fixed 24px rounded-sm squares coloured by a 4-bucket palette — +95% (blue-100), 94-80% (violet-400), -79% (blue-500), No transactions (gray-200), the exact Figma palette (the kit --chart-* tokens do not resolve to these). Hovering a cell draws a crosshair + a Day/bucket tooltip. Import { Heatmap, HeatmapLegend } and reuse the same shape for any provider-health / merchant-health matrix; swap the buckets and the row label.
// A categorical matrix = the reusable <Heatmap> primitive (a CSS grid,
// not Recharts). Buckets carry a Tailwind bg-* class; rows carry a Y-axis
// label + one bucket key per column; columns are the X-axis labels.
import { Heatmap, HeatmapLegend } from "@/components/ui/heatmap";
const buckets = {
high: { label: "+95%", className: "bg-blue-100" },
medium: { label: "94-80%", className: "bg-violet-400" },
low: { label: "-79%", className: "bg-blue-500" },
empty: { label: "No transactions", className: "bg-gray-200" },
};
const columns = [1, 2, 3, /* … */ 25];
const rows = providers.map((p) => ({
label: <ProviderLabel provider={p} />, // logo + rate
values: p.cellsByDay, // ["high", "low", …] per column
}));
<div className="flex flex-col gap-4">
<HeatmapLegend buckets={buckets} />
<Heatmap
buckets={buckets}
rows={rows}
columns={columns}
formatTooltip={(_r, c, key) => ({
title: `Day ${columns[c]}`,
value: buckets[key].label,
})}
/>
</div>Import
Copy this import line at the top of the file where you compose this atom.
import {
ChartContainer,
ChartTooltip,
ChartLegend,
type ChartConfig,
} from "@/components/ui/chart";
// Bring your own primitives from recharts:
import {
BarChart, LineChart, ComposedChart, PieChart,
Bar, Line, Area, Pie, Cell,
XAxis, YAxis, CartesianGrid,
} from "recharts";Props
Everything else from the underlying HTML or Radix primitive is forwarded via ...props.
| Prop | Type | Default | Description |
|---|---|---|---|
| config | ChartConfig (required) | — | The heart of the Yuno chart pattern. A map keyed by dataKey (e.g. 'live', 'today', 'cards') where each value is { label, color } (or { label, theme } for per-mode colors). ChartContainer generates a CSS variable per key (--color-{key}) so your BarChart / LineChart / etc. can reference tokens: fill="var(--color-live)". |
| className | string | — | Extra classes on the outer <div>. Common: h-64 w-full, h-80 w-full — a fixed height is required or the ResponsiveContainer collapses to 0. |
| id | string | — | Optional stable id used to scope the generated --color-{key} vars. Default is auto (React.useId). Only set this if you need deterministic ids across renders (rarely). |
| children | ReactNode (required) | — | A single Recharts chart element (BarChart / LineChart / ComposedChart / PieChart …). ChartContainer wraps it in a ResponsiveContainer — do NOT wrap it yourself. |
Related
Cross-links to atoms and patterns you may reach for next.
- KPI cardPairs with a chart in the Home performance layout: chart left, KPIs right.
- ProgressFor a single measurable progress state, Progress is lighter than a chart.
- TableWhen the data has more than 3-4 series or per-row details, a Table reads better.
- ColorsChart series should map to Yuno tokens (var(--primary), var(--success), var(--warning)) so both themes resolve.