Stat
A metric card for displaying a single key number with optional context: label, trend indicator, period-over-period change, and description. Designed for dashboards and analytics summaries.
Basic Usage
With Trend Indicator
Use the change + trend props to signal period-over-period movement:
Trend Variants
Note: trend="down" uses danger/red color because "going down" in most metrics is negative. For metrics where down is good (churn, errors, bounce rate), flip the semantics in your data layer before passing to the component — the churn tile above does exactly that, which is why a +0.3% sits under a red arrow.
trend therefore carries the sentiment, and the arrow and the color are the two things it drives. What a screen reader hears is taken from change instead: a + is announced as "increased" and a - as "decreased", whatever trend says, because a listener has no visible figure beside the word to reconcile it against. Reading the sentiment aloud made the churn tile announce "2.4%, decreased, +0.3%".
An unsigned change is left to trend. change="12%" is a magnitude and says nothing about direction, so the component takes the caller at their word rather than guessing a sign for them.
With Icon
Or use the iconSlot for custom icons:
<x-wirekit::stat label="Revenue" value="€31,200">
<x-slot:iconSlot>
<svg>...</svg>
</x-slot:iconSlot>
</x-wirekit::stat>
Stats Group
Use <x-wirekit:: to wrap multiple stat cards in a responsive grid:
Explicit Columns
Set a fixed number of columns:
Cascading Stagger
When the children carry animateIn or are wrapped in <x-wirekit::, the stagger flag cascades their entrances with an incremental delay so each stat card lands a beat after the previous one:
stagger (boolean) uses the default 75ms step. Pass an integer for a custom rhythm: :stagger="125". Caps at index 8 to avoid runaway delays. Honors prefers-reduced-motion: reduce (delays collapse to 0).
Stats Group Props
| Prop | Type | Default | Description |
|---|---|---|---|
cols |
string|null | null |
Fixed columns: '2', '3', '4', '5'. Default: auto-fit (≥200px per card) |
stagger |
bool|int|null | null |
When true, cascades child entrance animations at 75ms steps. Pass an integer to override the step (e.g. :stagger="125"). |
scope |
string|null | null |
Scoped personalization key |
Citation Footnote
Use the citation prop to attach a small subtle footnote (--text-wk-xs / --color-wk-text-subtle) below the change row — useful for source disclosures or measurement windows.
Counter Animation
Set animate to scroll-into-view-trigger a counter animation from 0 to the target value (ease-out cubic, ~1.2s). Respects prefers-reduced-motion: reduce — the value snaps to target with no animation if the OS-level setting is enabled. The static value remains in the DOM as a <span x-text="value"> fallback so search engines, no-JS browsers, and Alpine-pre-init paint all see the real number.
The counter runs once when the stat scrolls into view — it does not re-count every time the stat passes the viewport, which would be distracting on a dashboard the reader scrolls through.
To start it again, dispatch wirekit:stat-replay on the stat's root element:
// 1. The stat's root — the element carrying `data-target`.
const stat = document.querySelector('[data-target="12500"]');
// 2. Restart the count-up from zero. Safe to call repeatedly.
stat.dispatchEvent(new CustomEvent('wirekit:stat-replay'));
Under prefers-reduced-motion: reduce the value is already at its target and the event does
nothing — a restart is still motion.
When the frames stop arriving
The count-up is driven by request, and the browser only runs those callbacks
while the document is actually being painted. A stat inside a hidden tab, a zero-sized container
or a background tab therefore gets no frames at all — and since a restart resets the value to
zero before it counts, that zero would be what a reader sees when the panel finally opens.
The component watches for exactly that. A restart that has not reached its target after the animation's own duration plus a short grace settles the value on the target directly, without animating, so the number is never left standing at zero because the frames never came. Nothing is asked of you: it applies to every restart, and a normal run finishes long before the watch fires and is left alone.
One consequence worth knowing when you script against it: after a restart the value is guaranteed to be correct within roughly a second and a half, whether or not the stat was visible for any of it.
A symbol around the number is put back on the side it came from — $1,250.50 counts up to
$1,250.50, 42% to 42% — and the in-flight value is grouped and rounded the way your
application's locale writes numbers, to the number of decimals you wrote. So a German
application passing value="€31.200" counts to €31.200 rather than to €31,20: the value is
read in the locale it is written in, not in one fixed convention.
Animation Scope
The animate flag drives the value text only. Every other stat element (label, description, change row, citation, sparkline slot, progress slot, iconSlot) renders statically from first paint and does not participate in the animation cycle.
| Element | Animated? | Rendering |
|---|---|---|
value |
yes | Counts 0 → target via Alpine plugin (1.2s ease-out cubic) |
label |
no | Static |
description |
no (default) | Static; opt-in to descriptionDeferred (defer fade-in) or descriptionAnimate (synchronous color count-up) — see Three Description Options below |
change indicator |
no | Static |
citation |
no | Static |
iconSlot, sparkline, progress |
no | Static |
Layout stability (CLS). The stat reserves vertical space for the final value from first paint via the always-rendered <span x-text="value"> SR-fallback. The count-up does not shift surrounding content — verified to within 1px on the description's vertical position (CLS budget < 0.01) across the animation window.
Screen reader contract. SR users hear the static target value via the <span x-text="value"> fallback. There is no in-flight announcement for the count-up — the canonical SR text is the final number. The description, label, and change row are announced in their static rendered form.
Reduced-motion contract. Under prefers-reduced-motion: reduce, the value snaps to the target instantly (no count-up, no Alpine animation cycle). All description-animation opt-ins (Options A and C below) also degrade to static rendering under reduced motion.
Description behavior during count-up
The description below the value renders statically by default — it is fully visible from first paint, alongside the counting value. Two opt-in props change that behavior when the description should react to the count-up:
| Prop | Behavior |
|---|---|
| (none — default) | Description renders statically from first paint. Best for accessibility and screen-reader users. |
descriptionDeferred |
Description hides while the counter runs (~1.2s) and fades in once the value settles. Choose when the description is decorative and the reveal-on-settle reads cleaner. |
descriptionAnimate |
Description text color interpolates from --color-wk-text-muted to --color-wk-text synchronously with the count-up. Choose when the description is informationally critical and a subtle reinforcement on landing helps. |
<x-wirekit::stat label="Customers" value="12500" description="this year" animate descriptionDeferred />
<x-wirekit::stat label="Customers" value="12500" description="this year" animate descriptionAnimate />
description and description are mutually exclusive — passing both throws. Both honor prefers-reduced-motion: reduce and degrade to the static default.
Counter + Entrance Reveal
animate (counter) and animateIn (entrance reveal) compose cleanly. The outer wrapper carries the entrance keyframe, the inner element runs the counter — no Alpine x-data conflicts:
Sparkline + Progress Slots
Two structural slots — sparkline and progress — sit between the value and the change row. They render their content as-is with no additional styling, so you control the visualization entirely. Pair them with the <x-wirekit:: component or any inline SVG/Chart.js render.
The progress slot accepts a <x-wirekit:: component — set its variant to color the bar against the value's threshold. A near-quota reading reads better as warning than the neutral default:
Custom Value via Slot
When a simple string isn't enough, use the default slot:
<x-wirekit::stat label="Revenue">
<x-wirekit::text as="span" intent="accent">€31,200</x-wirekit::text>
<x-wirekit::text as="span" size="sm" intent="muted">EUR</x-wirekit::text>
</x-wirekit::stat>
Width & Layout
Individual stats auto-size to their content. Use the <x-wirekit:: wrapper with cols to create grid layouts:
<x-wirekit::stats cols="3">
<x-wirekit::stat label="Users" value="1,204" />
<x-wirekit::stat label="Revenue" value="$48k" />
<x-wirekit::stat label="Orders" value="342" />
</x-wirekit::stats>
To constrain the overall width, wrap the stats grid:
<div class="max-w-2xl">
<x-wirekit::stats cols="2">…</x-wirekit::stats>
</div>
A Value That Fits Its Tile
A long value in a narrow tile, revenue in a currency with many digits or a count in the millions, can shrink to fit its line with fit instead of running out of the tile. The value stays on one line and shrinks down to its floor, while the label, the change and the description keep their size. fit alone uses the --text-wk-fit-floor token, three quarters of the value's size, and a length such as fit="1rem" sets a floor of its own. With animate, the value counts up at the size of its final value, so it does not grow or shrink while it counts. fit fits value; a value written in the slot keeps its size. How the fitting works and what it costs is on the Fit Text page.
Intent Tiles
Set intent to turn a stat into a KPI tile with an intent-colored border and a faintly tinted body — the dashboard pattern without the hand-rolled inline styles. The tile becomes a labeled role="group" for screen readers.
Linking a Tile
Give a stat an href and the whole tile becomes a link, for a figure that leads into its report.
It keeps its place in a stats grid, shows the kit's focus ring on keyboard focus and a raised
shadow on hover, and is named by what it shows, label and value first. wire:navigate and target
reach the link as they are written; a tile with target="_blank" gets rel="noopener noreferrer"
and tells a screen reader that it opens a new tab.
An intent tile with href is a link as well, rather than a group: a group role on the element
would take its link role away.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string|null | null |
Metric label (small, muted) |
value |
string|null | null |
Primary value (large, prominent) |
change |
string|null | null |
Period-over-period change (e.g. '+12%') |
trend |
string|null | null |
'up', 'down', 'neutral', or null |
icon |
string|null | null |
Semantic icon alias shown top-right |
intent |
string|null | null |
Color role for the metric — 'primary', 'accent', 'success', 'warning', 'danger', 'info', 'neutral'. |
description |
string|null | null |
Secondary text (e.g. 'vs last month') |
descriptionDeferred |
bool | false |
Render the description only after the value has animated in (pairs with animate). |
descriptionAnimate |
bool | false |
Interpolate the description's text color from --color-wk-text-muted to --color-wk-text in step with the count-up. The FADE-IN is descriptionDeferred, which is a different prop and mutually exclusive with this one. |
citation |
string|null | null |
Small subtle footnote below the change row (e.g. data source, measurement window) |
href |
string|null | null |
Makes the whole tile a link (<a>) into the figure's report, with the focus ring and a hover shadow. See Linking a Tile. |
animate |
bool | false |
Opt-in counter animation: when true, the value text wraps in an Alpine wirekitStatAnimate handler that animates 0 → target over 1.2s (ease-out cubic) once the stat scrolls 40% into view. Respects prefers-reduced-motion: reduce (snaps to value, no animation). A symbol around the number is put back on the side it came from, and the in-flight value is grouped in your application's locale to the decimals you wrote. |
animateIn |
string|null | null |
Entrance-reveal preset (e.g. 'fade', 'slide-up'). Composes cleanly with animate: the outer wrapper carries the entrance reveal, the inner element keeps the counter handler. |
fit |
bool|string|null | null |
Shrinks the value from value down to a floor so it fits its line: true for the --text-wk-fit-floor token, or a length such as 1rem. A value in the slot keeps its size. See A Value That Fits Its Tile. |
scope |
string|null | null |
Scoped personalization name |
Slots
| Slot | Purpose |
|---|---|
| default | Custom value rendering (used when value prop is null) |
iconSlot |
Custom icon/SVG (overrides icon prop) |
sparkline |
Inline visualization between the value and the change row (no built-in styling) |
progress |
Inline progress indicator between the value and the change row (no built-in styling) |
Accessibility
- The trend arrow glyph (▲/▼/→) is marked
aria-hidden="true"— its meaning is duplicated by ansr-onlyspan ("increased","decreased","unchanged") so screen readers announce the direction without relying on visual glyphs - Use the card inside a semantic grid/section landmark for proper document outline
- Value text uses
tabular-nums(monospace digits) so trailing decimals in grids align visually - With
hrefthe tile is one link, named by everything it shows in order: label, value, change with its direction, description and citation
Keyboard Interaction
Without href a stat is not interactive and takes no keyboard input. With href it is a link:
| Key | Action |
|---|---|
Tab |
Move focus to the tile |
Enter |
Follow the link |
Pitfalls
- Don't put a link or a button inside a tile with
href. The tile is the link already, and a control inside a link is invalid HTML that a pointer cannot tell apart from the tile. - Don't use stat for prose. It renders a tile with its own surface, border and type scale, which stands in a paragraph as a box. For a figure inside flowing copy, write it in
<x-wirekit::.text>
Design Tokens
| Element | Token |
|---|---|
| Card background | --color-wk-bg-elevated |
| Card border | --color-wk-border |
| Card radius | --radius-wk-lg |
| Card padding, the same on every side | --padding-wk-x-lg |
| Label color | --color-wk-text-muted |
| Value color | --color-wk-text |
| Trend up color | --color-wk-success |
| Trend down color | --color-wk-danger-text |
| Trend neutral color | --color-wk-text-muted |
| Icon color | --color-wk-text-subtle |
Floor of a value with fit and no floor of its own |
--text-wk-fit-floor |
Customization
Override defaults without publishing views via config/:
'components' => [
'stat' => [
// Count up to the value on first paint instead of rendering it directly.
'animate' => false,
],
],