Currency Input
A money input with a currency-symbol affordance and live locale formatting (thousands grouping + fixed decimals). The visible field shows the formatted amount while a hidden field carries the raw decimal value, so the server receives a clean number. Inherits a wrapping field's name via @aware and self-wires aria-invalid/aria-describedby + old() off the shared error bag.
Preview
Shown formatted; submitted as a raw decimal.
<div class="mx-auto w-full max-w-xs">
<x-ui.field name="price" label="Price" hint="Shown formatted; submitted as a raw decimal.">
<x-ui.currency-input name="price" currency="EUR" locale="nl-NL" :value="1234.5" />
</x-ui.field>
</div>
Installation
php artisan ui:add currency-input
Note
This component ships an Alpine behavior module at
resources/js/ui/currency-input.js. Import it once from your bundle so it registers on alpine:init:
import './currency-input.js';
Registry contract
php artisan ui:add currency-input
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/currency-input/currency-input.blade.php -
resources/js/ui/currency-input.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: Currency Input (`currency-input`)
A money input with a currency-symbol affordance and live locale formatting (thousands grouping + fixed decimals). The visible field shows the formatted amount while a hidden field carries the raw decimal value, so the server receives a clean number. Inherits a wrapping field's name via @aware and self-wires aria-invalid/aria-describedby + old() off the shared error bag.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add currency-input
```
## Usage
```blade
<div class="mx-auto w-full max-w-xs">
<x-ui.field name="price" label="Price" hint="Shown formatted; submitted as a raw decimal.">
<x-ui.currency-input name="price" currency="EUR" locale="nl-NL" :value="1234.5" />
</x-ui.field>
</div>
```
## Props
- `name` (mixed|null, default `null`) — Form field name; inherited from a wrapping field via @aware or set directly. Names the hidden input that carries the raw decimal.
- `value` (mixed|null, default `null`) — Initial raw decimal amount.
- `currency` (string, default `USD`) — ISO 4217 currency code that decides the symbol and decimal precision.
- `locale` (string, default `en-US`) — Locale used to format thousands grouping and the decimal separator.
- `size` (string, default `md`) — Control height, matching the shared input size scale.
- `disabled` (bool, default `false`) — Disables the field and excludes it from submission.
- `invalid` (bool, default `false`) — Forces the invalid visual state independent of the shared error bag.
- `placeholder` (mixed|null, default `null`) — Placeholder text shown in the visible field while it is empty.
## Use when
- Use when formatting, validation, or constrained entry meaningfully reduces errors, such as OTP, phone, currency, password, or numeric entry.
- Collecting a monetary amount that should show as locale-formatted currency while the server still gets a clean decimal.
- The amount needs a currency-symbol affordance and thousands grouping as the user types.
## Avoid when
- Do not add specialized behavior if a plain field would be simpler and more forgiving.
- The value is not money (counts, percentages, quantities); use input with inputmode="numeric" instead.
- A fixed, non-editable currency display; render formatted text instead of an input.
## Anti-patterns
- Adding formatting behavior that makes correction harder
## Rules
- Use the `<brok:currency-input>` tag (or `<x-ui.currency-input>`) 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/currency-input
- Registry JSON (files, props, contract): https://brokui.dev/r/open/currency-input.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 |
|---|---|---|---|
| name | mixed | null | null | Form field name; inherited from a wrapping field via @aware or set directly. Names the hidden input that carries the raw decimal. |
| value | mixed | null | null | Initial raw decimal amount. |
| currency | string | USD | ISO 4217 currency code that decides the symbol and decimal precision. |
| locale | string | en-US | Locale used to format thousands grouping and the decimal separator. |
| size | string | md | Control height, matching the shared input size scale. |
| disabled | bool | false | Disables the field and excludes it from submission. |
| invalid | bool | false | Forces the invalid visual state independent of the shared error bag. |
| placeholder | mixed | null | null | Placeholder text shown in the visible field while it is empty. |
Slots
default— Unused; the field renders from props only.x-ui.currency-input.currency-input— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Formats the visible field live as the user types, grouping thousands and holding the currency's fixed decimal places.
- Submits through a hidden field carrying the raw decimal value, so the visible formatted text never reaches the server.
- 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 monetary amount that should show as locale-formatted currency while the server still gets a clean decimal.
- The amount needs a currency-symbol affordance and thousands grouping as the user types.
Avoid when
- Do not add specialized behavior if a plain field would be simpler and more forgiving.
- The value is not money (counts, percentages, quantities); use input with inputmode="numeric" instead.
- A fixed, non-editable currency display; render formatted text instead of an input.
Use instead
- Plain text or number field with validation
Anti-patterns
- Adding formatting behavior that makes correction harder
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- Inherits aria-invalid and aria-describedby from a wrapping field so the error message stays associated with the visible input.
- The currency symbol is decorative and marked aria-hidden; it is not read as part of the accessible name.
- 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="currency-input-{{ $record->id }}">
<div class="mx-auto w-full max-w-xs">
<x-ui.field name="price" label="Price" hint="Shown formatted; submitted as a raw decimal.">
<x-ui.currency-input name="price" currency="EUR" locale="nl-NL" :value="1234.5" />
</x-ui.field>
</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:currency-input
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. The hidden
field carries that name + the raw numeric value (so the server gets a clean
decimal); the visible field is display-only (no name) and self-wires
aria-invalid/aria-describedby off the shared $errors bag, mirroring input.
Inert with no field ancestor and no own name — fully backward-compatible. --}}
@aware([
'name' => null,
])
@props([
'name' => null,
'value' => null,
'currency' => 'USD',
'locale' => 'en-US',
'size' => 'md',
'disabled' => false,
'invalid' => false,
'placeholder' => null,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$input = $styles['input'];
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
$isInvalid = $invalid || $hasBagError;
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
$resolvedId = $attributes->get('id', filled($fieldName) ? $fieldName : null);
// old() repopulates the raw numeric value after a failed redirect-back.
$resolvedValue = filled($fieldName) ? old($fieldName, $value) : $value;
$size = $size === 'default' ? 'md' : (isset($input['sizes'][$size]) ? $size : 'md');
$state = $isInvalid ? $input['states']['invalid'] : $input['states']['default'];
// Leading padding gives room for the currency symbol affordance.
$classes = trim($input['base'].' '.$state.' '.$input['sizes'][$size].' ps-8');
$config = ['value' => $resolvedValue, 'currency' => $currency, 'locale' => $locale];
@endphp
<div
data-slot="currency-input"
x-data="uiCurrencyInput(@js($config))"
@if ($hasBagError) data-invalid="true" @endif
{{ $attributes->except(['name', 'value', 'id'])->merge(['class' => 'relative w-full']) }}
>
<span
class="pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3 text-sm text-muted-foreground"
x-text="symbol"
aria-hidden="true"
>{{ $currency }}</span>
{{-- Visible, formatted field — display only, carries NO name. --}}
<input
type="text"
inputmode="decimal"
data-slot="currency-input-display"
@if (filled($resolvedId)) id="{{ $resolvedId }}" @endif
x-model="display"
@input="onInput($event)"
@blur="onBlur()"
@if (filled($placeholder)) placeholder="{{ $placeholder }}" @endif
@disabled($disabled)
@if ($isInvalid) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
autocomplete="off"
class="{{ $classes }}"
/>
{{-- Hidden field — the submit source of truth: the raw decimal value. --}}
<input
type="hidden"
@if (filled($fieldName)) name="{{ $fieldName }}" @endif
x-bind:value="raw"
@if (filled($resolvedValue)) value="{{ $resolvedValue }}" @endif
/>
</div>
/**
* Currency input behaviour.
*
* Keeps a visible, locale-formatted amount in sync with a hidden field that
* carries the RAW numeric value (a plain decimal string), so the server receives
* `1234.5` while the user sees `1,234.50` beside a static `€` affordance. The
* visible field has no `name` — only the hidden field submits — so a formatted
* string can never reach the backend.
*
* Parsing is separator-agnostic: the last `.` or `,` is treated as the decimal
* point and all other separators are stripped, so both `1.234,56` (nl-NL) and
* `1,234.56` (en-US) round-trip. Reformatting happens on blur (and init), leaving
* the user free to type naturally.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiCurrencyInput', (config = {}) => ({
currency: config.currency || 'USD',
locale: config.locale || 'en-US',
// Raw numeric string (the submit value); display is the formatted view.
raw: config.value !== null && config.value !== undefined && config.value !== '' ? String(config.value) : '',
display: '',
init() {
this.format();
},
/** Currency-style formatter: the source of the symbol and the fraction digits. */
get currencyFormatter() {
return new Intl.NumberFormat(this.locale, {
style: 'currency',
currency: this.currency,
});
},
/**
* Display formatter: the amount alone, with the currency's fraction digits
* (2 for EUR, 0 for JPY). The symbol lives in the static affordance, so the
* field itself must not repeat it.
*/
get formatter() {
const { minimumFractionDigits, maximumFractionDigits } = this.currencyFormatter.resolvedOptions();
return new Intl.NumberFormat(this.locale, {
style: 'decimal',
minimumFractionDigits,
maximumFractionDigits,
});
},
/** The bare currency symbol for the static affordance (e.g. "€"). */
get symbol() {
const part = this.currencyFormatter
.formatToParts(0)
.find((p) => p.type === 'currency');
return part ? part.value : '';
},
onInput(event) {
// Track the keystrokes verbatim while typing; normalise to `raw`.
this.display = event.target.value;
this.raw = this.parse(event.target.value);
},
/** Reduce a free-form typed string to a clean decimal string. */
parse(input) {
const negative = /-/.test(input);
let s = input.replace(/[^0-9.,]/g, '');
const lastSep = Math.max(s.lastIndexOf('.'), s.lastIndexOf(','));
if (lastSep > -1) {
const intPart = s.slice(0, lastSep).replace(/[.,]/g, '');
const fracPart = s.slice(lastSep + 1).replace(/[.,]/g, '');
s = fracPart.length ? `${intPart}.${fracPart}` : intPart;
} else {
s = s.replace(/[.,]/g, '');
}
if (s === '' || s === '.') return '';
return (negative ? '-' : '') + s;
},
onBlur() {
this.format();
},
/** Render `raw` as a localized currency string into `display`. */
format() {
const n = parseFloat(this.raw);
this.display = this.raw === '' || Number.isNaN(n) ? '' : this.formatter.format(n);
},
}));
});
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