Counters
Animated numeric read-outs — a count-up that animates into view, and a digit-roll that re-rolls each column whenever the value changes.
Number Flow — An animated rolling-digit number: when the value changes each digit column rolls vertically to its new digit, like a mechanical odometer. Supports prefix/suffix (currency, units), decimals, and an optional plus/minus stepper. Tabular-nums, RTL-aware, with a plain-text no-JS fallback and a polite live region; respects prefers-reduced-motion.
Preview
<div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
<x-ui.number-flow :value="1234" prefix="$" />
</div>
Installation
php artisan ui:add number-flow
Note
This component ships an Alpine behavior module at
resources/js/ui/number-flow.js. Import it once from your bundle so it registers on alpine:init:
import './number-flow.js';
Registry contract
php artisan ui:add number-flow
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.
-
resources/views/components/ui/number-flow.blade.php -
resources/js/ui/number-flow.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
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.
# Brok UI: Number Flow (`number-flow`)
An animated rolling-digit number: when the value changes each digit column rolls vertically to its new digit, like a mechanical odometer. Supports prefix/suffix (currency, units), decimals, and an optional plus/minus stepper. Tabular-nums, RTL-aware, with a plain-text no-JS fallback and a polite live region; respects prefers-reduced-motion.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add number-flow
```
## Usage
```blade
<div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
<x-ui.number-flow :value="1234" prefix="$" />
</div>
```
## Props
- `value` (int, default `1234`) — Current numeric value shown and animated to.
- `prefix` (string, default ``) — Text prepended to the formatted number, such as a currency symbol.
- `suffix` (string, default ``) — Text appended to the formatted number, such as a unit or percent sign.
- `decimals` (int, default `0`) — Number of decimal places shown while formatting.
- `controls` (bool, default `true`) — Shows increment and decrement buttons beside the value.
- `step` (int, default `1`) — Amount added or subtracted per increment or decrement press.
- `min` (mixed|null, default `null`) — Lower bound; the decrement button disables at this value.
- `max` (mixed|null, default `null`) — Upper bound; the increment button disables at this value.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Displaying a numeric value that changes repeatedly and should animate like an odometer each time it updates.
- Pairing a live count with increment/decrement controls, such as a quantity stepper.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The number only needs to count up once on mount; use counter-number instead.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:number-flow>` tag (or `<x-ui.number-flow>`) 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/number-flow
- Registry JSON (files, props, contract): https://brokui.dev/r/open/number-flow.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | int | 1234 | Current numeric value shown and animated to. |
| prefix | string | Text prepended to the formatted number, such as a currency symbol. | |
| suffix | string | Text appended to the formatted number, such as a unit or percent sign. | |
| decimals | int | 0 | Number of decimal places shown while formatting. |
| controls | bool | true | Shows increment and decrement buttons beside the value. |
| step | int | 1 | Amount added or subtracted per increment or decrement press. |
| min | mixed | null | null | Lower bound; the decrement button disables at this value. |
| max | mixed | null | null | Upper bound; the increment button disables at this value. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Rolls each digit column to its new value with a CSS transform transition whenever value changes; non-digit characters render statically.
- controls toggles the increment/decrement buttons, which respect min, max, and step and disable at the bounds.
- Reduced motion drops the transition so digits snap to the new value instead of animating.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Displaying a numeric value that changes repeatedly and should animate like an odometer each time it updates.
- Pairing a live count with increment/decrement controls, such as a quantity stepper.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The number only needs to count up once on mount; use counter-number instead.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- The visual rolling digits are aria-hidden; a role="status" aria-live="polite" span carries the clean formatted text for screen readers.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="number-flow-{{ $record->id }}">
<div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
<x-ui.number-flow :value="1234" prefix="$" />
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:number-flow
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'value' => 1234,
'prefix' => '',
'suffix' => '',
'decimals' => 0,
'controls' => true,
'step' => 1,
'min' => null,
'max' => null,
])
@php
// Coerce numeric props once so the no-JS fallback and the JS config stay in lock-step.
$valueNum = (float) $value;
$decimalsNum = (int) $decimals;
$stepNum = (float) $step;
$minNum = $min === null ? null : (float) $min;
$maxNum = $max === null ? null : (float) $max;
$controlsBool = filter_var($controls, FILTER_VALIDATE_BOOL);
$config = [
'value' => $valueNum,
'decimals' => $decimalsNum,
'step' => $stepNum,
'min' => $minNum,
'max' => $maxNum,
'prefix' => (string) $prefix,
'suffix' => (string) $suffix,
];
// Server-rendered fallback text (also what screen readers get before hydration).
$fallback = (string) $prefix . number_format($valueNum, $decimalsNum) . (string) $suffix;
@endphp
{{--
Number Flow — an animated rolling-digit number. Each digit is a fixed-height,
overflow-hidden window containing a vertical 0–9 strip; the strip is translated by
`translateY(calc(var(--d) * -10%))` (where --d is the live digit) with a CSS
transition, so a value change rolls every column to its new digit like an odometer.
Non-digit characters (group separators, decimal point, prefix/suffix) render
statically. Logical properties keep it correct under dir="rtl"; tabular-nums keeps
the columns aligned. Reduced motion drops the transition so digits snap.
--}}
<div
data-slot="number-flow"
x-data="uiNumberFlow(@js($config))"
{{ $attributes->merge(['class' => 'inline-flex items-center gap-3 text-foreground']) }}
>
@if ($controlsBool)
<button
type="button"
data-slot="number-flow-decrement"
@click="decrement()"
x-bind:disabled="atMin"
x-bind:aria-disabled="atMin"
aria-label="{{ __('Decrease') }}"
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-input bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/></svg>
</button>
@endif
{{-- The rolling readout. aria-live announces changes; x-text gives SRs a clean string. --}}
<span
data-slot="number-flow-value"
role="status"
aria-live="polite"
class="relative inline-flex items-center text-2xl font-semibold tabular-nums [font-variant-numeric:tabular-nums]"
>
{{-- Clean text for screen readers (kept off-screen, announced via aria-live). --}}
<span class="sr-only" x-text="formatted">{{ $fallback }}</span>
{{-- No-JS / pre-hydration fallback: the static formatted value. Hidden once Alpine boots. --}}
<span data-slot="number-flow-fallback" aria-hidden="true" x-show="false">{{ $fallback }}</span>
{{-- Visual rolling cells (JS only). --}}
<span aria-hidden="true" class="inline-flex items-center" x-cloak>
<template x-for="(cell, i) in cells" :key="i">
<span class="inline-flex">
{{-- Static character (separator, dot, prefix/suffix) --}}
<template x-if="cell.type === 'char'">
<span class="inline-block whitespace-pre" x-text="cell.char"></span>
</template>
{{-- Rolling digit window: fixed-height, overflow-hidden 0–9 strip. --}}
<template x-if="cell.type === 'digit'">
<span
data-slot="number-flow-digit"
{{-- One tabular digit is exactly 1ch wide; the window is a little
taller than 1em so ascenders and descenders never clip. --}}
class="relative inline-block h-[1.15em] w-[1ch] overflow-hidden align-bottom leading-none"
>
<span
data-slot="number-flow-strip"
class="absolute start-0 top-0 flex w-full flex-col transition-transform duration-700 ease-emphasized will-change-transform motion-reduce:transition-none"
x-bind:style="`--d: ${cell.digit}; transform: translateY(calc(var(--d) * -10%))`"
>
<span class="flex h-[1.15em] items-center justify-center">0</span>
<span class="flex h-[1.15em] items-center justify-center">1</span>
<span class="flex h-[1.15em] items-center justify-center">2</span>
<span class="flex h-[1.15em] items-center justify-center">3</span>
<span class="flex h-[1.15em] items-center justify-center">4</span>
<span class="flex h-[1.15em] items-center justify-center">5</span>
<span class="flex h-[1.15em] items-center justify-center">6</span>
<span class="flex h-[1.15em] items-center justify-center">7</span>
<span class="flex h-[1.15em] items-center justify-center">8</span>
<span class="flex h-[1.15em] items-center justify-center">9</span>
</span>
</span>
</template>
</span>
</template>
</span>
</span>
@if ($controlsBool)
<button
type="button"
data-slot="number-flow-increment"
@click="increment()"
x-bind:disabled="atMax"
x-bind:aria-disabled="atMax"
aria-label="{{ __('Increase') }}"
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-input bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
@endif
</div>
/**
* Number Flow behavior — an animated rolling-digit number (odometer style).
*
* Holds one numeric `value`, clamped to an optional [min, max] and stepped by `step`.
* The value is formatted (locale grouping + fixed `decimals`, with optional prefix and
* suffix) into a list of "cells". Each cell is either a static character (group
* separator, decimal point, prefix/suffix) or a digit. Digit cells expose the digit
* 0–9; the Blade renders a fixed 0–9 strip per digit and translates it with a CSS var,
* so changing the value rolls each column to its new digit via a `transform` transition.
*
* No window/document listeners or timers are added — Alpine's reactivity drives the
* roll — but `destroy()` is provided for symmetry and forward-safety. Under
* `prefers-reduced-motion: reduce` the consumer markup drops the transition so digits
* snap instead of rolling (the strip offset is still applied, so the value is correct).
*
* Self-registers on `alpine:init` so import order does not matter — import this file
* once from your bundle (e.g. resources/js/ui/index.js).
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiNumberFlow', (config = {}) => ({
value: config.value ?? 0,
decimals: config.decimals ?? 0,
step: config.step ?? 1,
min: config.min ?? null,
max: config.max ?? null,
prefix: config.prefix ?? '',
suffix: config.suffix ?? '',
init() {
this.value = this.clamp(this.value);
},
destroy() {
// No external listeners/timers to remove; kept for teardown symmetry.
},
clamp(v) {
v = Number(v);
if (Number.isNaN(v)) v = 0;
if (this.min !== null) v = Math.max(this.min, v);
if (this.max !== null) v = Math.min(this.max, v);
return v;
},
get atMin() {
return this.min !== null && this.value <= this.min;
},
get atMax() {
return this.max !== null && this.value >= this.max;
},
increment() {
if (this.atMax) return;
this.value = this.clamp(this.value + this.step);
},
decrement() {
if (this.atMin) return;
this.value = this.clamp(this.value - this.step);
},
/** Plain formatted string for the live region / fallback. */
get formatted() {
const body = this.value.toLocaleString(undefined, {
minimumFractionDigits: this.decimals,
maximumFractionDigits: this.decimals,
});
return `${this.prefix}${body}${this.suffix}`;
},
/**
* Ordered cells driving the visual roll. Each cell is either
* `{ type: 'digit', digit: 0..9 }` or `{ type: 'char', char: 'x' }`.
*/
get cells() {
const body = this.value.toLocaleString(undefined, {
minimumFractionDigits: this.decimals,
maximumFractionDigits: this.decimals,
});
const out = [];
for (const ch of this.prefix) out.push({ type: 'char', char: ch });
for (const ch of body) {
if (ch >= '0' && ch <= '9') {
out.push({ type: 'digit', digit: Number(ch) });
} else {
out.push({ type: 'char', char: ch });
}
}
for (const ch of this.suffix) out.push({ type: 'char', char: ch });
return out;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.5 - 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