Patterns

Donut card

The canonical Yuno donut chart card (Top providers, Top issuers, Top card brands, Top payment methods, Top card types, Top countries). Wraps ChartCard chrome around an interactive Pie (hover a slice → the corresponding row highlights, center label updates, slice pushes 4px outward), a ranked rows list underneath (dot + name + count + % Badge), and an optional 'View more (N)' button that opens a HORIZONTAL Dialog (donut left, full list right). Passing onViewPaymentsRow enables per-slice + per-row drilldown; the container `actions` slot is suppressed automatically in that mode to avoid competing affordances.

Updated Aug 6, 2026 by Leonardo Posada

Anatomy

A ChartCard whose body is a two-block stack: (1) the DonutPieBlock — a Recharts Pie with an inner+outer radius that leaves room for a centered pointer-events-none KPI overlay showing the currently-hovered row's name (or the first row's, when nothing is hovered) + its percentage; (2) the DonutRowsList — a ranked ul with the same dot/label rhythm as LegendList, plus the count + % Badge on the right. When expandedRows is longer than the visible individual rows, a 'View more (N)' link appears below the list.

Top providers

dLocal43.20%
  • dLocal
    6.3M43.20%
  • Stripe
    3.1M21.40%
  • Adyen
    2.2M14.96%
  • Braintree
    1.4M9.69%
  • PayU
    890.5K6.11%
  • Others
    672.2K4.64%
  1. 1
    Header — Title + info + actions

    Inherited from ChartCard. Info tooltip defaults to 'Top by share for {title.toLowerCase()}.'. Actions slot renders the caller's ReactNode UNLESS onViewPaymentsRow is set (per-slice CTA wins to avoid competing affordances).

  2. 2
    Donut (Pie block)

    Recharts PieChart with innerRadius 55 + outerRadius 90 (card size) or 70 + 120 (Dialog size). paddingAngle 1 between slices, stroke var(--card) 2px so slices don't blur into each other. Hovered slice pushes 4px outward via activeShape; the row highlights via hovered index. Slices with `filter` become clickable when onViewPaymentsRow is set.

  3. 3
    Center label

    Absolutely-positioned overlay inside the donut hole. Shows [truncated name text-xs muted] + [pct.toFixed(2)% text-2xl semibold]. Uses lg = text-3xl inside the Dialog.

  4. 4
    Rows list

    Vertical ul. Each row: dot + name (left), formattedCount + secondary Badge with .toFixed(2)% (right). Hover highlights the corresponding slice AND vice-versa (bg-muted/60).

  5. 5
    Hover CTA

    When onViewPaymentsRow is set and the row carries `filter`, hovering the row swaps count + Badge for a 'View payments' link Button (grid overlay, no shift).

  6. 6
    View more (N)

    When expandedRows.length > visible individual rows, a centered link Button appears below the list. Opens a Dialog with a HORIZONTAL layout: bigger donut on the left (280px, radii 70/120, center label lg), full row list on the right.

Recipes

Standard (3a)

Rows are static. No per-row CTA. Actions slot renders whatever the caller passes (usually nothing). Use for donuts that are display-only in prod (Top payment methods, Top card types, Top countries).

Top providers

dLocal43.20%
  • dLocal
    6.3M43.20%
  • Stripe
    3.1M21.40%
  • Adyen
    2.2M14.96%
  • Braintree
    1.4M9.69%
  • PayU
    890.5K6.11%
  • Others
    672.2K4.64%
<DonutCard title="Top providers" rows={rows} />
// rows: DonutRow[] = [
//   { name, value, pct, color, filter? },
//   { name: "Others", value, pct, color, isOthers: true },
// ]
With custom actions

Same as standard, plus a Header actions slot with a ghost icon Button (menu, filter, etc.). Suppressed automatically if you enable drilldown.

Top providers

dLocal43.20%
  • dLocal
    6.3M43.20%
  • Stripe
    3.1M21.40%
  • Adyen
    2.2M14.96%
  • Braintree
    1.4M9.69%
  • PayU
    890.5K6.11%
  • Others
    672.2K4.64%
