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.
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::stats> 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::reveal>, 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 requestAnimationFrame, 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.
Numeric prefixes and suffixes ($, %, etc.) are preserved through the animation; toLocaleString() formats the in-flight value with locale-aware thousand-separators.
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 />
descriptionDeferred and descriptionAnimate 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::progress> component or any inline SVG/Chart.js render.
The progress slot accepts a <x-wirekit::progress> 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">
<span class="text-indigo-500">€31,200</span>
<span class="text-sm text-gray-500">EUR</span>
</x-wirekit::stat>
Width & Layout
Individual stats auto-size to their content. Use the <x-wirekit::stats> 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>
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.
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 |
Fade the description in rather than showing it immediately. |
citation |
string|null | null |
Small subtle footnote below the change row (e.g. data source, measurement window) |
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). Numeric prefixes / suffixes ($, %) are preserved through the animation. |
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. |
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
Keyboard Interaction
This component is purely presentational and does not respond to keyboard input.
Pitfalls
- Don't use stat for prose. It carries
<dt>/<dd>semantics — screen readers announce as a definition list. For inline metrics in flowing copy, use<x-wirekit::heading>+<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 | --padding-wk-x-lg / --padding-wk-y-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 |
Customization
Override defaults without publishing views via config/wirekit.php:
'components' => [
'stat' => [],
],