Skip to content
Brok UI

Loading…

No results

Number Field

Open source

A numeric stepper input with decrement/increment buttons, min/max/step clamping, and keyboard arrow stepping. variant="stepper" swaps the typing surface for a live aria-live read-out — the commerce quantity control.

Version
v1.2.4
Stability
stable
License
MIT
Related
Input
Slider
Counter Number

Preview

Size
Variant
Disabled
previews.components.number-field.default.blade.php Blade
<div class="w-full max-w-xs space-y-6">
    <x-ui.number-field name="qty" :value="2" :min="0" :max="10" />
</div>

Size options

Sm Current
Root Current
Btn Current
Input Current
Value Current
Icon Current
Md Current
Lg Current

Variant options

Default Current
Stepper Current

Installation

terminal
php artisan ui:add number-field

Note

This component ships an Alpine behavior module at resources/js/ui/number-field.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './number-field.js';

Registry contract

php artisan ui:add number-field 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-field.blade.php
  • js resources/js/ui/number-field.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-field.md
# Brok UI: Number Field (`number-field`)

A numeric stepper input with decrement/increment buttons, min/max/step clamping, and keyboard arrow stepping. variant="stepper" swaps the typing surface for a live aria-live read-out — the commerce quantity control.

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

## Install

```bash
php artisan ui:add number-field
```

## Usage

```blade
<div class="w-full max-w-xs space-y-6">
    <x-ui.number-field name="qty" :value="2" :min="0" :max="10" />
</div>
```

## Props

- `name` (string|null, default `null`) — Form name for the hidden input carrying the clamped value. Inherited from a wrapping <x-ui.field name="…"> via @aware.
- `value` (int|float, default `0`) — Initial value. Clamped to min/max on init; repopulated from old() after a failed redirect-back when the field has a name.
- `min` (int|float|null, default `null`) — Lower bound. Disables the minus button at the bound and enables Home to jump to it.
- `max` (int|float|null, default `null`) — Upper bound. Disables the plus button at the bound and enables End to jump to it.
- `step` (int|float, default `1`) — Increment applied by the buttons and the arrow keys. Its decimal places also decide the rounding.
- `size` (sm|md|lg, default `md`) — Control tier — 32 / 40 / 48px, matching input and button beside it.
- `variant` (default|stepper, default `default`) — `default` is the editable spinbutton. `stepper` replaces the text input with a non-focusable, aria-live read-out flanked by the minus/plus buttons — the commerce quantity control; typing is intentionally unavailable.
- `label` (string|null, default `null`) — Accessible name for the group. An `aria-label` (or a bound `:aria-label`) passed through the attribute bag wins and suppresses the default.
- `disabled` (bool, default `false`) — Renders the whole control inert and dimmed.
- `placeholder` (string|null, default `null`) — Placeholder for the text input. Ignored by variant="stepper", which has no typing surface.
- `unit` (string|null, default `null`) — Decorative suffix shown inside the frame ("kg", "%"). aria-hidden — put the unit in `label` when it carries meaning.

## Use when

- Use when formatting, validation, or constrained entry meaningfully reduces errors, such as OTP, phone, currency, password, or numeric entry.
- Collecting a bounded number a person nudges rather than types — a quantity, a guest count, a duration.
- Adding a cart/product quantity control: variant="stepper" renders minus/plus around a live read-out.
- You need the value to round-trip through a plain form post (give it a `name`) or bind to Alpine/Livewire (`x-model`/`wire:model`).

## Avoid when

- Do not add specialized behavior if a plain field would be simpler and more forgiving.
- The value is unbounded or typed in full (a price, an amount, an ID) — use <x-ui.input type="number"> instead.
- You need a range with a visible track — use <x-ui.slider>.

## Anti-patterns

- Adding formatting behavior that makes correction harder

## Rules

- Use the `<brok:number-field>` tag (or `<x-ui.number-field>`) 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-field
- Registry JSON (files, props, contract): https://brokui.dev/r/open/number-field.json

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

Examples

disabled.blade.php Blade
<div class="w-full max-w-xs space-y-6">
    <x-ui.number-field name="qty" :value="4" :min="0" :max="10" disabled />
</div>
min-max.blade.php Blade
<div class="w-full max-w-xs space-y-6">
    <x-ui.number-field name="rating" :value="3" :min="1" :max="5" :step="1" />
    <x-ui.number-field name="weight" :value="2.5" :min="0" :max="5" :step="0.5" unit="kg" />
