The dashboard number tile: an icon, a label, one big figure, and optional trend, hint, and click-through. "Every stats header in the product family re-invents this shape; this is the one implementation" (stat-tile.tsx:15-18).
The canon behind it. method/design-language/05-cards-and-surfaces.md §A6 (05-cards-and-surfaces.md:32, StatTile is for a single entity's summary row, not for lists) and method/design-language/09-page-composition.md §A4 (09-page-composition.md:25, composition order is fixed, stat tiles sit between PageTitle and the page's cards).
Toolkit sibling. packages/toolkit/src/text.ts (pluralize) and number.ts (getHumanReadableNumber) are the count-copy helpers a stat tile's value and label lean on; the toolkit README's module table names this doc as where text belongs (packages/toolkit/README.md, "Belongs with").
Narrow tiles. A tile is its own container. Below about 12rem of tile width the figure steps from text-3xl to text-2xl, the label wraps to two lines instead of truncating, and the trend label drops under the figure, so a seven-digit number in a two-column phone grid is never cut off. This works wherever the tile sits and needs no prop; the tile therefore needs a width from its parent (a grid track or flex-1), as it always has.
Laws it enforces#
- Every composite declares its narrow behaviour: the tile scales its own content instead of squishing it.
- Fit, then truncate, never clip: a label wraps to two lines before it is cut.
When to reach for it#
Two shapes are sanctioned, and both summarize ONE entity.
- A single record's summary row. A job's Examined, verb, Sent to review and
Duration across
grid-cols-2 md:grid-cols-4(speedway/app/routes/workspaces/jobs/run.tsx:413-437). - An account-level dashboard row. Still one entity, the account rather than a
list of records:
speedway/app/routes/workspaces/workspace.tsx:301-344,walmart-mvp/frontend/src/pages/Home.tsx:176-207andAdmin.tsx:729-738.
What the boundary excludes is a list. Canon doc 5 §A6: "StatTile is for a single entity's summary row, not for lists. ... Lists put numbers in columns instead." The same section records the dashboard row being stripped from speedway on 2026-07-29 and it has since returned, which is why both shapes are named here.
Contract#
label(required) /value(required):valuegetsfont-mono text-3xl font-semibold tabular-nums(text-2xlin a narrow tile) when it is a string or number viarenderNode; pass JSX to opt out.labelwraps to two lines.Icon/iconSize(default16) /iconClassName(IconImportProps): the icon sits in a small tinted tile whose background isbg-current/10, so it derives its color from whatevercolor/toneClassNameoriconClassNamesets, "one prop colors icon and tile" (stat-tile.tsx:64-67comment).trend("up" | "down" | "flat") /trendLabel: a small colored arrow-and-text pair beside the value (upsuccess,downerror,flatmuted). No shipped usage found in either app.hint: a muted line under the value row, in the tile's footer.onClick/href/linkAs/arrow: makes the tile interactive.hrefwins overonClickwhen both are given, and renders a real link (hover-prefetch, middle-click, copy-address all work) vialinkAs(a router link component) instead of a plain<a>.arrowdraws a right-arrow at the footer's right edge as the "this tile goes somewhere" affordance; whentoneClassName(orcolor) is also set, the arrow crossfades from grey to the tile's tone on hover using two stacked icons, because a computedgroup-hover:class on one icon would be purged by the build (stat-tile.tsx:96-106comment). No shipped usage ofonClickfound; every interactive tile in both apps useshref(+linkAs, +arrow) instead.tooltip: wraps the whole tile in the kitTooltip. The tile is rendered inside adisplay: gridwrapper specifically so the tooltip's own trigger<div>does not shrink-wrap the tile out of its grid track, keeping wrapped and bare tiles laid out identically (stat-tile.tsx:134-139).color(SemanticColor): themes the icon tile, the value, and the arrow's hover color together from the shared palette (thecoloraxis, canon14-prop-vocabulary.md).toneClassNameis the raw text-color-class escape hatch (e.g."text-warning") for a color the palette does not carry;colorwins over it. ExpliciticonClassName/valueClassNamestill win where they overlap.loading: data-slot loading. The label, icon, and hint render as themselves; only the value (and the hint, if none was given) become skeleton bars. This is distinct fromskeletonbelow: a tile withloadingstill shows its statically-known text.skeleton: renders a whole tile-shaped placeholder (icon-tile bar, label bar, value bar, footer bar), for a row whose tiles are not yet individually known.noAnimate: opts out of theanimate-fadeInUpentrance the tile plays by default on mount (mirrorsCard'snoAnimate; skeleton mode never animates).- Slot classnames per
StatTileClassNames:className,valueClassName,labelClassName.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
Icon + iconClassName + valueClassName + hint + arrow + tooltip + href + linkAs | A dashboard KPI tile that is fully clickable, colour-themed, and explains its own destination on hover | speedway/app/routes/workspaces/workspace.tsx:301-312 | The richest real usage: identity color, a hover explainer naming the destination page, and a deep link in one tile |
Icon + toneClassName + valueClassName + hint + href + arrow (no tooltip) | A dashboard KPI tile themed by outcome (success/warning/info/accent) without a hover explainer | walmart-mvp/frontend/src/pages/Home.tsx:177-186 | toneClassName alone is enough when the label and hint already say where the number leads |
loading with static label, hint, Icon, toneClassName, and value="" | A KPI row that shows its own shape (icon, label, hint) immediately while only the number is still arriving | walmart-mvp/frontend/src/pages/Admin.tsx:773, Home.tsx:80-83 | Doc 4 §A3's "never blank what you already know" applied at the tile level: everything known ahead of the fetch stays visible |
skeleton alone, looped for a fixed tile count | A KPI row whose entire shape (not just the values) is unknown yet | speedway/app/routes/workspaces/workspace.tsx:229 | Used ahead of loading in the same page: the loop renders bare skeleton tiles before any label/hint text is available at all |
A single tile wrapped in an external <Tooltip> (not the tile's own tooltip prop), explaining one anomalous number in an otherwise plain row | A one-off explanatory hover on the one tile that needs it | speedway/app/routes/workspaces/jobs/run.tsx:427-437 | The Duration tile's recoveries count needs "a recovery is the worker restarting after an interruption" explained only when recoveries > 0; the other three tiles in the same row carry no tooltip at all |
Banned combinations#
Do not use StatTile as a per-row element inside a list, "in place of table columns" per canon doc 5 §A6. The dashboard KPI row (an account-level count) is a distinct, sanctioned shape from a per-list-item tile; the rule targets the latter.
Do not set both onClick and href; href silently wins, and since real usage always reaches for href + linkAs for navigation, an onClick-only tile is untested territory in either app.
Do not size an unexplained number's tile without a hint or a tooltip. Doc 5 §A6: "A tile explains an odd number via Tooltip rather than an unexplained hint," citing the same Duration/recoveries case above.
Before you adopt this#
Five questions to answer before reaching for StatTile.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, StatTile is page content; no shell already renders it.
- Does this app already ship a local implementation of the same thing? Not applicable, no local StatTile clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in? The narrow-width scaling arrives with the next kit bump after 0.5.0; no app change is needed on upgrade.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them?
hrefsilently wins overonClick; the link itself, not a click handler, supplies navigation. - Which canon §A rules bind this surface, and which does the composition break? §A6 reserves StatTile for one entity's summary, never in place of table columns.
Travels with#
Tooltip, both as the tile's own tooltip prop and as an external wrap for a single anomalous tile in a row (see the two distinct patterns above; they are not interchangeable, the tile's own prop wraps the whole tile including its click target, an external wrap does the same but lives at the call site instead of inside the component).
PageTitle, which the tile row follows directly in the fixed composition order (canon doc 9 §A4: PageTitle → stat tiles → cards). A StatTile row is a sibling of Card, not nested inside one, per card.md's own "travels with" note.
Snippet#
import { StatTile } from "@versable-git/ui";// speedway/app/routes/workspaces/workspace.tsx:301-312<StatTile Icon="Review" iconClassName="text-warning" label="Assigned to you" value={d.reviewCount.toLocaleString()} valueClassName="text-warning" hint="parts in your review queue" arrow tooltip={<GoToPage page="review" />} href={`/workspaces/${wid}/review`} linkAs={RowLink}/>