Patterns

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 row on the right of the header with an optional Bar/Line variant switcher and an optional eye-hide toggle, and (3) the chart body as children. Use this molecule around any Chart recipe to get the Yuno chrome (info tooltip, KPI header, variant toggle, eye-hide) for free.

Updated Aug 6, 2026 by Leonardo Posada

Anatomy

Two stacked sections with zero gap between them. HEADER (p-6): the Info icon on the LEFT of the title (gap-4 / 16px between icon and text) + 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 that MUST live inside Main, not in the header), and finally the chart body.

Daily total sales volume

$90.52BUSD
Maximum ticket: USD 65,121
  1. 1
    Header — Info icon (left)

    Optional Info icon (16px, weight light) that mounts a Tooltip on hover. Sits to the LEFT of the title with gap-4 / 16px. Pass the `info` prop to enable it.

  2. 2
    Header — Title

    Card title. text-base (16px) font-semibold text-card-foreground leading-6. Never a caption — this IS the title of the chart block.

  3. 3
    Header — 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 />.

  4. 4
    Main — KPI value + suffix

    Optional big value (text-3xl, font-bold) + suffix (text-3xl, font-normal). Both use text-foreground. Omit the `value` prop to hide the entire row.

  5. 5
    Main — 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).

  6. 6
    Main — ChartCardControls row

    Right-aligned row of chart controls: eye-hide + separator + Bar/Line toggle. Renders INSIDE Main, just above the chart body — never in the top Header. Import as a separate subcomponent and place it as one of the children.

Controls per chart type

Yuno production has a canonical rule for which top-right controls appear on each chart type. Do NOT invent combinations. The matrix below is enforced across the Dashboard — copy the recipe that matches the chart you are drawing.

Chart typeBar / Line toggleEye-hideView 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

  1. ChartCard wraps its children in a Context Provider carrying `viewPaymentsFilter`.
  2. Custom chart bodies call `useChartCardContext()` to read the value.
  3. If it is defined (even as `{}`), the chart wires click handlers on each data point and augments its tooltip with a "View payments" button.
  4. If it is undefined, the chart stays non-interactive. Default off, per-card opt-in.

Recipes

Basic — single-series line

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

<ChartCard title="Daily yuno latency p90">
  <ChartCardControls onHide={() => {}} />
  <ChartContainer config={config} className="h-64 w-full">
    <AreaChart data={data}>{/* … */}</AreaChart>
  </ChartContainer>
</ChartCard>
With KPI header + subtitle

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

$90.52BUSD
Maximum ticket: USD 65,121
<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}>{/* … */}</BarChart>
  </ChartContainer>
</ChartCard>
KPI + trend (line/bar toggle + eye)

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

1,544,011
<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>
Composed dual-axis (bar + line)

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

Payments
2,283,428
Conversion rate
60.12%
<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>
Multi-line (2-5 series)

Comparing 2-5 time-series on a shared Y axis. Legend chips go above the chart body. Avoid the Bar/Line toggle — a bar variant of 5 series is unreadable. If you need more than 6 series, jump to the Provider chart recipe with the scrollable legend row.

Daily approval rate by provider

Succeeded
$—
Declined
$—
Refunded
$—
Error
$—
Pending
$—
Created
$—
<ChartCard
  title="Daily approval rate by provider"
  viewPaymentsFilter={{}}
>
  {/* Legend chips (Stripe / Adyen / Braintree / dLocal) */}
  <ChartCardControls onHide={() => {}} />
  <ChartContainer config={config} className="h-64 w-full">
    <LineChart data={data}>
      {/* one <Line/> per provider */}
    </LineChart>
  </ChartContainer>
</ChartCard>
Provider chart (6+ series, scrollable legend)

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

Stripe
70%
Adyen
72%
Braintree
74%
dLocal
76%
Cybersource
78%
PayU
80%
Worldpay
82%
Checkout.com
84%
<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>
Stacked bar

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

Succeeded
1,291,00056.53%
Declined
954,00041.78%
Fraud
38,4281.68%
<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>
Status distribution (6-line multiseries)

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

Succeeded
$—
Declined
$—
Refunded
$—
Error
$—
Pending
$—
Created
$—
<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>
Empty chart (ghost bars + no data)

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; the centered "No data available" tag explains the blank. 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

No data available
<ChartCard title="Daily APMs conversion rate">
  <EmptyChart />
</ChartCard>
Blank percentage scale (0-100, no data)

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

0%
<ChartCard title="Daily chargebacks rate" value="0" suffix="%">
  <BlankPctChart />
</ChartCard>
With alerts (Bell / BellSlash in actions)

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

43.71%
<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>
With data selector (AttemptsSelector in actions)

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

<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}>{/* one <Line/> per provider */}</LineChart>
  </ChartContainer>
</ChartCard>
Donut with View more (drilldown)

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

DonutCard body (pattern lives outside ChartCard)
<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>

Import

Copy this import line at the top of the file where you compose this molecule.

ChartCard is a wrapper — pass any <ChartContainer> composition as children. Chart tokens (--chart-1..20) and the Yuno tooltip chrome from the Chart atom flow through automatically. Import `useChartCardContext` when writing a custom chart body that should respect the `viewPaymentsFilter` opt-in.
import { ChartCard, ChartCardControls, useChartCardContext } from "@/components/patterns/chart-card";

Props

Everything else is forwarded to the underlying elements via ...props.

PropTypeDefaultDescription
titlestring (required)Card title. Rendered as <Title as="h3"> at the default size (text-base font-semibold text-foreground).
infostringTooltip text on the Info icon. The icon sits inside <Title>'s left icon slot (gap-3 / 12px between icon and title text — Yuno heading canon). Omit to hide the icon.
valuestringBig KPI value at the top of Main (text-3xl bold). Omit to skip the KPI row.
suffixstringSuffix next to the value (same text-3xl, normal weight). e.g. "USD", "BRL".
subtitlestringOptional metadata line under the KPI row (text-sm muted). Canonical use: "Maximum ticket: USD 65,121". Only renders when `value` is set.
actionsReactNodeRight-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.
viewPaymentsFilterRecord<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.
childrenReactNode (required)Main body content. Compose ticket wrapper / legend chips / <ChartCardControls /> / chart body in visual order (Main uses flex-col gap-4).
classNamestringExtra classes on the outer Card.

ChartCardControls (subcomponent)

PropTypeDefaultDescription
variantSwitcherbooleanfalseShow 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') => voidFires when the toggle changes.
onHide() => voidHandler for the eye-hide button. Omit to skip the eye button.
classNamestringExtra classes on the row wrapper.

useChartCardContext() hook

PropTypeDefaultDescription
viewPaymentsFilterRecord<string, string | number> | undefinedThe `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. Configure content via the `info` prop.