</div>
sizes.blade.php Blade
<div class="w-full max-w-xs space-y-4">
    <x-ui.number-field :value="1" size="sm" />
    <x-ui.number-field :value="2" size="md" />
    <x-ui.number-field :value="3" size="lg" />
</div>
stepper.blade.php Blade
<div class="flex w-full max-w-xs flex-col gap-6">
    <div class="flex flex-col gap-2">
        <span class="text-sm font-medium text-foreground">{{ __('Quantity') }}</span>
        <x-ui.number-field variant="stepper" :value="1" :min="1" :max="10" :label="__('Quantity')" class="w-fit" />
        <p class="text-xs text-muted-foreground">{{ __('Minus and plus flank a live read-out; the value is announced politely.') }}</p>
    </div>

    <div class="flex items-center gap-4">
        <x-ui.number-field variant="stepper" size="sm" :value="2" :min="1" :max="9" :label="__('Small quantity')" />
        <x-ui.number-field variant="stepper" size="lg" :value="3" :min="1" :max="9" :label="__('Large quantity')" />
    </div>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
name string | null null Form name for the hidden input carrying the clamped value. Inherited from a wrapping <x-ui.field name="…"> via @aware.
value int | float 0 Initial value. Clamped to min/max on init; repopulated from old() after a failed redirect-back when the field has a name.
min int | float | null null Lower bound. Disables the minus button at the bound and enables Home to jump to it.
max int | float | null null Upper bound. Disables the plus button at the bound and enables End to jump to it.
step int | float 1 Increment applied by the buttons and the arrow keys. Its decimal places also decide the rounding.
size sm | md | lg md Control tier — 32 / 40 / 48px, matching input and button beside it.
variant default | stepper default `default` is the editable spinbutton. `stepper` replaces the text input with a non-focusable, aria-live read-out flanked by the minus/plus buttons — the commerce quantity control; typing is intentionally unavailable.
label string | null null Accessible name for the group. An `aria-label` (or a bound `:aria-label`) passed through the attribute bag wins and suppresses the default.
disabled bool false Renders the whole control inert and dimmed.
placeholder string | null null Placeholder for the text input. Ignored by variant="stepper", which has no typing surface.
unit string | null null Decorative suffix shown inside the frame ("kg", "%"). aria-hidden — put the unit in `label` when it carries meaning.

Slots

Default Blade slot only.

Data slots

Stable hooks for CSS overrides and browser tests.

number-field number-field-input number-field-unit number-field-value

Behavior

  • Installs a lazy-loaded JavaScript behavior module.
  • 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

Specialized input

Collect constrained information with error-reducing formatting.

Use when

  • Use when formatting, validation, or constrained entry meaningfully reduces errors, such as OTP, phone, currency, password, or numeric entry.
  • Collecting a bounded number a person nudges rather than types — a quantity, a guest count, a duration.
  • Adding a cart/product quantity control: variant="stepper" renders minus/plus around a live read-out.
  • You need the value to round-trip through a plain form post (give it a `name`) or bind to Alpine/Livewire (`x-model`/`wire:model`).

Avoid when

  • Do not add specialized behavior if a plain field would be simpler and more forgiving.
  • The value is unbounded or typed in full (a price, an amount, an ID) — use <x-ui.input type="number"> instead.
  • You need a range with a visible track — use <x-ui.slider>.

Use instead

  • Plain text or number field with validation

Anti-patterns

  • Adding formatting behavior that makes correction harder
Anatomy
number-field number-field-input number-field-value number-field-unit
Theming hooks
number-field number-field-input number-field-unit number-field-value

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
ArrowUp ArrowDown Home End
Focus
native
  • The text input is role="spinbutton" with aria-valuenow, aria-valuemin and aria-valuemax; ArrowUp and ArrowDown step, Home and End jump to min and max.
  • The decrease and increase buttons have their own accessible names; give the group a label (label prop or aria-label) that names the quantity.
  • Inside a field, aria-invalid and aria-describedby point at the error text; the stepper variant announces the new value through aria-live="polite".
  • 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-field-{{ $record->id }}">
    <div class="w-full max-w-xs space-y-6">
        <x-ui.number-field name="qty" :value="2" :min="0" :max="10" />
    </div>
</div>

Validation

Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:number-field
        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-field.blade.php Blade
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware, so this
     composite self-wires old()/aria-invalid/aria-describedby off the shared
     $errors bag without the consumer repeating it. With no field ancestor and no
     own `name` it resolves to null and nothing below changes — fully
     backward-compatible. --}}
