Skip to content
Brok UI

Loading…

No results

Stat

Open source

A metric card with label, value, and a colour-coded trend delta. Add `tone` to colour by meaning instead of sign, or `href`/`as` to make the tile a filter link/button.

Version
v1.3.1
Stability
stable
License
MIT
Related
Counter Number
Chart Card
Kpi Strip

Preview

Total revenue
$48,200 +12.5%
vs. last month
Tone
Variant
Size
previews.components.stat.default.blade.php Blade
<x-ui.stat label="{{ __('Total revenue') }}" value="$48,200" delta="+12.5%" description="{{ __('vs. last month') }}" class="w-64" />

Tone options

Null Current
Positive Current
Attention Current
Negative Current
Neutral Current

Variant options

Card Current
Plain Current
Inline Current

Size options

Md Current
Lg Current

Installation

terminal
php artisan ui:add stat

Registry contract

php artisan ui:add stat writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/stat.blade.php
Registry dependencies
sparkline progress
Packages
composer: jml/brok:^0.2

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

stat.md
# Brok UI: Stat (`stat`)

A metric card with label, value, and a colour-coded trend delta. Add `tone` to colour by meaning instead of sign, or `href`/`as` to make the tile a filter link/button.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add stat
```

## Usage

```blade
<x-ui.stat label="{{ __('Total revenue') }}" value="$48,200" delta="+12.5%" description="{{ __('vs. last month') }}" class="w-64" />
```

## Props

- `label` (mixed|null, default `null`) — Label shown above the value.
- `value` (mixed|null, default `null`) — Primary figure displayed; falls back to the slot content when omitted.
- `delta` (mixed|null, default `null`) — Change amount or percentage shown next to the value, such as "+12.5%".
- `trend` (mixed|null, default `null`) — Explicit up, down, or neutral direction; derived from delta's sign when omitted.
- `tone` (mixed|null, default `null`) — Explicit positive, attention, negative, or neutral meaning, overriding the sign-derived color.
- `inverse` (bool, default `false`) — Set when a rise is bad news (spend, churn, latency): the sign-derived delta colour flips (up reads negative, down reads positive) while the value keeps the foreground colour. An explicit tone still wins.
- `description` (mixed|null, default `null`) — Optional supporting text shown below the value.
- `href` (mixed|null, default `null`) — Renders the tile as a link to this URL.
- `as` (mixed|null, default `null`) — Explicit tag override: div, button, or a.
- `variant` (card|plain|inline, default `card`) — Layout: card (bordered tile), plain (no frame, for a divided strip), or inline (label and value on one line).
- `size` (md|lg, default `md`) — Value text size: md, or lg for a hero number.
- `sparkline` (mixed|null, default `null`) — Series of numbers drawn as a small sparkline beside the value.
- `progress` (mixed|null, default `null`) — 0-100 value shown as a thin progress bar under the value.

## Use when

- Use for compact labels, counts, and concise metadata that support scanning.
- Surfacing a single KPI or metric as a tile or inline figure, optionally with a trend delta, sparkline, or progress bar.
- Turning a metric into a link or button that filters the list it summarizes, via href or as.

## Avoid when

- Do not force badges or short labels to carry complex explanations that need sentence-level context.
- Showing several related fields for one record side by side; use a card or a table row instead of stacking multiple stat tiles.

## Anti-patterns

- Encoding complex meaning in a badge alone

## Rules

- Use the `<brok:stat>` tag (or `<x-ui.stat>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/stat
- Registry JSON (files, props, contract): https://brokui.dev/r/open/stat.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

