Skip to content
Brok UI

Loading…

No results

Currency Input

Open source

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.

Version
v1.0.5
Stability
stable
License
MIT
Related
Input
Masked Input
Number Field

Preview

Shown formatted; submitted as a raw decimal.

Disabled
previews.components.currency-input.default.blade.php 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>

Installation

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

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

  • blade resources/views/components/ui/currency-input/currency-input.blade.php
  • js resources/js/ui/currency-input.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.

currency-input.md
# 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

manifest knowledge + registry-derived coverage

Props

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

currency-input currency-input-display

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

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 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
root currency-input-display
Theming hooks
input

Accessibility

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

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

resources/views/components/ui/currency-input/currency-input.blade.php Blade
{{-- 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>
resources/js/ui/currency-input.js JS
/**
 * 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