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.
Preview
<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
Variant options
Installation
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:
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.
-
resources/views/components/ui/number-field.blade.php -
resources/js/ui/number-field.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 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
<div class="w-full max-w-xs space-y-6">
<x-ui.number-field name="qty" :value="4" :min="0" :max="10" disabled />
</div>
<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>
<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>
<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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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-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.
<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.
{{-- 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>
/**
* 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