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.
✨ Four Components
🎯 Developer experience
bind:valueonValueChange callback with { floatValue, formattedValue, value } (react-number-format compatible)customPatternsallowEmptyFormatting to show the mask skeleton before typingnavigator at module eval)aria-* attributes, auto-sets aria-placeholder and inputmode🌍 Internationalization
Intl.NumberFormatCheck out the working demo: https://pitis.github.io/svelte-number-format/
npm install svelte-number-format
<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"
/>
<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"
/>
Locale-aware number formatting built on intl-number-input.
| 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. |
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 1234 → 12.34) |
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%)
<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 -->
<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 -->
<NumericFormat
bind:value={amount}
locale="de-DE"
options={{
formatStyle: NumberFormatStyle.Currency,
currency: 'EUR',
precision: 2
}}
/>
<!-- User sees: 1.234,56 € -->
<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 -->
<NumericFormat
bind:value={amount}
options={{
precision: 2,
valueRange: { min: 0, max: 1000 }
}}
placeholder="0 - 1000"
/>
<!-- Values are clamped to 0-1000 on blur -->
<NumericFormat
bind:value={price}
options={{
precision: 2,
autoDecimalDigits: true
}}
placeholder="Type 1234 → 12.34"
/>
<!-- Typing "1234" automatically formats as "12.34" -->
<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}
/>
Pattern-based input masking for structured text inputs.
| 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. |
| Character | Accepts | Example |
|---|---|---|
# |
Digit (0-9) | ### → 123 |
A |
Letter (a-zA-Z) | AAA → ABC |
* |
Alphanumeric (a-zA-Z0-9) | *** → A1B |
| Other | Literal | -, (, ), /, :, etc. |
Import ready-to-use patterns:
import { MaskPatterns } from 'svelte-number-format'
MaskPatterns.PHONE_US // (###) ###-####
MaskPatterns.PHONE_US_WITH_EXT // (###) ###-#### ext. #####
MaskPatterns.PHONE_INTERNATIONAL // +## (###) ###-####
MaskPatterns.CREDIT_CARD // #### #### #### ####
MaskPatterns.CREDIT_CARD_AMEX // #### ###### #####
MaskPatterns.DATE_US // ##/##/####
MaskPatterns.DATE_ISO // ####-##-##
MaskPatterns.DATE_EU // ##.##.####
MaskPatterns.TIME_12H // ##:## AM
MaskPatterns.TIME_24H // ##:##
MaskPatterns.DATETIME_US // ##/##/#### ##:##
MaskPatterns.SSN // ###-##-####
MaskPatterns.ZIP_US // #####
MaskPatterns.ZIP_US_PLUS4 // #####-####
MaskPatterns.IPV4 // ###.###.###.###
MaskPatterns.MAC_ADDRESS // ##:##:##:##:##:##
MaskPatterns.HEX_COLOR // #******
<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" -->
<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" -->
<PatternFormat
bind:value={date}
format={MaskPatterns.DATE_US}
placeholder="MM/DD/YYYY"
/>
<!-- User types: 12252024 -->
<!-- Display: 12/25/2024 -->
<!-- Value stored: "12252024" -->
<PatternFormat bind:value={ssn} format={MaskPatterns.SSN} />
<!-- Display: 123-45-6789 -->
<!-- Value stored: "123456789" -->
<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" -->
<PatternFormat bind:value={plate} format="AAA ####" placeholder="ABC 1234" />
<PatternFormat bind:value={product} format="***-***-***" />
<!-- Accepts any combination of letters and numbers -->
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 payloadFor 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.
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.
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.
allowEmptyFormattingShow 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.
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"
/>
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.
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.
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/:
/demos/superforms — sveltekit-superforms + Zod with server-side validation and action handling/demos/formsnap — Formsnap headless primitives on top of Superforms, auto-wiring all a11y attributes/demos/felte — Felte with the Zod validator, fully client-sideAll 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')
})
<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>
<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.
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-IN → 1,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. |
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>
<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>
<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>
See MIGRATION.md for the full guide.
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.
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'
Intl.NumberFormat supportContributions are welcome! This project uses:
Before each commit, the following runs automatically:
See CONTRIBUTING.md for detailed development setup and guidelines.
MIT © Pitis Radu