Chart card
The canonical Yuno chart-block chrome, extracted from the production Dashboard. A Card wrapping (1) an optional KPI header row with title + info icon + big value + suffix + subtitle metadata, (2) an actions slot on the right of the header, and (3) the chart body as children. Use this molecule around any Chart recipe to get the Yuno chrome (the Info icon with its tooltip or its definition of what the chart counts, the KPI header) for free. The Bar/Line switcher, the eye-hide button and the range slider (ChartCardControls, ChartBrush) are what production ships today: keep them for a production mirror only. The launch definition draws none of them, a chart is hidden from Customise, and the design system's ChartCard offers one switch instead, chart or table.
Anatomy
Two stacked sections with zero gap between them. HEADER (p-6): the Info icon on the LEFT of the title (gap-3 / 12px between icon and text), the unit Badge after the title when the definition asks for it, + an actions slot on the RIGHT — canonically a shadcn <Button variant="link" size="sm">View more</Button>, and ONLY on cards whose production counterpart has that button. MAIN (p-6, gap-4 between children): the big KPI value + suffix (both text-3xl) at the top, then whatever you compose — ticket wrapper, legend chips, <ChartCardControls /> (the toggle + eye row, production mirror only, inside Main and never in the header), and finally the chart body.
Daily total sales volume
- 1Header — Info icon (left)
Optional Info icon (16px, weight light, in a 24px button). Sits to the LEFT of the title with gap-3 / 12px. Pass the `info` prop to enable it: a string mounts a Tooltip on hover, a ChartDefinition opens a Popover on click that says what the chart counts, and with showPer prints the unit as a secondary Badge after the title.
- 2Header — Title
Card title. text-base (16px) font-semibold text-card-foreground leading-6. Never a caption — this IS the title of the chart block.
- 3Header — Actions slot (right)
Right-aligned slot. Canonical usage: <Button variant="link" size="sm">View more</Button>. Do NOT put toggle/eye here — those belong inside Main via <ChartCardControls />.
- 4Main — KPI value + suffix
Optional big value (t-figure) + suffix (text-3xl, font-normal). Both use text-foreground. Omit the `value` prop to hide the entire row.
- 5Main — Composable children
The rest of Main is `children`. Compose your ticket wrapper / legend chips / <ChartCardControls /> / chart body in the visual order you want, top to bottom (Main uses flex-col gap-4).
- 6Main — ChartCardControls row (production mirror only)
Right-aligned row of chart controls: eye-hide + separator + Bar/Line toggle. Production mirror only: a new chart carries neither (the definition hides a chart from Customise and draws one chart type per measure), so it is not drawn above. When a mirror needs it, it renders INSIDE Main, just above the chart body, never in the top Header.
Controls production ships today (a mirror)
What the live Insights cards carry today, kept so a screen that mirrors production matches it. A new chart carries none of these controls: the launch definition draws no Bar/Line switch, no eye (a chart is hidden from Customise) and no range slider, and the design system's ChartCard offers one switch, chart or table. Past three lines is a table, and a donut is a share bar.
| Chart type | Bar / Line toggle | Eye-hide | View more |
|---|---|---|---|
KPI + trend (single series bar/line) The standard Insights time-series card. Toggle lets the reader swap between bar and line for the same metric. No View more — trend cards are direct visualisations. | |||
Composite dual-axis (KPI + combined series) Payments + Conversion rate side-by-side (bar + line on two Y-axes). The chart type is fixed — no toggle. | |||
Multi-series line (status distribution, providers) Six statuses or many providers as separate lines. Bar variant would be unreadable — no toggle. | |||
Total volume composite (selectable KPIs + chart) The Volume tab hero: 6 clickable KPI cells above; the chart reflects the active KPI. Toggle allowed because the chart is a single series once a KPI is picked. | |||
Donut with drilldown available (Top issuers, Top providers, Top card brands) Only THESE three donuts show View more in prod. All three have a drilldown page — that is what View more opens. | |||
Donut without drilldown (Top payment methods, Top card types, Top countries) Same visual as the drilldown donuts but NO View more in prod — because there is no drilldown page prepared. Do not add it here. | |||
Stacked bar (daily total / unique customer payments) Prod does NOT show View more here despite the temptation. Legend row lives inside Main above the chart. No controls in the top Header. |
Drilldown — viewPaymentsFilter
Chart cards can opt into a point-level drilldown: users click a data point (a bar, a line node, a stack segment) and land on /payments filtered to that day. Pass the `viewPaymentsFilter` prop with an object of extra filter dimensions (empty `{}` for date-only) and chart bodies that support drilldown will wire the click handler + a "View payments" CTA on the tooltip automatically.
How chart bodies read the opt-in
- ChartCard wraps its children in a Context Provider carrying `viewPaymentsFilter`.
- Custom chart bodies call `useChartCardContext()` to read the value.
- If it is defined (even as `{}`), the chart wires click handlers on each data point and augments its tooltip with a "View payments" button.
- If it is undefined, the chart stays non-interactive. Default off, per-card opt-in.
Recipes
The minimal chart card. Just a title + chart body, no KPI value on top. Choose this when the metric is time-series with ONE series and there is no big current value worth highlighting (e.g. discrete latency percentiles). If the reader should see the current KPI, use the KPI header recipe instead.
Daily yuno latency p90
const axis = lineAxis(data.map((d) => d.ms)); // a band: p90 moves ~100 ms
<ChartCard title="Daily yuno latency p90">
<ChartContainer config={config} className="h-64 w-full">
<AreaChart data={data}>
<YAxis domain={axis.domain} ticks={axis.ticks} />
{/* … */}
</AreaChart>
</ChartContainer>
</ChartCard>Adds the big value + suffix + optional subtitle metadata line. The most common Dashboard shape: 'Daily total sales volume · $90.52B USD · Maximum ticket: USD 65,121'. Pass `viewPaymentsFilter` (even as `{}`) to make the chart clickable and jump to /payments filtered by the clicked day.
Daily total sales volume
const axis = barAxis(data.map((d) => d.volume)); // bars: from zero
<ChartCard
title="Daily total sales volume"
info="Total sales value across all payment methods for the selected period."
value="$90.52B"
suffix="USD"
subtitle="Maximum ticket: USD 65,121"
viewPaymentsFilter={{}}
>
<ChartContainer config={config} className="h-64 w-full">
<BarChart data={data}>
<YAxis domain={axis.domain} ticks={axis.ticks} />
{/* … */}
</BarChart>
</ChartContainer>
</ChartCard>Production mirror: the Insights time-series card as it ships today, Bar/Line toggle included. Use it only to rebuild that card. In a new flow the measure picks the form, no toggle: a rate is a line, a count or an amount is columns. The Yuno Insights time-series card. KPI header on the left; Bar/Line toggle + eye-hide on the right (both inside Main via <ChartCardControls />). Use for any single-series metric where the reader benefits from swapping bar and line (sales volume, approved payments, average ticket). Do NOT add View more — trend cards are direct visualisations, not drilldowns.
Daily approved payments
<ChartCard
title="Daily approved payments"
info="Sum of processed volume per day."
value="1,544,011"
viewPaymentsFilter={{}}
>
<ChartCardControls variantSwitcher defaultVariant="bar" onHide={() => {}} />
<ChartContainer config={config} className="h-64 w-full">
<BarChart data={data}>{/* … */}</BarChart>
</ChartContainer>
</ChartCard>Production mirror: the payments and conversion rate card as it ships today, two scales in one plot. Use it only to rebuild that card. In a new flow, the rate sits on its declared band (the Rate and volume recipe on /components/chart), not on 0 to 100. Two related metrics share the X axis but use different Y scales (e.g. payments count + conversion rate). One chart, two Y axes, one dual legend row above. The chart type is fixed — omit variantSwitcher. Use `viewPaymentsFilter` if you want click-to-drilldown on the primary series.
Daily total payments and conversion rate
<ChartCard
title="Daily total payments and conversion rate"
info="Payments per day paired with conversion rate."
viewPaymentsFilter={{}}
>
{/* Dual legend row (Payments primary + Conversion rate) */}
<ChartCardControls onHide={() => {}} />
<ChartContainer config={config} className="h-64 w-full">
<ComposedChart data={data}>
<Bar yAxisId="left" dataKey="payments" />
<Line yAxisId="right" dataKey="conversion" />
</ComposedChart>
</ChartContainer>
</ChartCard>Comparing two or three time-series on a shared Y axis, with a legend above the chart that names what it draws: each series with its latest value. Avoid the Bar/Line toggle. Past three series the lines tangle and a colour stops identifying anything: more than three is a table. Rates sit on their ten-point band (rateBand), as 2px straight lines.
Daily approval rate by provider
import { rateBand } from "@/lib/chart-axis";
// Rates: one ten-point band for every series it carries, widened only when they span more.
const axis = rateBand(data.flatMap((d) => [d.stripe, d.adyen, d.braintree]));
<ChartCard
title="Daily approval rate by provider"
viewPaymentsFilter={{}}
>
{/* Legend: Stripe / Adyen / Braintree, each with its latest value */}
<ChartContainer config={config} className="h-64 w-full">
<LineChart data={data}>
<YAxis domain={axis.domain} ticks={axis.ticks} />
{/* one <Line type="linear" strokeWidth={2} /> per provider */}
</LineChart>
</ChartContainer>
</ChartCard>Production mirror: the Insights provider charts as they ship today, eight providers on one chart with production's colours. Use it only to rebuild those cards. In a new flow a line chart carries up to three series; more providers is a table. 6+ series where each carries a visible current value (%, ms). The scrollable legend row at the top frees the chart from a dense bottom legend and lets the user filter to a single provider by tapping a chip. Pass `viewPaymentsFilter` if you want click-to-drilldown; if the user has filtered the chart to one provider, that provider is added to the query params automatically.
Approval rate by provider
<ChartCard
title="Approval rate by provider"
viewPaymentsFilter={{}}
>
<ProviderLegendRow items={providers} />
<ChartContainer config={config} className="h-64 w-full">
<LineChart data={data}>{/* one <Line/> per provider */}</LineChart>
</ChartContainer>
</ChartCard>Production mirror: the Insights status stack as it ships today, with production's colours. Use it only to rebuild that card. In a new flow each state keeps its StatusBadge colour, icon and word: see the Payment states recipe on /components/chart. Internal composition matters AND the total matters (e.g. total payments = succeeded + declined + fraud). Each bar sums to a total that is comparable across categories. No toggle. Only eye-hide inside Main. Prod does NOT show View more on these — do not add it.
Daily total payments
<ChartCard
title="Daily total payments"
info="Stacked breakdown by payment status."
viewPaymentsFilter={{}}
>
<StackedPaymentsLegend
succeeded={{ count: 1_291_000, pct: 56.53 }}
declined={{ count: 954_000, pct: 41.78 }}
fraud={{ count: 38_428, pct: 1.68 }}
/>
<ChartCardControls onHide={() => {}} />
<ChartContainer config={config} className="h-64 w-full">
<BarChart data={data}>
<Bar dataKey="succeeded" stackId="a" />
<Bar dataKey="declined" stackId="a" />
<Bar dataKey="fraud" stackId="a" />
</BarChart>
</ChartContainer>
</ChartCard>Production mirror: the Volume tab's six status lines as they ship today. Use it only to rebuild that card. In a new flow, payment states are stacked columns on their StatusBadge colours (the Payment states recipe on /components/chart), and a line chart carries up to three series. The Volume-tab hero pattern: 6 payment statuses (Succeeded / Declined / Refunded / Error / Pending / Created) rendered as separate lines with the full legend chip row above. Bar variant would be unreadable — no toggle. Only eye-hide sits top-right inside Main.
Volume status distribution
<ChartCard
title="Volume status distribution"
info="Processed volume split by payment status."
>
{/* Legend chips (Succeeded / Declined / Refunded / Error / Pending / Created) */}
<ChartCardControls onHide={() => {}} />
<ChartContainer config={config} className="h-64 w-full">
<LineChart data={data}>{/* one <Line/> per status */}</LineChart>
</ChartContainer>
</ChartCard>The metric IS configured but no data in the current range (e.g. APM conversion rate with no APM payments that week). Ghost bars keep the chart shape recognizable, with no scale, because they are not data; the centered tag names the window and the cut ("No APM payments in the last 7 days"), never a zero. Do NOT use this when the metric was never configured — that's the No-insight empty state (comes with a CTA to the guides).
Daily APMs conversion rate
<ChartCard title="Daily APMs conversion rate">
<EmptyChart />
</ChartCard>You know the value is 0% and want to render the 0-100 axis empty as a confirmation (e.g. chargebacks rate 0% = everything under control). The KPI header shows `value="0" suffix="%"` and the chart body has no series. Use only for this specific "healthy zero" case; if there is genuinely no data, use the Empty chart recipe.
Daily chargebacks rate
<ChartCard title="Daily chargebacks rate" value="0" suffix="%">
<BlankPctChart />
</ChartCard>The metric supports configuring alerts (thresholds, ping to Slack/email). Put a Bell / BellSlash icon button in the `actions` slot so the user can mute/unmute from the card without changing screens. Compatible with `viewPaymentsFilter` — the bell toggle is independent of the click-through.
Daily yuno error / rejection rate
<ChartCard
title="Daily yuno error / rejection rate"
value="43.71"
suffix="%"
actions={
<button aria-label="Mute alerts" onClick={toggleMute}>
{muted ? <BellSlash weight="light" /> : <Bell weight="light" />}
</button>
}
viewPaymentsFilter={{}}
>
<ChartContainer config={config} className="h-64 w-full">
<AreaChart data={data}>{/* … */}</AreaChart>
</ChartContainer>
</ChartCard>The metric changes based on how attempts are filtered (e.g. approval rate with or without retries). Put <AttemptsSelector /> in the `actions` slot so the user chooses the subset. Use only when retry logic changes the interpretation of the metric — do NOT bolt this onto latency or chargebacks, where it would suggest a capability that does not exist.
Daily cards approval rate by provider
import { rateBand } from "@/lib/chart-axis";
const axis = rateBand(data.flatMap((d) => [d.stripe, d.adyen, d.braintree]));
<ChartCard
title="Daily cards approval rate by provider"
viewPaymentsFilter={{}}
actions={
<AttemptsSelector value={value} onChange={setValue} />
}
>
<ChartContainer config={config} className="h-64 w-full">
<LineChart data={data}>
<YAxis domain={axis.domain} ticks={axis.ticks} />
{/* one <Line/> per provider */}
</LineChart>
</ChartContainer>
</ChartCard>Production mirror: the Top X donuts as Insights ships them today. Use it only to rebuild those cards. In a new flow a share is ShareBar: one horizontal bar that adds up to the whole, with the same row list under it. The three donut cards that have a real drilldown in prod (Top issuers, Top providers, Top card brands) show a View more link top-right of the Header via the `actions` slot. Pass a shadcn <Button variant="link" size="sm">View more</Button>. Do NOT add View more to any other card just because it looks tidy — prod is intentional about this.
Top issuers
<ChartCard
title="Top issuers"
info="Top issuing banks by volume."
actions={
<Button variant="link" size="sm" className="px-3">
View more
</Button>
}
>
{/* donut + legend list */}
</ChartCard>Two cards on one page do not always count the same thing: one counts payments, the next counts each attempt at a provider, and their totals do not add up. Pass `info` a ChartDefinition instead of a string and the Info icon opens a Popover on click with the sentence, the unit and what is left out. Set `showPer` only on the cards that differ from the rest of the page, so the Badge marks the exception. A string keeps the hover tooltip.
Top response codes
Per attemptimport type { ReactNode } from "react";
import { ChartCard } from "@/components/patterns/chart-card";
import type { ChartDefinition } from "@/components/patterns/chart-definition";
const counted: ChartDefinition = {
counts: "Every attempt at a provider, including retries of the same payment.",
per: "Per attempt",
leavesOut: "Payments that never reached a provider",
showPer: true,
};
// children is the chart body: a <ChartContainer> with its chart.
export function TopResponseCodes({ children }: { children: ReactNode }) {
return (
<ChartCard title="Top response codes" info={counted}>
{children}
</ChartCard>
);
}Import
Copy this import line at the top of the file where you compose this molecule.
import { ChartCard, ChartCardControls, useChartCardContext } from "@/components/patterns/chart-card";
import type { ChartDefinition } from "@/components/patterns/chart-definition";Props
Everything else is forwarded to the underlying elements via ...props.
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string (required) | — | Card title. Rendered as <Title as="h3"> at the default size (text-base font-semibold text-foreground). |
| info | string | ChartDefinition | — | The Info icon, inside <Title>'s left icon slot (gap-3 / 12px between icon and title text, Yuno heading canon). A string is tooltip text, shown on hover. A ChartDefinition ({ counts, per, leavesOut?, showPer?, labels? }) says what the chart counts and opens a Popover on click; with showPer it also prints `per` as a secondary Badge after the title. Omit to hide the icon. |
| value | string | — | Big KPI value at the top of Main (t-figure). Omit to skip the KPI row. |
| suffix | string | — | Suffix next to the value (same text-3xl, normal weight). e.g. "USD", "BRL". |
| subtitle | string | — | Optional metadata line under the KPI row (text-sm muted). Canonical use: "Maximum ticket: USD 65,121". Only renders when `value` is set. |
| actions | ReactNode | — | Right-side slot in the Header. Canonical: <Button variant="link" size="sm">View more</Button> (drilldown), a Bell/BellSlash mute toggle (alerts), or <AttemptsSelector /> (data-scope selector). Never put Bar/Line toggle or eye-hide here — those go in <ChartCardControls /> inside Main. |
| viewPaymentsFilter | Record<string, string | number> | — | Opt-in drilldown. When set (even as `{}`), point-supporting chart bodies wire click-to-navigate on data points and augment their tooltip with a "View payments" CTA. Charts read this via useChartCardContext(). See the Drilldown section above. |
| children | ReactNode (required) | — | Main body content. Compose ticket wrapper / legend chips / <ChartCardControls /> / chart body in visual order (Main uses flex-col gap-4). |
| className | string | — | Extra classes on the outer Card. |
ChartCardControls (subcomponent)
| Prop | Type | Default | Description |
|---|---|---|---|
| variantSwitcher | boolean | false | Show the Bar/Line toggle. State is managed internally; read it via variant + onVariantChange. |
| variant | 'bar' | 'line' | — | Controlled variant. Only relevant when variantSwitcher is true. |
| defaultVariant | 'bar' | 'line' | 'bar' | Uncontrolled default variant. |
| onVariantChange | (v: 'bar' | 'line') => void | — | Fires when the toggle changes. |
| onHide | () => void | — | Handler for the eye-hide button. Omit to skip the eye button. |
| className | string | — | Extra classes on the row wrapper. |
useChartCardContext() hook
| Prop | Type | Default | Description |
|---|---|---|---|
| viewPaymentsFilter | Record<string, string | number> | undefined | — | The `viewPaymentsFilter` prop set on the parent ChartCard, or undefined when none was set. Custom chart bodies call the hook to decide whether to wire click-to-navigate on their points. |
Related
Cross-links to the atoms this molecule composes and sibling patterns.
- ChartEvery ChartCard wraps a Chart. Palette + tooltip default flow through.
- KPI cardFor a single-metric block with no chart body, use KpiCard. ChartCard is the chart-carrying sibling.
- Attempts selectorThe First / All attempts dropdown that lives in the `actions` slot on conversion-rate cards.
- TooltipThe (i) info icon mounts a Tooltip on hover when `info` is a string.
- PopoverThe (i) info icon opens a Popover on click when `info` is a ChartDefinition: what the chart counts, the unit, and what is left out.