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. Series take the series palette, --series-1 through --series-6 and --series-other, assigned by entity: a one-series chart is --series-1, the brand indigo. Status tokens (--success / --warning / --destructive) are for charts whose series ARE states, never for identity. --chart-1..20 is production's palette, kept only for screens that mirror production, and --chart-stack-1..3 is its status distribution. 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 come from the series palette (--series-1 through --series-6, --series-other for the folded tail), one slot per entity, never per position in the data: a market keeps its colour when a filter removes its neighbours. A prior period is var(--muted-foreground), dashed, behind. Status tokens only paint series that are states (Succeeded / Declined), and then no identity colour shares the chart. (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.
Rules
What a chart must not do: say something the data does not say, or hide something it does. npm run kit:charts walks every page and checks these rules on the drawn charts, in light and dark; recipes tagged Production mirror are skipped and counted.
- Give each entity its slot by its place in a fixed catalogue, most common first (BRL, MXN, COP, USD…), the same slot on every chart. An entity past the sixth goes in a table.
- Draw payment states in their StatusBadge colours: Created --status-info, Pending warning, Succeeded success, Declined and Error destructive, Refunded --status-refunded. Declined and Error are one series; Refunded never sits next to the red.
- A recipe marked Production mirror copies a screen as it ships today: use it only to rebuild that screen, and follow its note for a new one.
- Colour by entity with --series-1..6. A one-series chart is --series-1.
- Past the sixth entity, fold into Other in --series-other. Other is always grey.
- One measure, one tone: a ranking of decline reasons is one colour, the bar length already says which is bigger.
- Draw the prior period in var(--muted-foreground), dashed, behind the current one.
- A rate is a line; a count or an amount is columns.
- No bar thicker than 24px: barSize={24} on every column, grouped or stacked.
- Start every bar at zero. Let a line's axis hug its data, with round ticks the data reaches.
- Up to three series in a line chart, with a legend and a label at the end of each line.
- One scale per plot, with one exception: a rate and its volume share a card, the volume as columns on the left, the rate as a line on its declared band on the right. Any other two measures are two plots that share the date.
- A day with no data is a gap, never a zero. An empty chart names the window and the cut.
- Don't paint an identity with a status token: a Yesterday line in --success reads as good news.
- Don't colour by position in the data: a filter that removes a neighbour repaints every survivor.
- Don't use --chart-* for a new chart. It is production's, kept for screens that mirror it.
- Don't put a range slider (ChartBrush) under a chart. The definition draws none; only a production mirror keeps it.
- Don't let a tick label round to a value the tick is not (3.8K on a 3,750 tick).
- Don't mix status colours and identity colours in one chart.
- Don't draw two scales for anything but a rate and its volume, and never let the rate's scale fit its own data: where the line crosses a column then depends only on how each axis was set. The rate sits on its declared band.
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 series palette: Live → var(--series-1), Sandbox → var(--series-2). 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(--series-1)" },
sandbox: { label: "Sandbox", color: "var(--series-2)" },
};
// A bar always starts at zero; the helper also rounds the ticks.
const axis = barAxis(data.flatMap((d) => [d.live, d.sandbox]));
<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} domain={axis.domain} ticks={axis.ticks} />
<ChartTooltip cursor={{ fill: "var(--muted)" }} />
<Bar barSize={24} dataKey="live" fill="var(--color-live)" />
<Bar barSize={24} dataKey="sandbox" fill="var(--color-sandbox)" />
</BarChart>
</ChartContainer>Production mirror: it copies the Home Sales volume chart as it ships today, line included. Use it only to rebuild that screen. For a new chart of an amount per day, draw columns with barAxis(). The canonical Home Sales volume chart. Two series on a ComposedChart: today (solid line, --series-1, fillOpacity 0.18) and yesterday (dashed line, --muted-foreground, fillOpacity 0.08). Yesterday is the reference, so it is grey and behind, never a colour of its own. 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(--series-1)" },
yesterday: { label: "Yesterday", color: "var(--muted-foreground)" },
};
<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>One rate over time, as the launch definition draws a trend: a 2 px line of straight segments (never smoothed: a curve shows values that never happened), on its declared ten-point band (rateBand, with allowDataOverflow and data-chart-band) so this week reads beside the last, the prior period as a grey dashed ghost behind, one label at the last point, and a tooltip that reads every series at a date. In production: recharts with the design system's rateBand(). A count or an amount over time is columns; an average is the Average line below.
import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
import { rateBand } from "@/lib/chart-axis";
const data = [
{ day: "Oct 1", rate: 92.0, prior: 91.4 },
{ day: "Oct 2", rate: 92.6, prior: 91.6 },
// …
];
const config: ChartConfig = {
rate: { label: "Conversion rate", color: "var(--series-1)" },
prior: { label: "Previous 14 days", color: "var(--muted-foreground)" },
};
// A rate read over time sits on its declared ten-point band, never 0 to 100.
const axis = rateBand(data.flatMap((d) => [d.rate, d.prior]));
const last = data.length - 1;
<div data-chart-band>
<ChartContainer config={config} className="h-56 w-full">
<LineChart data={data} margin={{ top: 8, right: 48, 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={axis.domain} ticks={axis.ticks}
allowDataOverflow interval={0} tickFormatter={(v) => `${v}%`} />
{/* The crosshair reads every series at a date. */}
<ChartTooltip content={<ChartTooltipContent formatter={(v) => `${Number(v).toFixed(1)}%`} />} />
{/* The prior period: a grey ghost behind. */}
<Line type="linear" dataKey="prior" stroke="var(--color-prior)" strokeWidth={2} strokeDasharray="4 4" dot={false} />
{/* The rate: 2 px, straight segments, one label at the last point. */}
<Line type="linear" dataKey="rate" stroke="var(--color-rate)" strokeWidth={2} dot={false}>
<LabelList dataKey="rate" content={(p) => p.index === last && p.value != null
? <text x={Number(p.x) + 6} y={Number(p.y)} dy={4} fontSize={12} fill="var(--color-rate)">{`${Number(p.value).toFixed(1)}%`}</text>
: null} />
</Line>
</LineChart>
</ChartContainer>
</div>The same rate for two or three entities over the same days, the definition's "Two lines" and Martin's case 7: at most three series, a legend always (colour, name and last value), a label at each line's end, every line on the same band. Each entity takes its colour from entityColours(), the same on every chart. Beyond three, the compare table. In production: recharts with rateBand() and entityColours().
import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
import { rateBand } from "@/lib/chart-axis";
import { entityColours } from "@/lib/entity-colour";
const data = [
{ day: "Oct 1", adyen: 93.1, stripe: 91.3 },
{ day: "Oct 2", adyen: 93.5, stripe: 90.9 },
// …
];
// Each provider keeps its colour on every chart: from the catalogue, never the position.
const colour = entityColours({ provider: ["Adyen", "Stripe", "dLocal"] });
const config: ChartConfig = {
adyen: { label: "Adyen", color: colour("provider", "Adyen") },
stripe: { label: "Stripe", color: colour("provider", "Stripe") },
};
const axis = rateBand(data.flatMap((d) => [d.adyen, d.stripe]));
const last = data.length - 1;
const end = (key: "adyen" | "stripe") => (p: { index?: number; x?: number | string; y?: number | string; value?: unknown }) =>
p.index === last && p.value != null ? <text x={Number(p.x) + 6} y={Number(p.y)} dy={4} fontSize={12} fill={`var(--color-${key})`}>{config[key].label}</text> : null;
{/* At most three series. Beyond three, the compare table. */}
<div className="flex flex-col gap-3">
{/* A legend always: colour, name and the last value. */}
<div className="flex flex-wrap gap-6 text-sm">
{(["adyen", "stripe"] as const).map((k) => (
<span key={k} className="flex items-center gap-2">
<span className="h-0.5 w-3 rounded-full" style={{ backgroundColor: `var(--color-${k})` }} />
{config[k].label} <b className="font-medium tabular-nums">{data[last][k].toFixed(1)}%</b>
</span>
))}
</div>
<div data-chart-band>
<ChartContainer config={config} className="h-56 w-full">
<LineChart data={data} margin={{ top: 8, right: 56, 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={axis.domain} ticks={axis.ticks}
allowDataOverflow interval={0} tickFormatter={(v) => `${v}%`} />
<ChartTooltip content={<ChartTooltipContent formatter={(v) => `${Number(v).toFixed(1)}%`} />} />
<Line type="linear" dataKey="adyen" stroke="var(--color-adyen)" strokeWidth={2} dot={false}>
<LabelList dataKey="adyen" content={end("adyen")} />
</Line>
<Line type="linear" dataKey="stripe" stroke="var(--color-stripe)" strokeWidth={2} dot={false}>
<LabelList dataKey="stripe" content={end("stripe")} />
</Line>
</LineChart>
</ChartContainer>
</div>
</div>An average over time (average ticket, latency): a 2 px line of straight segments with one label at the last point. Never columns, because averages do not add up across days, and not on a rate band: its axis hugs the data with round ticks (lineAxis). In production: recharts with the design system's lineAxis().
import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
import { lineAxis } from "@/lib/chart-axis";
const data = [
{ day: "Oct 1", ticket: 46 },
{ day: "Oct 2", ticket: 48 },
// …
];
const config: ChartConfig = {
ticket: { label: "Average ticket", color: "var(--series-1)" },
};
// An average is not a rate: its axis hugs the data, with round ticks the data reaches.
const axis = lineAxis(data.map((d) => d.ticket));
const last = data.length - 1;
<ChartContainer config={config} className="h-56 w-full">
<LineChart data={data} margin={{ top: 8, right: 48, 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={axis.domain} ticks={axis.ticks}
interval={0} tickFormatter={(v) => `$${v}`} />
<ChartTooltip content={<ChartTooltipContent formatter={(v) => `$${v}`} />} />
{/* Never columns: averages do not add up across days. */}
<Line type="linear" dataKey="ticket" stroke="var(--color-ticket)" strokeWidth={2} dot={false}>
<LabelList dataKey="ticket" content={(p) => p.index === last && p.value != null
? <text x={Number(p.x) + 6} y={Number(p.y)} dy={4} fontSize={12} fill="var(--color-ticket)">{`$${p.value}`}</text>
: null} />
</Line>
</LineChart>
</ChartContainer>A BarChart with three stacked Bars — Cards / Wallets / PIX over 6 months, each method on its series slot. Each month is its own column because an amount is counted per period: an area would claim values between two months that do not exist.
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(--series-1)" },
wallets: { label: "Wallets", color: "var(--series-2)" },
pix: { label: "PIX", color: "var(--series-3)" },
};
// Stacked: the axis carries each column's total, not each segment.
const axis = barAxis(data.map((d) => d.cards + d.wallets + d.pix));
<ChartContainer config={config} className="h-64 w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="4 4" />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} domain={axis.domain} ticks={axis.ticks} />
<ChartTooltip />
<Bar barSize={24} dataKey="cards" stackId="a" fill="var(--color-cards)" />
<Bar barSize={24} dataKey="wallets" stackId="a" fill="var(--color-wallets)" />
<Bar barSize={24} dataKey="pix" stackId="a" fill="var(--color-pix)" />
</BarChart>
</ChartContainer>For a share of a whole at one point in time (which method carries how much of the volume), don't reach for a pie or a donut: use ShareBar
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(--series-1)" },
};
// 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>Production mirror: the Insights status stack as it ships today (dashboard-web bar-chart.tsx, --chart-stack-*). Use it only to rebuild that card. In a new flow, use the Payment states recipe below. The Yuno canonical shape for 'daily total X by status' (Succeeded / Declined / Fraud). One BarChart with N Bars sharing a stackId. Colors: the production status stack, --chart-stack-3 (bottom, dominant), --chart-stack-1 (middle), --chart-stack-2 (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(--chart-stack-3)" },
declined: { label: "Declined / error", color: "var(--chart-stack-1)" },
fraud: { label: "Fraud or 3DS declined / error", color: "var(--chart-stack-2)" },
};
<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>A new flow's chart of payment states. Every state keeps the colour, icon and word StatusBadge gives it (the Yuno design system's own): Succeeded success, Refunded --status-refunded, Pending warning, Declined and Error destructive, Created --status-info. Declined and Error share the red, so the chart draws them as one series, as production does; the legend of StatusBadges keeps them apart by word and icon. Refunded never sits next to the red: side by side they are 4.3 apart (OKLab), where 15 is the floor for telling neighbours apart. A count, so stacked columns.
import { paymentStateColour } from "@/lib/payment-state";
// paymentStateColour() hands out each state's StatusBadge tone; Declined and Error share the red.
const config: ChartConfig = {
succeeded: { label: "Succeeded", color: paymentStateColour("succeeded") },
refunded: { label: "Refunded", color: paymentStateColour("refunded") },
pending: { label: "Pending", color: paymentStateColour("pending") },
failed: { label: "Declined / error", color: paymentStateColour("declined") },
created: { label: "Created", color: paymentStateColour("created") },
};
// The axis carries each day's total, from zero.
const axis = barAxis(data.map((d) => d.succeeded + d.refunded + d.pending + d.failed + d.created));
<div className="flex flex-col gap-4">
{/* The legend is the StatusBadges: colour, icon and word */}
<div className="flex flex-wrap gap-2">
<StatusBadge status="succeeded" />
<StatusBadge status="refunded" />
<StatusBadge status="pending" />
<StatusBadge status="declined" />
<StatusBadge tone="destructive" icon={WarningCircle} label="Error" />
<StatusBadge status="created" />
</div>
<ChartContainer config={config} className="h-64 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} domain={axis.domain} ticks={axis.ticks} />
<ChartTooltip cursor={{ fill: "var(--muted)" }} />
{/* Bottom to top. Refunded is never next to the red. */}
<Bar barSize={24} dataKey="succeeded" stackId="s" fill="var(--color-succeeded)" />
<Bar barSize={24} dataKey="refunded" stackId="s" fill="var(--color-refunded)" />
<Bar barSize={24} dataKey="pending" stackId="s" fill="var(--color-pending)" />
<Bar barSize={24} dataKey="failed" stackId="s" fill="var(--color-failed)" />
<Bar barSize={24} dataKey="created" stackId="s" fill="var(--color-created)" />
</BarChart>
</ChartContainer>
</div>A rate and the count behind it, over the same days, on one card, as the launch definition (AINs Console, the first launch) draws them: the attempts as columns from zero on the left scale (barAxis), the rate as a line on the right scale, on its declared ten-point band (rateBand, with allowDataOverflow and data-chart-band). The band sits where the merchant's own rates sit and never runs past 100%, so the ruler only moves when the book moves to another ten and this week reads beside last week. A legend names both and the scale each reads; one tooltip reads both on the same day. This is the only pair that shares a plot: any other two measures are two plots.
import { Bar, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
import { rateBand, barAxis } from "@/lib/chart-axis";
const data = [
{ day: "Jul 1", attempts: 3120, rate: 92.4 },
{ day: "Jul 2", attempts: 2980, rate: 92.9 },
// …
];
const config: ChartConfig = {
attempts: { label: "Attempts", color: "var(--muted-foreground)" },
rate: { label: "Approval rate", color: "var(--series-1)" },
};
// The count from zero on the left; the rate on its declared ten-point band on the right.
const countAxis = barAxis(data.map((d) => d.attempts));
const rateAxis = rateBand(data.map((d) => d.rate));
const formatK = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}K` : `${n}`);
<div className="flex flex-col gap-3">
{/* Two series, so a legend; each says which scale it reads. */}
<div className="flex flex-wrap items-center gap-6 text-sm">
<span className="flex items-center gap-2">
<span className="size-2 rounded-xs" style={{ backgroundColor: "var(--color-attempts)", opacity: 0.35 }} />
Attempts, left
</span>
<span className="flex items-center gap-2">
<span className="h-0.5 w-3 rounded-full" style={{ backgroundColor: "var(--color-rate)" }} />
Approval rate, right
</span>
</div>
<div data-chart-band>
<ChartContainer config={config} className="h-64 w-full">
<ComposedChart data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="4 4" />
<XAxis dataKey="day" tickLine={false} axisLine={false} />
<YAxis yAxisId="count" tickLine={false} axisLine={false}
domain={countAxis.domain} ticks={countAxis.ticks} interval={0} tickFormatter={formatK} />
<YAxis yAxisId="rate" orientation="right" tickLine={false} axisLine={false}
domain={rateAxis.domain} ticks={rateAxis.ticks} allowDataOverflow interval={0}
tickFormatter={(v) => `${v}%`} />
{/* One tooltip reads both on the same day. */}
<ChartTooltip cursor={{ fill: "var(--muted)" }}
content={<ChartTooltipContent formatter={(v, name) =>
name === "Approval rate" ? `${v}%` : Number(v).toLocaleString("en-US")} />} />
<Bar yAxisId="count" name="Attempts" dataKey="attempts" fill="var(--color-attempts)" fillOpacity={0.35} barSize={24} />
<Line yAxisId="rate" name="Approval rate" type="linear" dataKey="rate" stroke="var(--color-rate)" strokeWidth={2} dot={false} />
</ComposedChart>
</ChartContainer>
</div>
</div>Production mirror: it copies the payments and conversion rate chart as it ships today. Use it only to rebuild that screen. For a new screen, use the recipe above, Rate and volume: one card, two scales, the rate on its declared band rather than 0 to 100. 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(--series-1)" },
rate: { label: "Conversion rate", color: "var(--series-2)" },
};
<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>Production mirror: the Top X donuts as Insights ships them today. Use it only to rebuild those screens. For a new screen, a share is ShareBar: one horizontal bar that adds up to the whole, with this same legend table under it. 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(--series-1)" },
wallets: { label: "Wallets", color: "var(--series-2)" },
applepay: { label: "Apple Pay", color: "var(--series-3)" },
others: { label: "Others", color: "var(--series-other)" },
};
<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>Axis
lineAxis() and barAxis() from @/lib/chart-axis give a YAxis its domain and ticks. A bar always starts at zero: its length is the magnitude. A line does not have to: when the spread is small next to the level, zero flattens it, so the axis becomes a band around the data. The ticks are the round values (1, 2, 2.5, 5 × a power of ten) that the data reaches. Pass every value the axis carries, from every series on it. Negative values are covered: bars below zero hang from it, and a line that crosses zero always shows it. For a rate a merchant opens every week, rateBand() declares the band instead: ten points wide, on the ten the book lives in (a book in the 74s draws 70 to 80), sliding along its own ticks when the book sits across a round ten (89.9 to 90.1 draws 85 to 95), widening to round numbers only when no ten-point band on the ticks holds the book, and never under 0 or past 100. It keeps its height, not its place: around 90 it can read 80 to 90, 85 to 95 or 90 to 100 from one week to the next, a price taken on purpose over a fifteen-point ruler. A stray value outside that range does not move the axis: give that YAxis allowDataOverflow, and the line is cut at the edge while the tooltip keeps the real value. Mark that chart with data-chart-band.
Left, from zero: the rate lives between 88% and 94%, so the line is pinned to the top and the movement is a few pixels tall. Right, lineAxis(): a band around the data with round ticks.
import { lineAxis, barAxis } from "@/lib/chart-axis";
// Every value the axis carries, from every series on it.
const axis = lineAxis(data.map((d) => d.rate));
<YAxis domain={axis.domain} ticks={axis.ticks} tickFormatter={(v) => `${v}%`} />
// Bars always from zero, with round ticks:
const bars = barAxis(data.map((d) => d.payments));
<YAxis domain={bars.domain} ticks={bars.ticks} />Colour by entity
entityColours() from @/lib/entity-colour gives each entity (a currency, a provider, a country) the --series-N slot of its place in a catalogue, the same on every chart. Colour follows the entity, never its rank: a filter that removes a neighbour does not repaint the rest. The catalogue is the product's, most common first; the kit ships no order. Past the sixth, and for an entity the catalogue does not carry, the colour is the grey of Other, with a warning in development.
Left, every currency. Right, the same chart filtered to three: COP, USD and CLP keep the colour they had. Coloured by position instead, COP would turn blue the moment BRL left.
- BRL$8.4M44.4%
- MXN$5.1M27.0%
- COP$2.3M12.2%
- USD$1.9M10.1%
- CLP$1.2M6.3%
- COP$2.3M42.6%
- USD$1.9M35.2%
- CLP$1.2M22.2%
import { entityColours } from "@/lib/entity-colour";
// Once per product: its catalogues, most common first.
const colour = entityColours({
currency: ["BRL", "MXN", "COP", "USD", "CLP", "ARS"],
});
// Every chart asks by the entity's stable id (not a label that may be translated),
// so a filter never repaints the survivors.
<ShareBar
rows={rows.map((r) => ({ ...r, color: colour("currency", r.key) }))}
formatValue={formatMoney}
/>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.
- ColorsThe Series section: --series-1..6 and --series-other, with their dark values.