Skip to main content
Copy for LLM

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

Plain stat
Revenue
€31,200

With Trend Indicator

Use the change + trend props to signal period-over-period movement:

Trend indicator + description
Revenue
€31,200
increased +12% vs last month

Trend Variants

Up, down, neutral
Users
1,250
increased +18% vs last month
Churn
2.4%
increased +0.3% vs last month
NPS
62
unchanged 0 vs last month

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

Stat with icon
Active Users
842
increased +5% last 30 days

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:

Stats group — auto-fit grid
Revenue
€31,200
increased +12%
Users
1,250
increased +8%
Churn
2.4%
increased +0.3%
NPS
62
unchanged 0

Explicit Columns

Set a fixed number of columns:

Stats group — 3 columns
Revenue
€31,200
Users
1,250
NPS
62

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:

Stats group with 75ms stagger
Revenue
€31,200
increased +12%
Users
1,250
increased +8%
NPS
62
increased +4

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.

Stat with citation
Active accounts
12,540
increased +4.2% vs Q4
Source: Internal billing snapshot, March 31 2026

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.

Stat with counter animation on scroll-into-view
Customers
12500
increased +18% this year

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.

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 />

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:

Stat that slides in AND counts up
Customers
12500
increased +18% this year

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:

Stat with progress slot — quota approaching limit
Quota usage
70 GB

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::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>

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.

Long values fitted to a row of four tiles
Revenue
€1,234,567.89
Orders
1,284,903
Refunds
€48,210.40
NPS
62

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.

KPI tiles across intents
Revenue
€421,840
increased +12.4%
Churn
3.2%
decreased -0.4%
Incidents
2

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.

Tiles that lead into their reports

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 an sr-only span ("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 href the 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/wirekit.php:

'components' => [
    'stat' => [
        // Count up to the value on first paint instead of rendering it directly.
        'animate' => false,
    ],
],

Further Reading

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store