svelte-number-format Svelte Themes

Svelte Number Format

Svelte 5 lightweight and reactive number input component, built on top of intl-number-input

svelte-number-format

Svelte Number Format is a lightweight and reactive input component library for Svelte 5.
Inspired by react-number-format, it provides two powerful components for handling formatted inputs with full caret stability and two-way binding.

Features

Four Components

  • NumericFormat — locale-aware number input (currency, percentages, decimals)
  • PatternFormat — pattern-based input masking (phone, credit cards, dates, custom)
  • NumericText — display-only rendering of formatted numbers (no input)
  • PatternText — display-only rendering of masked strings (no input)

🎯 Developer experience

  • Full TypeScript support
  • Two-way binding with bind:value
  • Svelte 5 native (runes only)
  • Caret position stability across formatting
  • Paste handling that re-formats the whole clipboard buffer in one shot
  • IME (composition) aware — doesn't break CJK input
  • Rich onValueChange callback with { floatValue, formattedValue, value } (react-number-format compatible)
  • Custom pattern tokens via customPatterns
  • allowEmptyFormatting to show the mask skeleton before typing
  • SSR-safe (no navigator at module eval)
  • A11y-ready — forwards aria-* attributes, auto-sets aria-placeholder and inputmode

🌍 Internationalization

  • Built on Intl.NumberFormat
  • Any BCP-47 locale
  • Automatic thousands / decimal / currency symbol per locale

Live Demo

Check out the working demo: https://pitis.github.io/svelte-number-format/

Installation

npm install svelte-number-format

Quick Start

Currency Input

<script lang="ts">
  import { NumericFormat, NumberFormatStyle } from 'svelte-number-format'

  let amount = $state<number | null>(1234.56)
</script>

<NumericFormat
  bind:value={amount}
  locale="en-US"
  options={{
    formatStyle: NumberFormatStyle.Currency,
    currency: 'USD',
    precision: 2
  }}
  placeholder="$0.00"
/>

Phone Number Input

<script lang="ts">
  import { PatternFormat, MaskPatterns } from 'svelte-number-format'

  let phone = $state<string | null>(null)
</script>

<PatternFormat
  bind:value={phone}
  format={MaskPatterns.PHONE_US}
  placeholder="(123) 456-7890"
/>

NumericFormat Component

Locale-aware number formatting built on intl-number-input.

Props

Prop Type Default Description
value number | string | null null The numeric value. Use bind:value for two-way binding.
valueType 'number' | 'string' 'number' Whether the bound value is emitted as a number or a decimal string.
locale string | undefined resolved lazily Locale string. Defaults to navigator.language on the client, 'en-US' during SSR.
options Partial<NumberInputOptions> {} Formatting options (see below).
onInput (raw: number | null, formatted: string | null) => void undefined Callback fired on every keystroke.
onChange (raw: number | null, formatted: string | null) => void undefined Callback fired on blur/change.
onValueChange (values: NumberFormatValues, source: SourceInfo) => void undefined Rich payload callback. See onValueChange.
...rest any All other HTML input attributes.

Options

The options prop accepts these properties:

Option Type Description
formatStyle NumberFormatStyle Decimal, Currency, or Percent
currency string Currency code (e.g., 'USD', 'EUR', 'GBP') - required for Currency style
precision number Number of decimal places
valueRange { min?: number, max?: number } Min/max value constraints
autoDecimalDigits boolean Automatically position decimal (e.g., typing 123412.34)

NumberFormatStyle Enum

import { NumberFormatStyle } from 'svelte-number-format'

NumberFormatStyle.Decimal // Plain number with locale formatting
NumberFormatStyle.Currency // Currency with symbol ($, €, £, etc.)
NumberFormatStyle.Percent // Percentage (0.75 → 75%)

Examples

Basic Number Input

<script lang="ts">
  import { NumericFormat } from 'svelte-number-format'
  let value = $state<number | null>(1234.56)
</script>

<NumericFormat
  bind:value
  options={{ precision: 2 }}
  placeholder="Enter amount"
/>
<!-- User sees: 1,234.56 -->

Currency (USD)

<script lang="ts">
  import { NumericFormat, NumberFormatStyle } from 'svelte-number-format'
  let price = $state<number | null>(99.99)
</script>

<NumericFormat
  bind:value={price}
  locale="en-US"
  options={{
    formatStyle: NumberFormatStyle.Currency,
    currency: 'USD',
    precision: 2
  }}
/>
<!-- User sees: $99.99 -->

Currency (EUR with German locale)

<NumericFormat
  bind:value={amount}
  locale="de-DE"
  options={{
    formatStyle: NumberFormatStyle.Currency,
    currency: 'EUR',
    precision: 2
  }}