@aware([
    'name' => null,
])

@props([
    'name' => null,
    'value' => 0,
    'min' => null,
    'max' => null,
    'step' => 1,
    'size' => 'md',
    'variant' => 'default',
    'label' => null,
    'disabled' => false,
    'placeholder' => null,
    'unit' => null,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);

    $size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'md';

    // `default` is the editable spinbutton (type a value, arrow-step it).
    // `stepper` is the commerce quantity control: minus/plus flanking a live
    // read-out, no typing surface, announced through aria-live. Same state,
    // same clamping, same x-model contract — only the middle changes.
    $variant = in_array($variant, ['default', 'stepper'], true) ? $variant : 'default';

    // @aware leaves $name undefined without a wrapping field; normalise it.
    $awareName = $name ?? null;
    $fieldName = $awareName ?? $attributes->get('name');

    $bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
    $hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);

    // old() repopulates the numeric value after a failed redirect-back.
    $resolvedValue = filled($fieldName) ? old($fieldName, $value) : $value;

    // Point aria-describedby at the field's error region only when inheriting from
    // a field AND there is an error to announce (the field renders that node under
    // the same condition, so the reference is never dangling).
    $describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;

    // Heights follow the control scale (32/40/48px) so the field lines up with
    // inputs and buttons beside it. The text field starts compact (a stepper
    // shows a few digits) and grows only when the root is given a width.
    $sizes = [
        'sm' => ['root' => 'h-control-h-sm text-xs', 'btn' => 'w-control-h-sm', 'input' => 'w-12 text-xs', 'value' => 'min-w-8 px-2 text-xs', 'icon' => 'size-4'],
        'md' => ['root' => 'h-control-h-md text-sm', 'btn' => 'w-control-h-md', 'input' => 'w-16 text-sm', 'value' => 'min-w-10 px-2 text-sm', 'icon' => 'size-4'],
        'lg' => ['root' => 'h-control-h-lg text-base', 'btn' => 'w-control-h-lg', 'input' => 'w-20 text-base', 'value' => 'min-w-12 px-3 text-base', 'icon' => 'size-5'],
    ];
    $s = $sizes[$size];

    $btnBase = 'inline-flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none';

    // The stepper read-out has no typing surface, so the buttons sit flush against
    // it — the hairline divider only earns its place beside an editable field.
    $btnStart = $variant === 'stepper' ? '' : ' border-e border-input';
    $btnEnd = $variant === 'stepper' ? '' : ' border-s border-input';

    // Let a caller name the group ("Quantity for Merino Sweater") without the
    // default duplicating the attribute on the root.
    $groupLabel = $attributes->has('aria-label') || $attributes->has(':aria-label') || $attributes->has('x-bind:aria-label')
        ? null
        : ($label ?? __('Number field'));
@endphp

<div
    x-data="uiNumberField({ value: @js((float) $resolvedValue), min: @js($min !== null ? (float) $min : null), max: @js($max !== null ? (float) $max : null), step: @js((float) $step) })"
    {{-- Modelable: `x-model` (Alpine) and `wire:model` (Livewire binds through
         the same x-model contract) attach to the root. Inert without either. --}}
    x-modelable="model"
    data-slot="number-field"
    data-size="{{ $size }}"
    data-variant="{{ $variant }}"
    @if ($hasBagError) data-invalid="true" @endif
    role="group"
    @if ($groupLabel !== null) aria-label="{{ $groupLabel }}" @endif
    {{ $attributes->except(['name'])->merge(['class' => 'inline-flex items-stretch overflow-hidden rounded-md border border-input bg-background '.$s['root'].($disabled ? ' pointer-events-none opacity-50' : '')]) }}
