Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Currency Input

A text input for an amount of money in one currency, formatted as it is typed: $1,234.56 in English, 1.234,56 € in German, lakh groups in India and Arabic-Indic digits in Egypt.

Rendered by React 19.3.1

Loading React…
import { CurrencyInput } from '@hamur/react/currency-input'
import { Field } from '@hamur/react/field'

export default function CurrencyInputExample() {
  return (
    <Field.Root name="price" className="field">
      <Field.Label>Price</Field.Label>
      <CurrencyInput.Root
        className="currency-input"
        currency="USD"
        locale="en-US"
        min="0"
      >
        <CurrencyInput.Input
          className="input currency-input-input"
          autoComplete="transaction-amount"
        />
      </CurrencyInput.Root>
      <Field.Description className="muted">In US dollars.</Field.Description>
    </Field.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<CurrencyInput.Root>
  <CurrencyInput.Input />
</CurrencyInput.Root>

// Not in this example: CurrencyInput.Symbol

Examples

Transfer

An amount held by the app with a currency picked beside it; the yen rounds it once, the dinar shows three decimals.

CurrencyInputTransfer.tsx
import * as React from 'react'
import { CurrencyInput } from '@hamur/react/currency-input'
import { Field } from '@hamur/react/field'

const currencies = ['USD', 'EUR', 'JPY', 'KWD']
const currencyNames = new Intl.DisplayNames('en-US', { type: 'currency' })

export default function CurrencyInputTransferExample() {
  const [currency, setCurrency] = React.useState('USD')
  const [value, setValue] = React.useState<string | null>('1234.56')
  const [change, setChange] = React.useState('none yet')
  return (
    <div className="stack">
      <label className="field">
        Currency
        <select
          className="input currency-input-currency"
          autoComplete="transaction-currency"
          value={currency}
          onChange={(event) => setCurrency(event.target.value)}
        >
          {currencies.map((code) => (
            <option key={code} value={code}>
              {currencyNames.of(code)}
            </option>
          ))}
        </select>
      </label>
      <Field.Root name="amount" className="field">
        <Field.Label>Amount</Field.Label>
        <CurrencyInput.Root
          className="currency-input"
          currency={currency}
          locale="en-US"
          min="0"
          value={value}
          onValueChange={(next: string | null, details: { reason: string }) => { setValue(next) setChange(details.reason) }}
        >
          <CurrencyInput.Input
            className="input currency-input-input"
            autoComplete="transaction-amount"
          />
        </CurrencyInput.Root>
        <Field.Description className="muted">
          Each currency keeps its own number of decimals.
        </Field.Description>
      </Field.Root>
      <p className="muted currency-input-value">Value: {value ?? '(empty)'}</p>
      <p className="muted currency-input-change">Last change: {change}</p>
      <p className="muted currency-input-summary">
        You send: {CurrencyInput.format(value, { currency, locale: 'en-US' }) || 'nothing'}
      </p>
    </div>
  )
}

Locale

The same euro amount in five locales: groups, the decimal mark, Arabic-Indic digits and the symbol's side.

CurrencyInputLocale.tsx
import * as React from 'react'
import { CurrencyInput } from '@hamur/react/currency-input'
import { Field } from '@hamur/react/field'

const locales = [
  ['en-US', 'English (United States)'],
  ['de-DE', 'German (Germany)'],
  ['fr-FR', 'French (France)'],
  ['en-IN', 'English (India)'],
  ['ar-EG', 'Arabic (Egypt)'],
]

export default function CurrencyInputLocaleExample() {
  const [locale, setLocale] = React.useState('en-US')
  return (
    <div className="stack">
      <label className="field">
        Locale
        <select
          className="input currency-input-locale"
          value={locale}
          onChange={(event) => setLocale(event.target.value)}
        >
          {locales.map(([code, name]) => (
            <option key={code} value={code}>
              {name}
            </option>
          ))}
        </select>
      </label>
      <Field.Root name="budget" className="field">
        <Field.Label>Budget</Field.Label>
        <CurrencyInput.Root
          className="currency-input"
          currency="EUR"
          locale={locale}
          defaultValue="1234567.89"
        >
          <CurrencyInput.Input className="input currency-input-input" />
        </CurrencyInput.Root>
        <Field.Description className="muted">
          In euros; type . or , for the decimals.
        </Field.Description>
      </Field.Root>
    </div>
  )
}

Outside

currencyPlacement="outside" with a Symbol after the amount, as German writes the euro, and a label naming the currency.

CurrencyInputOutside.tsx
import { CurrencyInput } from '@hamur/react/currency-input'
import { Field } from '@hamur/react/field'

export default function CurrencyInputOutsideExample() {
  return (
    <Field.Root name="amount" className="field">
      <Field.Label>Amount (EUR)</Field.Label>
      <CurrencyInput.Root
        className="currency-input currency-input-outside"
        currency="EUR"
        locale="de-DE"
        currencyPlacement="outside"
        defaultValue="1234.5"
      >
        <CurrencyInput.Symbol className="currency-input-symbol" />
        <CurrencyInput.Input className="input currency-input-input" />
      </CurrencyInput.Root>
    </Field.Root>
  )
}

Accounting

A balance shown in parentheses when negative and with a minus sign while edited, said in Field.Description.

CurrencyInputAccounting.tsx
import { CurrencyInput } from '@hamur/react/currency-input'
import { Field } from '@hamur/react/field'

export default function CurrencyInputAccountingExample() {
  return (
    <Field.Root name="balance" className="field">
      <Field.Label>Balance</Field.Label>
      <CurrencyInput.Root
        className="currency-input"
        currency="USD"
        locale="en-US"
        format={{ currencySign: 'accounting' }}
        defaultValue="-1234.5"
      >
        <CurrencyInput.Input className="input currency-input-input" />
      </CurrencyInput.Root>
      <Field.Description className="muted">
        Negative amounts are shown in parentheses; type - or ( to change the sign.
      </Field.Description>
    </Field.Root>
  )
}

Fixed Decimal

fixedDecimalEntry: the digits fill the amount from the cents, as on a payment terminal.

CurrencyInputFixedDecimal.tsx
import { CurrencyInput } from '@hamur/react/currency-input'
import { Field } from '@hamur/react/field'

export default function CurrencyInputFixedDecimalExample() {
  return (
    <Field.Root name="tip" className="field">
      <Field.Label>Tip</Field.Label>
      <CurrencyInput.Root
        className="currency-input"
        currency="USD"
        locale="en-US"
        min="0"
        fixedDecimalEntry
      >
        <CurrencyInput.Input className="input currency-input-input" />
      </CurrencyInput.Root>
      <Field.Description className="muted">Type the cents too: 1025 is $10.25.</Field.Description>
    </Field.Root>
  )
}

Validation

A transfer from $10.00 to $5,000.00, with a message for each bound when the field is left.

CurrencyInputValidation.tsx
import { CurrencyInput } from '@hamur/react/currency-input'
import { Field } from '@hamur/react/field'

export default function CurrencyInputValidationExample() {
  return (
    <Field.Root name="transfer" className="field" validationMode="onBlur">
      <Field.Label>Transfer</Field.Label>
      <CurrencyInput.Root
        className="currency-input"
        currency="USD"
        locale="en-US"
        min="10"
        max="5000"
      >
        <CurrencyInput.Input
          className="input currency-input-input"
          autoComplete="transaction-amount"
        />
      </CurrencyInput.Root>
      <Field.Description className="muted">From $10.00 to $5,000.00.</Field.Description>
      <Field.Error className="error" match="rangeUnderflow">
        The smallest transfer is $10.00.
      </Field.Error>
      <Field.Error className="error" match="rangeOverflow">
        The largest transfer is $5,000.00.
      </Field.Error>
    </Field.Root>
  )
}

Form

A required amount with a max in a form, with a message for a missing and a too large amount, and a reset.

CurrencyInputForm.tsx
import * as React from 'react'
import { CurrencyInput } from '@hamur/react/currency-input'
import { Field } from '@hamur/react/field'
import { Form } from '@hamur/react/form'

export default function CurrencyInputFormExample() {
  const [submitted, setSubmitted] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(_values, details) => {
        const form = details.event.target as HTMLFormElement
        setSubmitted([...new FormData(form)].map(([key, value]) => `${key}=${value}`).join('&'))
      }}
    >
      <Field.Root name="amount" className="field">
        <Field.Label>Amount</Field.Label>
        <CurrencyInput.Root
          className="currency-input"
          currency="USD"
          locale="en-US"
          min="0"
          max="5000"
          defaultValue="250"
          required
        >
          <CurrencyInput.Input
            className="input currency-input-input"
            autoComplete="transaction-amount"
          />
        </CurrencyInput.Root>
        <Field.Description className="muted">Up to $5,000.00.</Field.Description>
        <Field.Error className="error" match="valueMissing">
          Enter an amount.
        </Field.Error>
        <Field.Error className="error" match="rangeOverflow">
          Enter at most $5,000.00.
        </Field.Error>
      </Field.Root>
      <div className="row">
        <button className="button" type="submit">
          Send
        </button>
        <button className="button" type="reset" onClick={() => setSubmitted('')}>
          Reset
        </button>
      </div>
      {submitted ? <pre className="output">{submitted}</pre> : null}
    </Form>
  )
}

API reference

24 props across 3 parts, in React syntax. className, style, render are listed once, under Shared props.

InputCurrencyInput.Input

The text input that shows the amount formatted while the user types. It is the field's labelled control; its value is the Root's. Renders an <input>.

Input props
PropTypeDefault
typeThe input's type."text"'text'
inputModeThe virtual keyboard to show."search" | "text" | "none" | "tel" | "url" | "email" | "numeric" | "decimal"—
autoCompleteThe autocomplete hint, such as 'transaction-amount'.React.HTMLInputAutoCompleteAttribute'off'
autoCorrectThe autocorrect hint for virtual keyboards.string'off'
spellCheckWhether the browser checks the spelling of the value.Booleanishfalse
Input data attributes
Data attributeDescription
data-negativePresent when the value is below zero.
data-disabledPresent when the currency input is disabled.
data-readonlyPresent when the currency input is readonly.
data-requiredPresent when the currency input is required.
data-validPresent when the currency input is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the currency input is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the currency input has been touched (when wrapped in Field.Root).
data-dirtyPresent when the currency input's value has changed (when wrapped in Field.Root).
data-filledPresent when the currency input has a value.
data-focusedPresent when the input is focused.

RootCurrencyInput.Root

Groups the currency input and its symbol, holds the amount, and renders the hidden input the form submits. Renders a <div>.

Root props
PropTypeDefault
currencyrequiredThe ISO 4217 code of the currency, such as 'USD'.string—
localeThe locale that formats the amount.Intl.LocalesArgument—
formatIntl.NumberFormat options: currencyDisplay, currencySign, useGrouping,CurrencyInputFormat—
currencyPlacementWhether the currency text is part of the input's text, or drawn by CurrencyInput.SymbolCurrencyInputPlacement'inline'
valueThe amount as a canonical decimal string, null when empty.string | null—
defaultValueThe amount when the component is initially rendered, and the amount a form reset restores.string | nullnull
onValueChangeCallback fired when the amount changes.((value: string | null, eventDetails: CurrencyInputRootChangeEventDetails) => void)—
onValueCommittedCallback fired when the input loses focus after the user changed the amount.((value: string | null, eventDetails: CurrencyInputRootCommitEventDetails) => void)—
onValueInvalidCallback fired when typed, pasted or dropped text is refused.((value: string, eventDetails: CurrencyInputRootInvalidEventDetails) => void)—
minThe smallest valid amount.string | number—
maxThe largest valid amount.string | number—
formatOnBlurWhether the input shows the plain amount while focused and the formatted text otherwise.booleanfalse
fixedDecimalEntryWhether typed digits fill the amount from its last fraction digit, so typing 1 0 2 5booleanfalse
nameIdentifies the field when a form is submitted.string—
formThe id of the form element the hidden input belongs to.string—
requiredWhether the user must enter a value before submitting a form.booleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the value.booleanfalse
inputRefA ref to the hidden input that holds the form value.React.Ref<HTMLInputElement>—
Root data attributes
Data attributeDescription
data-negativePresent when the value is below zero.
data-disabledPresent when the currency input is disabled.
data-readonlyPresent when the currency input is readonly.
data-requiredPresent when the currency input is required.
data-validPresent when the currency input is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the currency input is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the currency input has been touched (when wrapped in Field.Root).
data-dirtyPresent when the currency input's value has changed (when wrapped in Field.Root).
data-filledPresent when the currency input has a value.
data-focusedPresent when the input is focused.

SymbolCurrencyInput.Symbol

The currency text drawn outside the input when currencyPlacement is 'outside', with the side the locale puts it on. Hidden from assistive technology. Renders a <span>.

Symbol data attributes
Data attributeDescription
data-sideIndicates the side of the amount the locale puts the currency on, in logical order.
data-negativePresent when the value is below zero.
data-disabledPresent when the currency input is disabled.
data-focusedPresent when the input is focused.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
Inserts the digit at the caret; the groups are written again and the caret follows itInput
A letter or another character the amount does not take changes nothingInput
.,Types the decimal mark, whatever the locale writesInput
-Changes the sign; the caret keeps its placeInput
BackspaceDeletes the digit before the caret, past the group marksInput
BackspaceDeleteNext to the decimal mark, moves the caret over it instead of joining the digitsInput
ControlZMetaZUndoes the last edit; Meta+Z on Apple platformsInput
ControlShiftZControlYRedoes the edit undone lastInput

Accessibility

What the parts set
PartSetsSo that
Inputa plain input with no role, no aria-roledescription and no aria-valuenow; value is the formatted textit is read as the text field it is, with the amount the user sees
Inputaria-labelledby the Field label, aria-describedby its description and errors, aria-invalid, aria-requiredthe name, the currency, the error and the rule are said with the field
Inputinputmode decimal (numeric for a currency with no decimals, text on iOS when negatives are allowed), autocorrect off, spellcheck falsethe keyboard has the keys the amount needs, and nothing rewrites the text by itself
Inputa minus sign (U+2212) for a negative amount while focused, the accounting parentheses only while not focusedscreen readers say "minus" where they would skip a hyphen or the parentheses
Symbolaria-hidden="true", no rolethe currency is seen beside the field and said by its label, not twice
Rootthe hidden input type="number" with tabindex="-1", aria-hidden="true", name, required, min, max and step="any"the form value and its validity never add a stop or a name

Focus order

  1. The Input is one tab stop; the Symbol and the hidden input are never focused.
  2. Focus by Tab, by the label or by the app keeps the selection the browser puts there; nothing takes focus on mount or on a form reset.