Skip to content
Brok UI

Loading…

No results

Counters

Open source

Animated numeric read-outs — a count-up that animates into view, and a digit-roll that re-rolls each column whenever the value changes.

Version
v1.0.5
Stability
stable
License
MIT
Related
Counter Number

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

$1,234
previews.components.number-flow.default.blade.php 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>

Installation

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

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/number-flow.blade.php
  • js resources/js/ui/number-flow.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

number-flow.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

number-flow number-flow-decrement number-flow-digit number-flow-fallback number-flow-increment number-flow-strip number-flow-value

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

Scannable data display

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
root number-flow-decrement number-flow-value number-flow-fallback number-flow-digit number-flow-strip number-flow-increment
Theming hooks
number-flow number-flow-decrement number-flow-digit number-flow-fallback number-flow-increment number-flow-strip number-flow-value

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/number-flow.blade.php Blade
@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>
resources/js/ui/number-flow.js JS
/**
 * 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