>
    @if (filled($fieldName))
        <input type="hidden" name="{{ $fieldName }}" :value="value" />
    @endif

    <button
        type="button"
        aria-label="{{ __('Decrease') }}"
        class="{{ $btnBase.' '.$s['btn'].' rounded-s-md'.$btnStart }}"
        @disabled($disabled)
        x-bind:disabled="atMin"
        x-on:click="decrement()"
    >
        <svg class="{{ $s['icon'] }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <line x1="5" y1="12" x2="19" y2="12" />
        </svg>
    </button>

    @if ($variant === 'stepper')
        {{-- Read-out only. `aria-live="polite"` announces the new quantity after a
             press; the buttons carry their own labels, so nothing here is focusable. --}}
        <span
            data-slot="number-field-value"
            aria-live="polite"
            class="{{ 'inline-flex flex-1 items-center justify-center text-center font-medium text-foreground tabular-nums '.$s['value'] }}"
            x-text="value"
        >{{ $resolvedValue }}</span>
    @else
        <input
            type="text"
            inputmode="decimal"
            role="spinbutton"
            autocomplete="off"
            aria-label="{{ __('Value') }}"
            @if ($placeholder) placeholder="{{ $placeholder }}" @endif
            @disabled($disabled)
            @if ($hasBagError) aria-invalid="true" @endif
            @if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
            data-slot="number-field-input"
            class="{{ 'min-w-0 flex-1 border-0 bg-transparent text-center text-foreground tabular-nums focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed '.$s['input'] }}"
            x-model="value"
            x-bind:aria-valuenow="value"
            x-bind:aria-valuemin="min"
            x-bind:aria-valuemax="max"
            x-on:keydown="onKey($event)"
            x-on:change="setFromInput($event.target.value)"
            x-on:blur="setFromInput($event.target.value)"
        />
    @endif

    @if ($unit)
        <span data-slot="number-field-unit" aria-hidden="true" class="inline-flex shrink-0 items-center pe-2 text-muted-foreground">{{ $unit }}</span>
    @endif

    <button
        type="button"
        aria-label="{{ __('Increase') }}"
        class="{{ $btnBase.' '.$s['btn'].' rounded-e-md'.$btnEnd }}"
        @disabled($disabled)
        x-bind:disabled="atMax"
        x-on:click="increment()"
    >
        <svg class="{{ $s['icon'] }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <line x1="12" y1="5" x2="12" y2="19" />
            <line x1="5" y1="12" x2="19" y2="12" />
        </svg>
    </button>
</div>
resources/js/ui/number-field.js JS
/**
 * Number Field behavior.
 *
 * Holds one numeric `value`, clamped to the optional [min, max] range and snapped
 * to `step`. The decrement (−) and increment (+) buttons step the value, and
 * ArrowUp/ArrowDown on the text input do the same; Home/End jump to min/max when
 * those bounds are set. `atMin`/`atMax` getters drive the disabled state of the two
 * buttons and the `aria-valuemin`/`aria-valuemax`/`aria-valuenow` of the spinbutton.
 *
 * `setFromInput()` re-parses whatever the user typed on blur/change, falling back to
 * the last valid value for non-numeric input. The clamped value is mirrored to a
 * hidden form input via binding, so the field round-trips through a normal form post.
 *
 * Fully self-contained in x-data — it adds no window/document listeners, so there is
 * nothing to tear down. Self-registers on `alpine:init` so import order doesn't matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiNumberField', (config = {}) => ({
        value: config.value ?? 0,
        min: config.min ?? null,
        max: config.max ?? null,
        step: config.step ?? 1,

        init() {
            this.value = this.clamp(this.value);
        },

        /** Modelable value (`x-modelable="model"` on the root): the clamped number. */
        get model() {
            return this.value;
        },
        set model(v) {
            this.value = this.clamp(v ?? this.min ?? 0);
        },

        // Number of decimals implied by `step`, used to scrub binary-float dust.
        get decimals() {
            return String(this.step).split('.')[1]?.length ?? 0;
        },

        get atMin() {
            return this.min !== null && this.value <= this.min;
        },

        get atMax() {
            return this.max !== null && this.value >= this.max;
        },

        clamp(v) {
            v = Number(v);
            if (!Number.isFinite(v)) v = this.min ?? 0;
            if (this.max !== null) v = Math.min(this.max, v);
            if (this.min !== null) v = Math.max(this.min, v);
            return this.decimals ? Number(v.toFixed(this.decimals)) : v;
        },

        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);
        },

        // Re-parse the raw input on blur/change. Empty/non-numeric falls back to the
        // current (already valid) value rather than jumping to 0.
        setFromInput(raw) {
            const parsed = parseFloat(raw);
            this.value = this.clamp(Number.isFinite(parsed) ? parsed : this.value);
        },

        onKey(e) {
            switch (e.key) {
                case 'ArrowUp':
                    e.preventDefault();
                    this.increment();
                    break;
                case 'ArrowDown':
                    e.preventDefault();
                    this.decrement();
                    break;
                case 'Home':
                    if (this.min !== null) {
                        e.preventDefault();
                        this.value = this.clamp(this.min);
                    }
                    break;
                case 'End':
                    if (this.max !== null) {
                        e.preventDefault();
                        this.value = this.clamp(this.max);
                    }
                    break;
            }
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.4
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