layouts.blade.php Blade
{{-- `variant="plain"` in a divided strip, `variant="inline"` for a KPI bar, `size="lg"` for a hero number. --}}
<div class="flex w-full max-w-3xl flex-col gap-8">
    <div class="grid grid-cols-1 divide-y divide-border rounded-lg border border-border bg-card sm:grid-cols-3 sm:divide-x sm:divide-y-0">
        <x-ui.stat variant="plain" label="{{ __('Orders') }}" value="1,284" delta="+4.1%" class="p-6" />
        <x-ui.stat variant="plain" label="{{ __('Average order') }}" value="€ 86,20" delta="-1.2%" class="p-6" />
        <x-ui.stat variant="plain" label="{{ __('Refund rate') }}" value="0.9%" delta="0%" class="p-6" />
    </div>

    <div class="flex flex-col gap-2 rounded-lg border border-border bg-card p-6 sm:max-w-sm">
        <x-ui.stat variant="inline" label="{{ __('Open tickets') }}" value="37" delta="+5" tone="attention" />
        <x-ui.stat variant="inline" label="{{ __('First response') }}" value="1h 12m" delta="-9m" tone="positive" />
        <x-ui.stat variant="inline" label="{{ __('CSAT') }}" value="4.8" />
    </div>

    <x-ui.stat size="lg" label="{{ __('Monthly recurring revenue') }}" value="€ 128.400" delta="+8.2%" description="{{ __('Sep 2026, vs. Aug 2026') }}" :sparkline="[80, 84, 90, 88, 96, 104, 110, 118, 128]" class="sm:max-w-md" />
