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.
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.
Open the day to start.
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
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.
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.
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.
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%".
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.
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.
Final composition
The whole TodayDashboard, assembled. Loading skeleton, empty branch, error branch, drill routing. Drop it in and wire fetchSnapshots to your API.
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.
StatHerorenders the label as anh2with 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
RowCardis 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
Skeletonwrapper carriesaria-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.