Home/ Cookbook/ Dashboards/ KPI hero + drilldown

Build a KPI hero with drill-down rows

By the end of this recipe you'll have a mobile dashboard that leads with one brand-tinted hero number, supports it with three secondary KPIs, and lets the operator tap any of four row cards to drill into the matching detail page — with sparklines, deltas, and an honest empty state.

Dashboards 2 primitives · 4 blocks · 0 patterns ~30 min React · @corelithzw/react

Overview

One hero KPI, three supporting stats, four drillable rows. The whole page answers one question — "how is the business doing right now?" — and gets out of the way.

The owner of a four-branch business opens this on her phone first thing in the morning. She does not want a chart. She wants cash on hand, today's sales, what she's owed, what she owes, then a way to tap into any of them. The hero KPI is brand-tinted because it's the single number that will end the conversation; the supporting three are quieter on purpose so they do not compete. The row cards below are the drill targets — each one shows a live value and a chevron, and tapping routes to the detail page for that line.

Deltas are computed against the same period yesterday, not against last close, because "up 4% from yesterday" is the comparison a human reasons about. Sparklines run over the last 7 days, not 30, because a phone is 320px wide and a 30-day spark is just noise.

Skip this recipe if your dashboard's job is to show one chart and one chart only — a single big KPI card or the operator overview are simpler. Use this pattern when the operator's first job is to decide which drilldown to open.

What you'll build

Three states of the same page. The hero leads, the rows route, the empty state stays honest.

Today
Cash on hand $48,210
Sales$6.2k
Owed$12k
You owe$4.1k
Cash on hand$48.2k
Sales today$6.2k
Money owed$12.0k
Money you owe$4.1k
01 Loaded — hero, stats, drillable rows
Today
Cash on hand
No data yet today.
Open the day to start.
Open today's day
02 Empty — no data yet, with primary CTA
Today
Cash on hand · 7-day $48,210
Sales$6.2k
Owed$12k
You owe$4.1k
Trend+8% wow
03 Hero with 7-day sparkline

You can see this live in

Portal demos that wire this hero-KPI in context. Open one to tap from number to drilldown.

Required pieces

Everything this recipe pulls from @corelithzw/react. Open any reference page for props, variants and the do/don't.

@corelithzw/react exports used here: Stack, StatHero, KpiGrid, RowCard, EmptyState, Skeleton, Button. The same names appear across every dashboard recipe in this batch — keep them.

Step-by-step build

01

Shape the data once, render it three times

The hero, the KPI grid and the row list all read from the same shape — a flat summary object plus a four-row lines array. Compute deltas against yesterday's snapshot in one place so the three views can never disagree.

Code-only step

Deltas computed once. null when there's no baseline — render as "—".

The lines array is the source of truth for both the row list and the KPI grid.

Switch to the Code tab to see the snippet.

Step 1 · Model@corelithzw/react
02

Render the hero KPI with delta and a 7-day sparkline

StatHero takes one big number plus an optional delta and sparkline. Keep the unit ("USD"), the period ("Today, 09:14") and the comparison ("vs yesterday") visible — a naked number on a hero is the kind of thing that ends up screenshotted in the wrong context.

Cash on hand
$48,210USD
+4% As of 09:14 · vs yesterday
Step 2 · Hero
03

Three secondary KPIs in a KpiGrid — quieter on purpose

Use the neutral tone, not brand. These three exist to give context to the hero; if they shouted as loudly as the hero, nothing would lead. Show deltas as null-safe — "—" beats a misleading "0%".

Sales
$6,210
+12%
Owed
$12,040
You owe
$4,120
Step 3 · KPI grid
04

Map the lines array to RowCards with live values and a chevron

One RowCard per drill target. The row's right edge shows the live value, the chevron tells the user it routes, and the whole row is the click target — never just the chevron. If you have a router, hand it the href; if not, RowCard falls back to a plain anchor.

Cash on hand $48,210
Sales today $6,210
Money owed to you $12,040
Money you owe $4,120
Step 4 · Drill rows
05

Handle the "no data yet" state without faking zero

If the day hasn't been opened, every number is genuinely unknown — not zero. Render an EmptyState with a single primary CTA ("Open today's day") instead of stacking four "$0.00" cards that lie. The hero shrinks to a muted shell so the page still has the right shape.

Cash on hand
No close yet today
No data yet today
Open the day to start recording sales, cash and tickets.
Open today's day
Step 5 · Empty

Final composition

The whole TodayDashboard, assembled. Loading skeleton, empty branch, error branch, drill routing. Drop it in and wire fetchSnapshots to your API.

Cash on hand
$48,210
+4% vs yesterday · 09:14
SALES
$6,210
OWED
$12,040
YOU OWE
$4,120
Cash on hand$48,210
Sales today$6,210
Money owed to you$12,040
Money you owe$4,120
TodayDashboard.tsx@corelithzw/react

Variations

Three common forks. Same model, different surface.

Two heroes side-by-side

For business owners watching both inflow and outflow at once. Cash on hand stays brand-tinted; net cash flow joins it on the right.

// Two heroes, equal weight
<div style={{ display: 'grid',
              gridTemplateColumns: '1fr 1fr',
              gap: 12 }}>
  <StatHero label="Cash on hand" tone="brand"
            value={fmtMoney(m.cashOnHand)} />
  <StatHero label="Net cash flow" tone="neutral"
            value={fmtMoney(m.sales - m.payable)} />
</div>

Branch picker on top

Multi-branch owners filter the whole dashboard via a segmented control above the hero. The model rebuilds when the selection changes.

// Branch picker drives the fetch
const [branch, setBranch] = useState('all');
useEffect(() => {
  fetchSnapshots(branch).then(setState);
}, [branch]);

<SegmentedControl value={branch}
  options={branches} onChange={setBranch} />

Polled refresh every 60s

For dashboards left open on a wall display. Use useInterval to refetch quietly and update the hero in-place.

// Background refresh, no spinner
import { useInterval } from '@corelithzw/react';

useInterval(() => {
  fetchSnapshots().then((s) => {
    if (s.kind === 'loaded') setState(s);
  });
}, 60_000);

Accessibility

What this recipe takes care of for you.

  • The hero is a heading. StatHero renders the label as an h2 with the value as its descendant — screen readers read "Cash on hand, forty-eight thousand two hundred ten US dollars" as one unit.
  • Deltas read with context. The delta string is rendered after the value with aria-label="up 4 percent vs yesterday", never as a bare "+4%".
  • Null deltas show "—", not zero. A missing baseline is announced as "no comparison", which is honest; "+0%" sounds like a flat day and isn't the same thing.
  • Each drill row is a single click target. The whole RowCard is the link — not the chevron — so a 44pt thumb target survives. Tab order matches reading order, top to bottom.
  • The loading state is announced. The Skeleton wrapper carries aria-busy="true" and a polite live region, so a screen reader hears "loading" once and not on every shimmer frame.
  • The empty state has a real button. "Open today's day" is the only focusable element on the empty branch, so a keyboard user can tab once and act.
  • Sparklines have a text fallback. The SVG gets role="img" + aria-label="Cash on hand last 7 days: $42k, $44k, $43k, $46k, $45k, $48k, $48k" so the chart is a sentence for non-sighted users.