<DonutCard
  title="Top providers"
  rows={rows}
  actions={
    <Button variant="ghost" size="icon">
      <DotsThree weight="light" />
    </Button>
  }
/>
With drilldown (3b — hover CTA + clickable slices)

Wire onViewPaymentsRow. Rows with `filter` reveal 'View payments' on hover; the corresponding slice becomes clickable and dispatches to the same handler. The Others bucket (isOthers: true) stays non-clickable — no misleading affordance.

Top providers

dLocal43.20%
  • dLocal
    6.3M43.20%
  • Stripe
    3.1M21.40%
  • Adyen
    2.2M14.96%
  • Braintree
    1.4M9.69%
  • PayU
    890.5K6.11%
  • Others
    672.2K4.64%
<DonutCard
  title="Top providers"
  rows={rows}
  onViewPaymentsRow={(row) => navigate({ dimensions: row.filter })}
/>
With View more (horizontal Dialog)

Pass rows (5 individuals + optional Others) AND expandedRows (full individual list). A 'View more (N)' link appears below the card's list. Opens a horizontal Dialog: bigger donut left, full row list right. Common on Top providers / Top issuers / Top card brands.

Top providers

dLocal43.20%
  • dLocal
    6.3M43.20%
  • Stripe
    3.1M21.40%
  • Adyen
    2.2M14.96%
  • Braintree
    1.4M9.69%
  • PayU
    890.5K6.11%
  • Others
    672.2K4.64%
<DonutCard
  title="Top providers"
  rows={topFive}                 // 5 individuals + Others bucket
  expandedRows={fullList}        // 10 individuals, no Others
  onViewPaymentsRow={handler}    // opens horizontal Dialog
/>

Import

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

DonutCard wraps ChartCard. All ChartCard behavior (info tooltip, actions slot, tokens) flows through. The Recharts Pie is composed internally — you pass rows, not chart primitives.
import { DonutCard, type DonutRow } from "@/components/patterns/donut-card";

Props

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

DonutCard

PropTypeDefaultDescription
titlestring (required)Card title. Rendered inside ChartCard's Header.
rowsDonutRow[] (required)Rows shown both as Pie slices and as the ranked list. Prod caps at 6 (5 individuals + optional "Others" bucket).
expandedRowsDonutRow[]Full individual row list (no "Others"). When provided and longer than the individual rows in `rows`, the card renders a "View more (N)" button that opens a horizontal Dialog (donut left, full list right).
actionsReactNodeOptional custom top-right actions slot in the Header. Suppressed automatically when `onViewPaymentsRow` is set (per-slice CTA wins).
onViewPaymentsRow(row: DonutRow) => voidWhen defined, rows with `filter` reveal a "View payments" CTA on hover and their slices become clickable. Fires with the whole row on click.
infostring"Top by share for {title}."Info tooltip text on the Header. Falls back to the default sentence.
formatCount(n: number) => stringcompact ("1.2K")Format the row `value`. Pass a currency formatter for volume donuts.
classNamestringExtra classes forwarded to the underlying ChartCard.

DonutRow

PropTypeDefaultDescription
namestring (required)Slice + row label.
valuenumber (required)Raw count. Formatted via `formatCount` in the row.
pctnumber (required)Percentage 0–100. Rendered in the center label + the row Badge.
colorstring (required)Slice color. Prefer var(--chart-N).
isOthersbooleanTrue if this row is the aggregate "Others" bucket. Omit `filter` on Others rows so they stay non-clickable.
filterRecord<string, string | number>Per-row drilldown filter. When present + onViewPaymentsRow wired, both the slice and the row's hover CTA drill down.

Related

Cross-links to the atoms this molecule composes and sibling patterns.

  • Chart cardThe outer chrome. DonutCard is a specialization; ChartCard hosts every other chart-body shape.
  • Legend listFor a data card WITHOUT a pie (list-only). Use inside ChartCard's body when you don't need the donut.