</div>
sparklines.blade.php Blade
{{-- A sparkline in the trend's tone, a progress bar for a quota, an icon chip, a hero size. --}}
<div class="grid w-full max-w-3xl grid-cols-1 gap-4 sm:grid-cols-3">
    <x-ui.stat label="{{ __('Revenue') }}" value="$48.2k" delta="+12.5%" description="{{ __('vs. last month') }}" :sparkline="[12, 14, 13, 17, 16, 19, 22, 21, 25]" />
    <x-ui.stat label="{{ __('Churn') }}" value="2.4%" delta="-0.8%" description="{{ __('vs. last month') }}" :sparkline="[4.1, 3.8, 3.9, 3.2, 3.0, 2.8, 2.6, 2.4]" />
    <x-ui.stat label="{{ __('Seats used') }}" value="42 / 50" :progress="84" tone="attention" description="{{ __('8 seats left on the Team plan') }}">
        <x-slot:icon><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" /></svg></x-slot:icon>
    </x-ui.stat>
</div>
variants.blade.php Blade
<div class="grid w-full max-w-2xl grid-cols-1 gap-4 sm:grid-cols-3">
    <x-ui.stat label="{{ __('Revenue') }}" value="$48.2k" delta="+12.5%" description="{{ __('vs. last month') }}" />
    <x-ui.stat label="{{ __('Churn') }}" value="2.4%" delta="-0.8%" description="{{ __('vs. last month') }}" />
    <x-ui.stat label="{{ __('Active users') }}" value="9,312" delta="0%" description="{{ __('no change') }}" />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
label mixed | null null Label shown above the value.
value mixed | null null Primary figure displayed; falls back to the slot content when omitted.
delta mixed | null null Change amount or percentage shown next to the value, such as "+12.5%".
trend mixed | null null Explicit up, down, or neutral direction; derived from delta's sign when omitted.
tone mixed | null null Explicit positive, attention, negative, or neutral meaning, overriding the sign-derived color.
inverse bool false Set when a rise is bad news (spend, churn, latency): the sign-derived delta colour flips (up reads negative, down reads positive) while the value keeps the foreground colour. An explicit tone still wins.
description mixed | null null Optional supporting text shown below the value.
href mixed | null null Renders the tile as a link to this URL.
as mixed | null null Explicit tag override: div, button, or a.
variant card | plain | inline card Layout: card (bordered tile), plain (no frame, for a divided strip), or inline (label and value on one line).
size md | lg md Value text size: md, or lg for a hero number.
sparkline mixed | null null Series of numbers drawn as a small sparkline beside the value.
progress mixed | null null 0-100 value shown as a thin progress bar under the value.

Slots

  • default —
  • icon —

Renders as div, button, a.

Data slots

Stable hooks for CSS overrides and browser tests.

stat stat-delta stat-description stat-icon stat-label stat-sparkline stat-value

Behavior

  • Derives trend from the sign of delta when trend is not passed explicitly, so delta="+12.5%" alone produces the right color and arrow.
  • tone overrides the sign-derived coloring when a metric's meaning does not match its arithmetic sign, such as a rising backlog being bad news.
  • Renders as a div, a, or button depending on as, or as a link automatically when href is set without as.
  • Structural display primitive with no JavaScript behavior.
  • Actionable (`href`/`as`) renders a single focusable tag with a visible focus ring — never a nested interactive element.
  • Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.

Guidance

Compact label and metadata

Communicate concise status, count, or metadata.

Use when

  • Use for compact labels, counts, and concise metadata that support scanning.
  • Surfacing a single KPI or metric as a tile or inline figure, optionally with a trend delta, sparkline, or progress bar.
  • Turning a metric into a link or button that filters the list it summarizes, via href or as.

Avoid when

  • Do not force badges or short labels to carry complex explanations that need sentence-level context.
  • Showing several related fields for one record side by side; use a card or a table row instead of stacking multiple stat tiles.

Use instead

  • Visible explanatory text

Anti-patterns

  • Encoding complex meaning in a badge alone
Anatomy
root stat-icon stat-label stat-value stat-delta stat-sparkline stat-description
Theming hooks
stat stat-delta stat-description stat-icon stat-label stat-sparkline stat-value

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
No component-owned keyboard interaction; native element behavior applies.
Focus
none
  • An actionable stat (a or button) gets a visible focus ring and a hover border so keyboard and pointer users can tell it is interactive.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Safe

Livewire can update this component through forwarded wire:* attributes.

Source

The exact, editable file ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/stat.blade.php Blade
@props([
    'label' => null,
    'value' => null,
    'delta' => null,
    'trend' => null,
    'tone' => null,
    // true when a rise is bad news (spend, churn, latency): flips the
    // sign-derived delta colour only; the value keeps the foreground colour.
    'inverse' => false,
    'description' => null,
    'href' => null,
    'as' => null,
    // card (default): a bordered card. plain: no frame, for a strip of stats
    // separated by dividers. inline: label and value on one line, for a KPI bar.
    'variant' => 'card',
    // md | lg (a hero number).
    'size' => 'md',
    // A series for a sparkline drawn in the tone at the end of the value row.
    'sparkline' => null,
    // 0–100: a thin progress bar under the value (a quota, a target).
    'progress' => null,
])

@php
    // Accept a string, a backed enum or a Stringable for `variant`.
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    // Derive the trend from the delta's sign when it is not set explicitly, so
    // a caller can pass just `delta="+12.5%"` and get the right colour + arrow.
    $resolvedTrend = $trend;
    if ($resolvedTrend === null && $delta !== null) {
        $magnitude = (float) str_replace([',', ' ', '%', '+'], '', (string) $delta);
        $resolvedTrend = $magnitude > 0 ? 'up' : ($magnitude < 0 ? 'down' : 'neutral');
    }

    // `tone` says whether the figure is good or bad news, independently of the
    // delta's arithmetic sign — a KPI can be "up and bad" (returns rising) or
    // "down and good" (backlog falling). Left unset, it falls back to today's
    // sign-derived colouring, so existing `delta`/`trend` usage is unchanged.
    $resolvedTone = in_array($tone, ['positive', 'attention', 'negative', 'neutral'], true)
        ? $tone
        : match ($resolvedTrend) {
            'up' => $inverse ? 'negative' : 'positive',
            'down' => $inverse ? 'positive' : 'negative',
            default => 'neutral',
        };

    $toneClass = match ($resolvedTone) {
        'positive' => 'text-success-text',
        'attention' => 'text-warning-text',
        'negative' => 'text-destructive-text',
        default => 'text-muted-foreground',
    };

    // The value only carries tone colour once a caller opts in explicitly —
    // sign-derived tone stays on the delta alone, matching v1 exactly.
    $valueToneClass = $tone !== null ? $toneClass : '';

    // A KPI tile is usually a filter into the list it summarises. Render as a
    // link or a button when the caller wants that; keep the plain, inert
    // `<div>` default otherwise. `as` wins when it names an allowed tag,
    // else `href` implies a link, else the tile stays a `<div>`.
    $variant = in_array($variant, ['card', 'plain', 'inline'], true) ? $variant : 'card';
    $size = in_array($size, ['md', 'lg'], true) ? $size : 'md';
    $valueSize = $size === 'lg' ? 'text-4xl' : 'text-2xl';
    $sparkTone = match ($resolvedTone) { 'positive' => 'success', 'negative' => 'destructive', 'attention' => 'warning', default => 'primary' };
    $progressTone = match ($resolvedTone) { 'positive' => 'success', 'negative' => 'destructive', 'attention' => 'warning', default => 'default' };
    $allowedTags = ['div', 'button', 'a'];
    $tag = $as !== null && in_array($as, $allowedTags, true)
        ? $as
        : ($href !== null ? 'a' : 'div');
    $actionable = $tag !== 'div';
@endphp

<{{ $tag }}
    data-slot="stat"
    data-tone="{{ $resolvedTone }}"
    @if ($actionable) data-actionable="true" @endif
    @if ($tag === 'a')
        href="{{ $href }}"
    @elseif ($tag === 'button')
        type="button"
    @endif
    data-variant="{{ $variant }}"
    {{ $attributes->merge([
        'class' => match ($variant) {
                'inline' => 'flex items-baseline justify-between gap-4 text-foreground',
                'plain' => 'flex flex-col gap-2 text-foreground',
                default => 'flex flex-col gap-2 rounded-lg border border-border bg-card p-6 text-card-foreground',
            }
            .($actionable ? ' w-full text-start transition-colors hover:border-info-line focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background' : ''),
    ]) }}
>
    {{-- No label and no icon (a stat inside a titled frame such as tray):
         skip the empty header row so the flex gap does not push the value down. --}}
    @if (isset($icon) || $label !== null)
    <div class="flex items-center {{ $variant === 'inline' ? 'gap-2' : 'justify-between gap-3' }}">
        @isset($icon)
            @if ($variant !== 'inline')
                <span data-slot="stat-icon" class="order-last inline-flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground [&_svg]:size-4">{{ $icon }}</span>
            @else
                <span data-slot="stat-icon" class="text-muted-foreground [&_svg]:size-4">{{ $icon }}</span>
            @endif
        @endisset
        @if ($label !== null)
            <span data-slot="stat-label" class="text-sm font-medium text-muted-foreground">{{ $label }}</span>
        @endif
    </div>
    @endif

    <div class="flex {{ $variant === 'inline' ? 'items-baseline' : 'flex-wrap items-end' }} gap-x-3 gap-y-0.5">
        <span data-slot="stat-value" class="{{ $variant === 'inline' ? 'text-base' : $valueSize }} font-semibold tracking-tight tabular-nums {{ $valueToneClass }}">{{ $value ?? $slot }}</span>

        @if ($delta !== null)
            <span data-slot="stat-delta" class="inline-flex shrink-0 items-center gap-0.5 text-sm font-medium {{ $toneClass }}">
                @if ($resolvedTrend === 'up')
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M7 17 17 7M17 7H8M17 7v9"/></svg>
                @elseif ($resolvedTrend === 'down')
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M7 7 17 17M17 17V8M17 17H8"/></svg>
                @endif
                <span>{{ $delta }}</span>
            </span>
        @endif

        @if (is_array($sparkline) && count($sparkline) >= 2)
            <span data-slot="stat-sparkline" class="ms-auto shrink-0" aria-hidden="true">
                <x-ui.sparkline :data="$sparkline" :width="88" :height="28" :tone="$sparkTone" />
            </span>
        @endif
    </div>

    @if ($progress !== null)
        <x-ui.progress :value="(float) $progress" :tone="$progressTone" :label="$label ?? __('Progress')" class="h-1" />
    @endif

    @if ($description !== null)
        <span data-slot="stat-description" class="text-xs text-muted-foreground">{{ $description }}</span>
    @endif
</{{ $tag }}>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.3.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0