KPI tile
One number that matters, its label, and how it moved.
Beta · Content blocks · packages/css/src/components/kpi-tile.css · React on Base UI: @pepperui/kpi-tile
.pui-kpi-tile · tone --blue --red --yellow (neutral default) · __top __value at the top, __bottom (__label __detail) at the bottom, the row’s height between them; .pui-kpi-row lays a row. One tile may be negative. Only a STATUS delta is rung — --green up, --red down, a 2 px ring in slot white. A badge wearing the card’s own tint is a label, not a status: the grey flat below and every tag in the row under it are flush.
Sizes across, states down.
What it is for
Section titled “What it is for”- Tone blue / red / yellow / neutral — a variant axis on kpi/tone/*/bg, not the Accent mode, because one row shows every team
- The NEUTRAL tone follows the ground: kpi/tone/neutral/bg is surface/sunken — neutral/100 on a white page, neutral/200 on a surface/card product screen, one step darker than what it sits on. Set Ground = card on the frame that paints the product ground; the three tints do not move
- Value (title/large — 36, 32 on mobile), Label (body/small — 16 / 14; 18 Medium on slides is the same role), Has delta with an exposed Badge (green or red by sign, small; swap its Tone) — the DELTA, a signed number and nothing else; Has tag with a second exposed Badge for a category (a team, an industry, a product — tone and Icon swapped, off by default), Has detail — a white slot with an Icon swap and a Detail line (body/medium)
- 260 wide by default (four across a 1200 page at 32 gaps); FILL it in the row; heights hug — put the tiles in one auto-layout row so they match. Below about 240 wide the value wraps beside its delta: in a narrow column (the 896 beside the rail) go three across, never four
- ONLY A STATUS DELTA IS RUNG — green because the number went up, red because it went down: a 2 px ring in kpi/delta/ring, stroke OUTSIDE, and nothing else in the library carries one. A badge in that corner wearing THE TILE’S OWN TINT — grey on the neutral tile, blue on the blue one — is a LABEL, not a status, and stays flush; so is the TAG, whatever its tone, because a red tag on a red tile is a team and not a fall. The reason a status needs it: the delta is the one thing here that says good or bad, and it says it in a tint-pair sitting on another tint, where the two edges run together; the ring cuts it out the way the white slot does lower down. A delta that is neither green nor red is not a delta — put it in Has tag. One or the other in that corner, not both
- A glyph inside either badge takes THAT BADGE’S ink — tint/{tone}/fg, never text/default. In CSS it is currentColor and free; in Figma a swapped icon keeps its own stroke, so recolour it
- Slots follow the inversion rule: the tinted tile carries a white slot
- TWO BLOCKS pushed apart: Top (the value with its delta) at the top, Bottom (the label over the detail slot, 4 apart) at the bottom — SPACE_BETWEEN with 20 as the floor (Bottom’s top padding, space/20); a tile that FILLs a row’s height opens the room between them. Never a stack that runs top-down and leaves the bottom empty. In the detail slot the 24 glyph sits on the text’s top line, as in Icon list — never centred.
- At least one tile in a row should be allowed to be negative — four green pills read as a brochure, not a product
- Width narrow — below 200 px of its own width (four across on a tablet, two on a phone — the site’s 167): the Top row stacks, the value over its tag at space/4; the Value steps to title/medium; the tile sits 12 above and below, 20 at the sides. In code the tile answers its own width by a container query — no class; it takes its width from its row, never from its content (request 42)
Use when a dashboard opens with its headline numbers; a case study or a deck states a result in one figure; a stat needs a period-over-period movement next to it.
Do not use when the number needs a chart to mean anything (a Card with the chart in its slot); there are more than six of them (a Table); the tile would hold a paragraph (Card); it is a status, not a metric (Badge, Alert).
Relates to Card, Badge, Insight block, Chart recipes, Table.
Anti-patterns a ring on a category tag, or a delta with none · a delta and a tag in the same corner · a glyph in a different ink from the label beside it · four green deltas in one row · the same tint on every tile · a delta with no sign · a tile as a button · a value in a body size · the Accent mode used to colour a row of tiles (they must differ within the row).
Keywords: kpi, tile, metric, stat, number, delta, headline figure, dashboard, card metric
Figma — page KPI tile: KPI tile (8 variants)
The markup — copy this
Section titled “The markup — copy this”The class, the parts, and nothing that is not in the package. The same block opens kpi-tile.css.
<div class="pui-kpi-tile pui-kpi-tile--blue"> <div class="pui-kpi-tile__top"><span class="pui-kpi-tile__value">$150K+</span><span class="pui-badge pui-badge--green pui-badge--small">+12 %</span></div> <div class="pui-kpi-tile__bottom"><span class="pui-kpi-tile__label">Monthly revenue growth</span> <div class="pui-kpi-tile__detail"><span class="pui-icon pui-icon--24">…</span>$ 8 969 916</div></div></div>The modifiers
Section titled “The modifiers”| What it is | Note | |
|---|---|---|
| Narrow | under 200 px of its OWN width (four across on a tablet, two on a phone — the site’s 167) the tile stacks the value over its tag at space/4, steps the value to title/medium and sits 12 / 20 inside (request 42). The tile is a size container for it: it takes its width from its row and never from its content | — |
| Tone | (none) neutral · --blue · --red · --yellow. The neutral tone FOLLOWS THE GROUND (ground.css): it is surface/sunken — neutral/100 on a white page, neutral/200 on the product ground; the three tints do not move | — |
| Parts | __tag — a category in the top corner instead of a delta; never rung, whatever its tone | — |
Rule — only a STATUS delta is rung — --green up, --red down. A badge wearing the card’s own tint (grey on grey, blue on blue) is a label, not a status, and stays flush
Accessibility — measured, not assumed
Section titled “Accessibility — measured, not assumed”Every number here is measured on the rendered component: contrast (npm run contrast:render), the focus ring (npm run focus) and the keyboard (npm run keyboard). The floors are principle 12’s.
Contrast
Section titled “Contrast”| ink on ground | reads | floor | the matrix calls it | |
|---|---|---|---|---|
#ec6663 on #f9d3d2 |
2.29 | 4.5 | Red badge / chip (exempt) | axe |
#6a66e4 on #d4d3f7 |
3.12 | 4.5 | Blue badge / chip (exempt) | axe |
#1a6b36 on #d4f2de |
5.48 | 4.5 | Success badge (promised) | ok |
#634e0f on #fad567 |
5.62 | 4.5 | Yellow badge / chip (promised) | ok |
#544d4d on #dad9d9 |
5.85 | 4.5 | text.muted on ground.card.sunken (promised) | ok |
#0b0202 on #e9e9fb |
17.1 | 4.5 | KPI value, blue tile (promised) | ok |
#0b0202 on #fce9e8 |
17.54 | 4.5 | KPI value, red tile (promised) | ok |
#0b0202 on #f2f2f2 |
18.32 | 4.5 | KPI value, neutral tile (promised) | ok |
#0b0202 on #fef9e9 |
19.47 | 4.5 | KPI value, yellow tile (promised) | ok |
#0b0202 on #ffffff |
20.51 | 4.5 | text.default on surface.page (promised) | ok |
10 distinct pair(s) rendered, 2 that axe calls a violation — each one carries its reason in storybook/test/axe-allowlist.json.
The focus ring
Section titled “The focus ring”Nothing in this component takes focus.
Keyboard
Section titled “Keyboard”Nothing in this component claims an ARIA role with a keyboard contract; its controls are ordinary ones and the browser already gives them their keys.
Tab order across the whole library is document order over 344 stops, and Shift+Tab is its exact reverse — no keyboard trap anywhere (WCAG 2.1.2).
Pepper UI v1.5.0
The Mobupps design system. 386 tokens, 88 components, 65 pages in the workshop — every page of this site generated from the repository, so nothing here is typed twice.