/>
<!-- User sees: 1.234,56 € -->

Percentage

<script lang="ts">
  import { NumericFormat, NumberFormatStyle } from 'svelte-number-format'
  let rate = $state<number | null>(0.75) // Store as decimal
</script>

<NumericFormat
  bind:value={rate}
  options={{
    formatStyle: NumberFormatStyle.Percent,
    precision: 2
  }}
/>
<!-- User sees: 75.00% -->
<!-- Value stored as: 0.75 -->

With Value Range

<NumericFormat
  bind:value={amount}
  options={{
    precision: 2,
    valueRange: { min: 0, max: 1000 }
  }}
  placeholder="0 - 1000"
/>
<!-- Values are clamped to 0-1000 on blur -->

Auto Decimal Mode

<NumericFormat
  bind:value={price}
  options={{
    precision: 2,
    autoDecimalDigits: true
  }}
  placeholder="Type 1234 → 12.34"
/>
<!-- Typing "1234" automatically formats as "12.34" -->

With Callbacks

<script lang="ts">
  import { NumericFormat } from 'svelte-number-format'

  let value = $state<number | null>(null)

  function handleInput(raw: number | null, formatted: string | null) {
    console.log('Input:', raw, formatted)
  }

  function handleChange(raw: number | null, formatted: string | null) {
    console.log('Change:', raw, formatted)
  }
</script>

<NumericFormat
  bind:value
  options={{ precision: 2 }}
  onInput={handleInput}
  onChange={handleChange}
/>

PatternFormat Component

Pattern-based input masking for structured text inputs.

Props

Prop Type Default Description
value string | null null The raw unmasked value. Use bind:value for two-way binding.
format string '' Pattern string (e.g. '(###) ###-####'). See pattern characters.
mask string '' Deprecated — use format. Emits a dev-mode warning. Removed in 2.0.
maskChar string '_' Character shown in auto-generated placeholder for pattern positions.
placeholder string auto Placeholder text. Auto-generated from format if not provided.
customPatterns Record<string, RegExp> undefined Additional pattern tokens. See Custom patterns.
allowEmptyFormatting boolean false Render the mask skeleton as the input's value when empty. See below.
onInput (raw: string | null, formatted: string | null) => void undefined Callback fired on every keystroke (not during IME composition).
onChange (raw: string | null, formatted: string | null) => void undefined Callback fired on blur/change.
onValueChange (values: NumberFormatValues, source: SourceInfo) => void undefined Rich payload callback. See onValueChange.
...rest any All other HTML input attributes.

Pattern Characters

Character Accepts Example
# Digit (0-9) ###123
A Letter (a-zA-Z) AAAABC
* Alphanumeric (a-zA-Z0-9) ***A1B
Other Literal -, (, ), /, :, etc.

Predefined Patterns

Import ready-to-use patterns:

import { MaskPatterns } from 'svelte-number-format'

Phone Numbers

MaskPatterns.PHONE_US // (###) ###-####
MaskPatterns.PHONE_US_WITH_EXT // (###) ###-#### ext. #####
MaskPatterns.PHONE_INTERNATIONAL // +## (###) ###-####

Credit Cards

MaskPatterns.CREDIT_CARD // #### #### #### ####
MaskPatterns.CREDIT_CARD_AMEX // #### ###### #####

Dates & Time

MaskPatterns.DATE_US // ##/##/####
MaskPatterns.DATE_ISO // ####-##-##
MaskPatterns.DATE_EU // ##.##.####
MaskPatterns.TIME_12H // ##:## AM
MaskPatterns.TIME_24H // ##:##
MaskPatterns.DATETIME_US // ##/##/#### ##:##

Identification

MaskPatterns.SSN // ###-##-####
MaskPatterns.ZIP_US // #####
MaskPatterns.ZIP_US_PLUS4 // #####-####

Other

MaskPatterns.IPV4 // ###.###.###.###
MaskPatterns.MAC_ADDRESS // ##:##:##:##:##:##
MaskPatterns.HEX_COLOR // #******

Examples

Phone Number

<script lang="ts">
  import { PatternFormat, MaskPatterns } from 'svelte-number-format'
  let phone = $state<string | null>(null)
</script>

<PatternFormat bind:value={phone} format={MaskPatterns.PHONE_US} />
<!-- User types: 1234567890 -->
<!-- Display: (123) 456-7890 -->
<!-- Value stored: "1234567890" -->

Credit Card

<script lang="ts">
  import { PatternFormat, MaskPatterns } from 'svelte-number-format'
  let card = $state<string | null>(null)
