---
title: Stat
description: Single statistic display
visibility: guest
draft: false
---

# 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

:::preview{title="Plain stat"}
<x-wirekit::stat label="Revenue" value="€31,200" />
:::

## With Trend Indicator

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

:::preview{title="Trend indicator + description"}
<x-wirekit::stat
    label="Revenue"
    value="€31,200"
    change="+12%"
    trend="up"
    description="vs last month"
/>
:::

### Trend Variants

:::preview{title="Up, down, neutral"}
<x-wirekit::stats cols="3">
    <x-wirekit::stat label="Users" value="1,250" change="+18%" trend="up" description="vs last month" />
    <x-wirekit::stat label="Churn" value="2.4%" change="+0.3%" trend="down" description="vs last month" />
    <x-wirekit::stat label="NPS" value="62" change="0" trend="neutral" description="vs last month" />
</x-wirekit::stats>
:::

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

:::preview{title="Stat with icon"}
<x-wirekit::stat label="Active Users" value="842" icon="user" change="+5%" trend="up" description="last 30 days" />
:::

Or use the `iconSlot` for custom icons:

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

:::preview{title="Stats group — auto-fit grid"}
<x-wirekit::stats>
    <x-wirekit::stat label="Revenue" value="€31,200" change="+12%" trend="up" />
    <x-wirekit::stat label="Users" value="1,250" change="+8%" trend="up" />
    <x-wirekit::stat label="Churn" value="2.4%" change="+0.3%" trend="down" />
    <x-wirekit::stat label="NPS" value="62" change="0" trend="neutral" />
</x-wirekit::stats>
:::

### Explicit Columns

Set a fixed number of columns:

:::preview{title="Stats group — 3 columns"}
<x-wirekit::stats cols="3">
    <x-wirekit::stat label="Revenue" value="€31,200" />
    <x-wirekit::stat label="Users" value="1,250" />
    <x-wirekit::stat label="NPS" value="62" />
</x-wirekit::stats>
:::

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

:::preview{title="Stats group with 75ms stagger"}
<x-wirekit::stats cols="3" stagger>
    <x-wirekit::stat animateIn="slide-up" label="Revenue" value="€31,200" change="+12%" trend="up" />
    <x-wirekit::stat animateIn="slide-up" label="Users" value="1,250" change="+8%" trend="up" />
    <x-wirekit::stat animateIn="slide-up" label="NPS" value="62" change="+4" trend="up" />
</x-wirekit::stats>
:::

`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.

:::preview{title="Stat with citation"}
<x-wirekit::stat
    label="Active accounts"
    value="12,540"
    change="+4.2%"
    trend="up"
    description="vs Q4"
    citation="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.

:::preview{title="Stat with counter animation on scroll-into-view"}
<x-wirekit::stat
    label="Customers"
    value="12500"
    change="+18%"
    trend="up"
    description="this year"
    animate
/>
:::

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:

```javascript
// 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. |

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

:::preview{title="Stat that slides in AND counts up"}
<x-wirekit::stat
    label="Customers"
    value="12500"
    change="+18%"
    trend="up"
    description="this year"
    animate
    animateIn="slide-up"
/>
:::

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

:::preview{title="Stat with progress slot — quota approaching limit"}
<x-wirekit::stat label="Quota usage" value="70 GB">
    <x-slot:progress>
        <x-wirekit::progress value="70" max="100" size="sm" variant="warning" />
    </x-slot:progress>
</x-wirekit::stat>
:::

## Custom Value via Slot

When a simple string isn't enough, use the default slot:

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

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

```blade
<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.

:::preview{title="KPI tiles across intents"}
<x-wirekit::row gap="md" wrap>
    <x-wirekit::stat label="Revenue" value="€421,840" change="+12.4%" trend="up" intent="success" />
    <x-wirekit::stat label="Churn" value="3.2%" change="-0.4%" trend="down" intent="warning" />
    <x-wirekit::stat label="Incidents" value="2" intent="danger" />
</x-wirekit::row>
:::

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

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

```php
'components' => [
    'stat' => [],
],
```

## Further Reading

- [Refactoring UI — Creating Depth with Typography](https://www.refactoringui.com/)
- [Material Design — Data Visualization Principles](https://m2.material.io/design/communication/data-visualization.html)
- [WAI-ARIA: Text Alternatives (`sr-only` pattern)](https://www.w3.org/WAI/tutorials/images/textual/)
- [MDN: `aria-hidden`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden)
- [MDN: `font-variant-numeric: tabular-nums`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-numeric)
