svelte-number-format
v2.0.0
Published
Lightweight, reactive number and pattern formatting for Svelte 5. NumericFormat & PatternFormat for inputs, NumericText & PatternText for SSR-safe display.
Downloads
537
Maintainers
Readme
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
onValueChangecallback with{ floatValue, formattedValue, value }(react-number-format compatible) - Custom pattern tokens via
customPatterns allowEmptyFormattingto show the mask skeleton before typing- SSR-safe (no
navigatorat module eval) - A11y-ready — forwards
aria-*attributes, auto-setsaria-placeholderandinputmode
🌍 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-formatQuick 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 1234 → 12.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) | AAA → ABC |
| * | 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:
PatternFormatsetsaria-placeholderto the auto-generated mask string (e.g.(___) ___-____) so screen readers announce the expected shape.PatternFormatauto-infersinputmodefrom the pattern (numeric/tel/text) to trigger the right mobile keyboard. Consumer-suppliedinputmodewins.NumericFormatgets itsinputmodefrom the underlying formatter (decimalby 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/:
/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-side
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-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. |
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.NumberFormatsupport - 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
- Inspired by react-number-format
- Built on intl-number-input