</script>

<PatternFormat
  bind:value={card}
  format={MaskPatterns.CREDIT_CARD}
  placeholder="1234 5678 9012 3456"
/>
<!-- User types: 1234567890123456 -->
<!-- Display: 1234 5678 9012 3456 -->
<!-- Value stored: "1234567890123456" -->

Date

<PatternFormat
  bind:value={date}
  format={MaskPatterns.DATE_US}
  placeholder="MM/DD/YYYY"
/>
<!-- User types: 12252024 -->
<!-- Display: 12/25/2024 -->
<!-- Value stored: "12252024" -->

Social Security Number

<PatternFormat bind:value={ssn} format={MaskPatterns.SSN} />
<!-- Display: 123-45-6789 -->
<!-- Value stored: "123456789" -->

Custom Pattern

<PatternFormat
  bind:value={code}
  format="AAA-###-***"
  placeholder="ABC-123-XYZ"
/>
<!-- Accepts: [Letter][Letter][Letter]-[Digit][Digit][Digit]-[Any][Any][Any] -->
<!-- Example: ABC-123-X5Z -->
<!-- Value stored: "ABC123X5Z" -->

License Plate (Custom)

<PatternFormat bind:value={plate} format="AAA ####" placeholder="ABC 1234" />

Product Code (Custom)

<PatternFormat bind:value={product} format="***-***-***" />
<!-- Accepts any combination of letters and numbers -->

Display-only components

NumericText and PatternText render formatted values as a <span> (no input). Useful in tables, summaries, and read-only views where you want to reuse your formatting rules.

<script lang="ts">
  import {
    NumericText,
    PatternText,
    MaskPatterns,
    NumberFormatStyle
  } from 'svelte-number-format'
</script>

<!-- Display currency -->
<NumericText
  value={1234.56}
  locale="en-US"
  options={{
    formatStyle: NumberFormatStyle.Currency,
    currency: 'USD',
    precision: 2
  }}
  class="price"
/>
<!-- renders: <span class="price">$1,234.56</span> -->

<!-- Display formatted phone -->
<PatternText value="4155551234" format={MaskPatterns.PHONE_US} />
<!-- renders: <span>(415) 555-1234</span> -->

<!-- Fallback when value is null -->
<NumericText value={null} fallback="—" />

Both components accept a fallback prop for null/empty values.


onValueChange rich payload

For parity with react-number-format, both inputs accept an onValueChange callback with a structured payload:

interface NumberFormatValues {
  floatValue: number | undefined // parsed number, or undefined when empty/invalid
  formattedValue: string // what the user sees in the input
  value: string // raw string representation (e.g. "1234.56")
}

interface SourceInfo {
  event: Event | undefined
  source: 'event' | 'prop' // 'prop' if triggered by external value change
}
<script lang="ts">
  import { NumericFormat, type NumberFormatValues } from 'svelte-number-format'

  let amount = $state<number | null>(null)

  function handleValueChange(values: NumberFormatValues) {
    console.log(values.floatValue) // 1234.56
    console.log(values.formattedValue) // "$1,234.56"
    console.log(values.value) // "1234.56"
  }
</script>

<NumericFormat
  bind:value={amount}
  options={{ precision: 2 }}
  onValueChange={handleValueChange}
/>

Pick the field that matches your form-library's expectations — floatValue for Zod z.number(), value for string schemas, formattedValue for display.


Paste handling

PatternFormat correctly handles paste in one shot, not character-by-character. Pasting any string (including pre-formatted input like (415) 555-1234 into a phone mask) strips non-matching characters and re-applies the mask atomically, placing the cursor where expected.

No configuration needed — it just works.


Custom patterns

Add your own token characters for patterns that don't fit # / A / *:

<script lang="ts">
  import { PatternFormat } from 'svelte-number-format'

  let hexColor = $state<string | null>(null)
</script>

<PatternFormat
  bind:value={hexColor}
  format="HHHHHH"
  customPatterns={{ H: /[0-9a-fA-F]/ }}
  placeholder="ff00aa"
/>

<!-- Binary -->
<PatternFormat format="BBBB BBBB" customPatterns={{ B: /[01]/ }} />

