Components

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.

Updated Oct 14, 2026 by Juan Pablo Turina

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).

  1. 1
    ChartConfig

    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.

  2. 2
    ChartContainer

    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.

  3. 3
    Recharts 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)).

  4. 4
    ChartTooltip / 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.

Do
  • 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
  • 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.

Bar comparison

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>
Sales volume (today vs yesterday)Production mirror

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>
Rate trend

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>
Two lines (up to three)

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().

Adyen 92.2%Stripe 88.2%
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>
Average line

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>
Stacked columns (composition over time)

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

Sparkline (inline, no axes)

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.

Conversion rate94.2%
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>
Stacked bar (status distribution)Production mirror

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.

Succeeded / authorized
Declined / error
Fraud or 3DS declined / error
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>
Payment states over time (a new flow)

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.

SucceededRefundedPendingDeclinedErrorCreated
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>
Rate and volume: one card, two scales

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.

Attempts, leftApproval rate, right
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>
Bar + line dual-axis (payments + rate)Production mirror

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>
Donut with legend row (Top X)Production mirror

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.

Card94.54%
Card
1.46M94.54%
Wallets
72.1k4.66%
Apple Pay
11.8k0.76%
Others
3400.02%
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>
Heatmap (Reconciliation rate matrix)

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.

+95%94-80%-79%No transactions
100%
100%
100%
100%
100%
100%
100%
100%
12345678910111213141516171819202122232425
// 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.

The same conversion rate, two axes

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.

From zero
lineAxis()
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.

The same currencies, before and after a filter

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.

All currencies
  • BRL
    $8.4M44.4%
  • MXN
    $5.1M27.0%
  • COP
    $2.3M12.2%
  • USD
    $1.9M10.1%
  • CLP
    $1.2M6.3%
Filtered: without BRL and MXN
  • 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 + ChartConfig from the kit, then bring the specific recharts primitives your chart needs. Do NOT wrap in your own ResponsiveContainer — ChartContainer already does.
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.

PropTypeDefaultDescription
configChartConfig (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)".
classNamestring—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.
idstring—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).
childrenReactNode (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.