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

Updated Jul 28, 2026 by Juan Pablo Turina

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

  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.

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

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>
Single-line trend

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

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>
Pie (payment method share)

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>
Donut (with center total)

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.

Total100%
// 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>
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(--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>
Stacked bar (status distribution)

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.

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(--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>
Bar + line dual-axis (payments + rate)

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

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

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)".
classNamestringExtra 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.
idstringOptional 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.
  • ColorsChart series should map to Yuno tokens (var(--primary), var(--success), var(--warning)) so both themes resolve.