Keys that collide with the built-in tokens (#, A, *) trigger a dev-mode warning and the built-in takes precedence.


allowEmptyFormatting

Show the mask skeleton in the input even when empty, so users see the expected shape before they start typing:

<PatternFormat format={MaskPatterns.PHONE_US} allowEmptyFormatting />
<!-- input.value = "(___) ___-____" even with no value -->

On focus, the caret lands at the first fillable slot.


Accessibility

Both input components forward all HTML attributes via spread, so aria-invalid, aria-describedby, aria-label, aria-errormessage, and role work out of the box. In addition:

  • PatternFormat sets aria-placeholder to the auto-generated mask string (e.g. (___) ___-____) so screen readers announce the expected shape.
  • PatternFormat auto-infers inputmode from the pattern (numeric / tel / text) to trigger the right mobile keyboard. Consumer-supplied inputmode wins.
  • NumericFormat gets its inputmode from the underlying formatter (decimal by default).
<PatternFormat
  format={MaskPatterns.PHONE_US}
  aria-label="Phone number"
  aria-invalid={!phone}
  aria-describedby="phone-error"
/>

SSR / SvelteKit

Both components render safely in SSR. NumericFormat's default locale is resolved lazily — navigator.language on the client, 'en-US' during server render — so +page.svelte using these components won't throw on the server.

<!-- +page.svelte (SSR-safe) -->
<script lang="ts">
  import { NumericFormat } from 'svelte-number-format'
  let price = $state<number | null>(19.99)
</script>

<NumericFormat bind:value={price} locale="en-US" options={{ precision: 2 }} />

Pass an explicit locale prop if you want consistent server/client rendering regardless of the visitor's browser language.


Subpath imports

For more explicit tree-shaking, import from narrow subpaths:

// Numeric only (skips loading pattern masking code)
import { NumericFormat, NumericText } from 'svelte-number-format/numeric'

// Pattern only (skips loading intl-number-input)
import { PatternFormat, PatternText } from 'svelte-number-format/pattern'

// Just the patterns constant
import { MaskPatterns } from 'svelte-number-format/patterns'

// Just display-only components
import { NumericText, PatternText } from 'svelte-number-format/display'

The root export (svelte-number-format) still works and includes everything. Modern bundlers tree-shake the root export correctly, but subpath imports are clearer about intent.


Form-library integration

Both inputs use plain bind:value on a number (NumericFormat) or a string of raw digits (PatternFormat), which makes them drop-in compatible with the popular Svelte form libraries. The playground has three worked examples — source in src/routes/demos/:

All three share one schema:

import { z } from 'zod'

export const schema = z.object({
  amount: z.number().min(0).max(1_000_000),
  phone: z.string().regex(/^\d{10}$/, 'Must be exactly 10 digits')
})

Superforms (the standard choice)

<script lang="ts">
  import { superForm } from 'sveltekit-superforms'
  import {
    NumericFormat,
    PatternFormat,
    MaskPatterns,
    NumberFormatStyle
  } from 'svelte-number-format'

  let { data } = $props()
  const { form, errors, enhance } = superForm(data.form, { dataType: 'json' })
</script>

<form method="POST" use:enhance>
  <NumericFormat
    bind:value={$form.amount}
    options={{
      formatStyle: NumberFormatStyle.Currency,
      currency: 'USD',
      precision: 2
    }}
  />
  {#if $errors.amount}<p class="error">{$errors.amount}</p>{/if}

  <PatternFormat bind:value={$form.phone} format={MaskPatterns.PHONE_US} />
  {#if $errors.phone}<p class="error">{$errors.phone}</p>{/if}

  <button type="submit">Submit</button>
</form>

Felte (client-side)

<script lang="ts">
  import { createForm } from 'felte'
  import { validator } from '@felte/validator-zod'
  import {
    NumericFormat,
    PatternFormat,
    MaskPatterns,
    NumberFormatStyle
  } from 'svelte-number-format'

  let amount = $state<number | null>(0)
  let phone = $state<string | null>('')

  const { form, errors, setFields } = createForm({
    initialValues: { amount: 0, phone: '' },
    extend: [validator({ schema })],
    onSubmit: (values) => {
      /* ... */
    }
  })

  $effect(() => {
    setFields('amount', amount ?? 0, true)
  })
  $effect(() => {
    setFields('phone', phone ?? '', true)
  })
</script>

<form use:form>
  <NumericFormat
    name="amount"
    bind:value={amount}
    options={{
      formatStyle: NumberFormatStyle.Currency,
      currency: 'USD',
      precision: 2
    }}
  />
  <PatternFormat
    name="phone"
    bind:value={phone}
    format={MaskPatterns.PHONE_US}
  />
</form>

The Felte integration needs a tiny $effect bridge because Felte's internal store is string-keyed form data populated by DOM name=… attributes, while our components emit typed values via bind:value. Both Superforms and Formsnap avoid this because they already consume a reactive store.


Migrating from react-number-format

svelte-number-format mirrors react-number-format's API where possible. The big differences come from Svelte's idioms rather than missing features.

react-number-format svelte-number-format Notes
<NumericFormat /> <NumericFormat /> Same name, same concept.
<PatternFormat /> <PatternFormat /> Same name, same concept.
<NumericFormat displayType="text" /> <NumericText /> Separate component instead of a prop.
<PatternFormat displayType="text" /> <PatternText /> Same idea for pattern masks.
value={state} + onValueChange bind:value={state} or onValueChange Use Svelte's bind:value — simpler, no need to wire state.
onValueChange={(v) => ...} onValueChange={(v, s) => ...} Payload shape is the same: { floatValue, formattedValue, value }.
format="(###) ###-####" format="(###) ###-####" Same token characters (#, but not A/* in react-number-format's default build).
format with custom patterns customPatterns={{ H: /[0-9a-f]/ }} Pass the regex map as a prop.
allowEmptyFormatting allowEmptyFormatting Same semantics.
mask="_" maskChar="_" Renamed to avoid collision with the legacy mask prop.
thousandSeparator options.useGrouping Locale-aware — set locale instead of separators.
decimalSeparator Locale-aware Set locale="de-DE" to get 1.234,56.
thousandsGroupStyle Locale-aware Locales handle grouping (en-IN1,23,456).
prefix / suffix options.formatStyle: Currency For currency, use formatStyle + currency for locale-correct symbols.
valueIsNumericString valueType="string" Emits the bound value as a string when set.

Advanced Usage

Controlled Components

Both components support controlled mode:

<script lang="ts">
  import { NumericFormat } from 'svelte-number-format'
  let amount = $state<number | null>(100)
</script>

<NumericFormat bind:value={amount} options={{ precision: 2 }} />

<button onclick={() => (amount = 100)}>$100</button>
<button onclick={() => (amount = 1000)}>$1,000</button>
<button onclick={() => (amount = null)}>Clear</button>

Form Integration

<script lang="ts">
  let formData = $state({
    price: null as number | null,
    phone: null as string | null
  })

  function handleSubmit() {
    console.log('Form data:', formData)
  }
</script>

<form onsubmit={handleSubmit}>
  <NumericFormat
    bind:value={formData.price}
    options={{ formatStyle: NumberFormatStyle.Currency, currency: 'USD' }}
  />

  <PatternFormat bind:value={formData.phone} format={MaskPatterns.PHONE_US} />

  <button type="submit">Submit</button>
</form>

Custom Styling

<NumericFormat
  bind:value={amount}
  class="my-custom-input"
  style="border: 2px solid blue;"
/>

<style>
  :global(.my-custom-input) {
    padding: 1rem;
    font-size: 1.5rem;
    border-radius: 8px;
  }
</style>

Migration from v1.x

See MIGRATION.md for the full guide.

v1.x → v2.0 breaking changes

v2.0 removes three long-deprecated APIs. None of them have functional replacements you don't already have — it's pure cleanup.

Removed Replacement Deprecated since
SvelteNumberFormat (re-export of NumericFormat) NumericFormat 1.0
SvelteMaskFormat (re-export of PatternFormat) PatternFormat 1.0
<PatternFormat mask="###"> prop <PatternFormat format="###"> 1.0
<!-- v1.x -->
<script>
  import { SvelteMaskFormat } from 'svelte-number-format'
</script>
<SvelteMaskFormat mask="(###) ###-####" />

<!-- v2.0 -->
<script>
  import { PatternFormat } from 'svelte-number-format'
</script>
<PatternFormat format="(###) ###-####" />

The v1.2 dev-mode warning for the mask prop is removed in v2.0 along with the prop itself.


TypeScript

Full TypeScript support with proper type definitions:

import type { NumberInputOptions } from 'intl-number-input'
import {
  NumericFormat,
  PatternFormat,
  NumericText,
  PatternText,
  NumberFormatStyle,
  MaskPatterns
} from 'svelte-number-format'
import type {
  MaskPattern,
  NumberFormatValues,
  OnValueChange,
  SourceInfo,
  ValueChangeSource
} from 'svelte-number-format'

Browser Support

  • Svelte 5+
  • Modern browsers with Intl.NumberFormat support
  • IE11+ with polyfills

Contributing

Contributions are welcome! This project uses:

  • Husky - Git hooks for quality checks
  • lint-staged - Run checks on staged files only
  • Pre-commit hooks - Automatic formatting, linting, and testing

Before each commit, the following runs automatically:

  • ✅ Prettier formatting
  • ✅ ESLint linting with auto-fix
  • ✅ Tests for changed files

See CONTRIBUTING.md for detailed development setup and guidelines.


License

MIT © Pitis Radu


Acknowledgments

Top categories

Loading Svelte Themes