# Formatted number

> Formats one supplied number as locale text through Intl.NumberFormat, with the locale always explicit: decimals, percentages, currency, units and compact notation, inside a \<data\> element that keeps the raw value.

- ID: `cmp_formatted_number_01`
- Slug: `formatted-number-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T22:19:03Z
- Updated: 2026-10-21
- Available versions: `1.0.0`
- Kind: control
- Primary category: `typography`
- Detail page: https://pagesugar.com/components/formatted-number-01?variant=default
- Preview: https://pagesugar.com/preview/formatted-number-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `default` | Inherited colour | yes | `sha256-1c879b5a8693bc42b0a555751e0a3729a9a2460f8f52ef7aa93c17a6e96a5e15` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/formatted-number-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass a value, a BCP 47 locale and, optionally, Intl.NumberFormat options; it renders one inline \<data\> element with the locale text. It does not detect the visitor's locale, convert currencies, parse strings into numbers or round beyond what Intl does.

Required props: `value`, `locale`

```svelte
<script lang="ts">
	import FormattedNumber from '$lib/components/formatted-number-01/FormattedNumber.svelte';
</script>

<p>
	Total:
	<FormattedNumber value={1234.5} locale="de-DE" options={{ style: 'currency', currency: 'EUR' }} />
</p>

<p>
	<FormattedNumber value={1500000} locale="en-US" options={{ notation: 'compact' }} /> tasks
</p>
```

Limitations:

- The locale is always yours to supply: read it from the user's settings, the URL or the Accept-Language header on the server, and pass the same string to the browser. A locale the runtime has no data for renders the fallback, because Intl would otherwise quietly format with the runtime's own locale and the server and browser could disagree.
- Output comes from the runtime's ICU data. Node with full ICU (the default since Node 13) and current browsers agree for common locales, but CLDR updates occasionally change a space or symbol between versions, which can show as a hydration mismatch.
- Currency style only chooses the symbol and the decimal places; 1234.5 with currency EUR is shown as euros, never converted from another currency.
- Strings are not parsed: a numeric string renders the fallback. Convert with Number() or BigInt() first.
- A malformed locale, a locale the runtime has no data for, a malformed currency code or unit, or options Intl rejects render the fallback rather than throwing. A well-formed but unknown currency code (ZZZ) is accepted by Intl and shown as the code; older runtimes may ignore options they do not know.
- The full value for compact output is read by screen readers only; sighted users who need it should see it elsewhere, for example in a table or a detail view.
- Infinity and -Infinity are formatted (∞), not treated as invalid; filter them out first if they mean missing data.
- The fallback label is read in the page's language. On a page in another language, pass fallbackLabel in that language.

## Usage guide

### Formatted number

One value in, one piece of locale text out. The locale is required on purpose: a formatter
left to pick its own locale uses the server's on the server and the visitor's in the browser,
and the page changes under the reader during hydration.

```svelte
<script lang="ts">
	import FormattedNumber from '$lib/components/formatted-number-01/FormattedNumber.svelte';

	let { data } = $props(); // data.locale comes from the user's settings or Accept-Language
</script>

<FormattedNumber value={0.256} locale={data.locale} options={{ style: 'percent' }} />
<FormattedNumber
	value={1234.5}
	locale={data.locale}
	options={{ style: 'currency', currency: 'EUR' }}
/>
<FormattedNumber
	value={42}
	locale={data.locale}
	options={{ style: 'unit', unit: 'kilometer-per-hour' }}
/>
<FormattedNumber value={1500000} locale={data.locale} options={{ notation: 'compact' }} />
```

#### What it renders

```html
<data value="1500000" lang="en-US" dir="auto" class="formatted-number …">
	<span aria-hidden="true">1.5M</span><span class="sr-only">1,500,000</span>
</data>
```

The hidden full value appears only when the visible text is abbreviated: compact or scientific
notation, or a short or narrow unit. Everything else renders the formatted text alone. Keep the `parts` snippet non-interactive:
when the hidden expansion is added, the visible copy is `aria-hidden`.

#### Styling parts

The `parts` snippet receives `formatToParts` output. Keep every part's value and change only
how it looks, so the text a screen reader hears and the text a visitor copies stay the same.

```svelte
<FormattedNumber
	value={12}
	locale="en-US"
	options={{ style: 'currency', currency: 'USD', minimumFractionDigits: 2 }}
	class="text-5xl font-semibold tracking-tight"
>
	{#snippet parts(list)}
		{#each list as part, i (i)}
			{#if part.type === 'currency' || part.type === 'decimal' || part.type === 'fraction'}
				<span class="text-zinc-500">{part.value}</span>
			{:else}
				{part.value}
			{/if}
		{/each}
	{/snippet}
</FormattedNumber>
```

#### Missing values

`NaN`, `null`, `undefined` and anything that is not a number or a bigint render the fallback.
So do a malformed currency code or unit, options Intl rejects, and a locale the runtime has no
data for, because a dash reads better than a page that throws, and a guessed locale is the
mismatch the required prop exists to prevent. The default dash is announced as "Not available"; translate it
with `fallbackLabel`, or pass a word as `fallback` and it is read as written.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `value` | number \| bigint \| null \| undefined | yes |  | The number to format. NaN, null, undefined or a non-number renders the fallback; -0 is shown as 0. |
| `locale` | `string` | yes |  | BCP 47 locale such as en-GB or ar-EG-u-nu-latn. Required so the server and the browser produce the same text; a locale the runtime has no data for renders the fallback. Also set as the element's lang. |
| `options` | `Intl.NumberFormatOptions` | no |  | Passed to Intl.NumberFormat: style (decimal, percent, currency, unit), currency, unit, unitDisplay, notation, signDisplay, fraction and significant digits. |
| `fallback` | `string` | no | `'—'` | Text shown when there is no valid number, or the locale or options cannot be used. The default dash is softened with the muted token; a fallback written as words keeps the full ink. |
| `fallbackLabel` | `string` | no |  | What screen readers hear in place of the fallback. Defaults to 'Not available' for the default dash; a custom fallback is read as written. |
| `tabular` | `boolean` | no | `true` | Tabular figures, so digits line up in columns and do not shift as values change. Turn off for numbers in running prose set in a face whose tabular digits look loose. |
| `class` | `string` | no |  | Extra classes on the element, for weight, size or colour. |
| `parts` | `Snippet<[Intl.NumberFormatPart[]]>` | no |  | Custom renderer that receives the formatToParts output, to set the currency symbol, fraction or unit apart. Rendered inside the \<data\> element; keep it non-interactive, because it is hidden from screen readers when a spoken expansion is added. |

## Customization

The number inherits the surrounding font, size, weight and colour; style it with class or the parts snippet, and retone it with --formatted-number-ink and --formatted-number-muted.

- Colour: by default the number is the surrounding text colour and the fallback dash a 64% mix of it, so it works on white, on a dark band and on a tinted card. Set --formatted-number-ink to colour only the number, and --formatted-number-muted for the dash; keep the number at 4.5:1 and the dash at 3:1 against the background.
- Dark or tinted page: set the container's text colour (text-zinc-50 on bg-zinc-950) and the number follows. Only set the tokens when the number should differ from the text around it.
- Size and weight: pass class="text-5xl font-semibold tracking-tight" for a stat, or leave it to inherit inside a sentence or a table cell.
- Symbol and fraction: pass a parts snippet and wrap the parts whose type is currency, decimal or fraction in a muted span; usage.md has the snippet. Keep every part's value, so the text a screen reader hears and the text copied are unchanged.
- Tables: right-align the cell (text-end) and keep tabular on, so decimal places line up. A column of mixed currencies lines up only if every row uses the same currencyDisplay.
- Signs: options={{ signDisplay: 'exceptZero' }} for changes (+12.5%, −3.1%, 0%); currencySign: 'accounting' shows negative amounts in brackets in locales that use them.
- Digits: Arabic locales use Arabic-Indic digits; append -u-nu-latn to the locale (ar-EG-u-nu-latn) for Western digits.

| Token | Public CSS variable |
| --- | --- |
| `ink` | `--formatted-number-ink` |
| `muted` | `--formatted-number-muted` |

## Accessibility

- Renders a \<data\> element whose value attribute is the raw number. \<data\> has generic semantics, so the number is read as ordinary text.
- The lang attribute is set to the locale, so a screen reader reads 1.234,50 with German number rules even on an English page.
- Compact and scientific output (1.5M) is hidden from screen readers and paired with a visually hidden full value (1,500,000). Short and narrow units (km/h) are paired with their long names (kilometres per hour).
- The default fallback dash is hidden from screen readers and announced as 'Not available'; set fallbackLabel to translate it.
- Colour is inherited, so contrast is whatever the page sets for its text. Only the default dash is softened, a 64% mix of the text colour (about 6.1:1 for zinc-950 on white); it is hidden from screen readers. A fallback written as words keeps the full text colour. Re-check both if you set the tokens.
- dir="auto" isolates the number and gives it its own base direction, so -1,234.5 keeps its sign on the left inside right-to-left text and an Arabic currency amount keeps its order inside left-to-right text. It never breaks across lines.
- Choosing a locale that matches the page language, and stating elsewhere what an abbreviation in a parts snippet means, is the consumer's responsibility.

Known limitations:

- The visually hidden full value is included when a range of text containing it is copied in some browsers.
- Speech output for currency symbols and unit names depends on the screen reader and its voice for the locale.
- Spoken output for compact or scientific values is the whole value to up to 21 significant digits, which can be more precise than the abbreviation on screen.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Inherited colour (`default`)
- Entry: `FormattedNumber.svelte`
- Suggested directory: `src/lib/components/formatted-number-01`
- Files: 1
- Artifact digest: `sha256-1c879b5a8693bc42b0a555751e0a3729a9a2460f8f52ef7aa93c17a6e96a5e15`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `FormattedNumber.svelte`

Role: entry · 5927 bytes · SHA-256 `bac78436541242bf6dc111eaeda5eb24a5c473295534d744422f23ada9a16b8c`

```svelte
<script lang="ts" module>
	/*
	 * One formatter per locale and options, shared by every instance. Building an
	 * Intl.NumberFormat is the expensive part; a table of a few hundred cells reuses a handful.
	 * The cache is capped, because on a server it lives as long as the process.
	 */
	const CACHE_LIMIT = 200;
	// eslint-disable-next-line svelte/prefer-svelte-reactivity -- a plain cache, never read reactively
	const formatters = new Map<string, Intl.NumberFormat | null>();

	/**
	 * The formatter, or null when it cannot format predictably: a malformed locale, a locale
	 * the runtime has no data for (Intl would quietly use the runtime's own, so the server and
	 * the browser could disagree), or options Intl rejects.
	 */
	function formatterFor(locale: string, options: Intl.NumberFormatOptions) {
		let key: string;
		try {
			key = `${locale}\u0000${JSON.stringify(options)}`;
		} catch {
			return null;
		}
		if (!formatters.has(key)) {
			let formatter: Intl.NumberFormat | null = null;
			try {
				if (Intl.NumberFormat.supportedLocalesOf(locale).length > 0) {
					formatter = new Intl.NumberFormat(locale, options);
				}
			} catch {
				// RangeError or TypeError from Intl: render the fallback rather than throw mid-page.
			}
			if (formatters.size >= CACHE_LIMIT) formatters.delete(formatters.keys().next().value!);
			formatters.set(key, formatter);
		}
		return formatters.get(key) ?? null;
	}

	/**
	 * The options a screen reader hears. Compact, scientific and engineering output ("1.5M")
	 * are spoken as the whole value, and short or narrow units ("km/h") by their long names,
	 * so no abbreviation is announced without its expansion. Digit options are dropped with
	 * the notation: they were set for the abbreviated mantissa, and kept they would read
	 * 1.23E-7 as "0".
	 */
	function spokenOptions(options: Intl.NumberFormatOptions): Intl.NumberFormatOptions {
		const spoken: Intl.NumberFormatOptions = { ...options };
		if (spoken.notation && spoken.notation !== 'standard') {
			for (const digits of [
				'compactDisplay',
				'minimumFractionDigits',
				'maximumFractionDigits',
				'minimumSignificantDigits',
				'roundingIncrement',
				'roundingPriority'
			] as const) {
				delete spoken[digits];
			}
			spoken.notation = 'standard';
			spoken.maximumSignificantDigits = 21;
		}
		if (spoken.style === 'unit') spoken.unitDisplay = 'long';
		return spoken;
	}
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		/** The number to format. NaN, null or anything that is not a number renders the fallback. */
		value: number | bigint | null | undefined;
		/** BCP 47 locale. Required, so the server and the browser format the same way. */
		locale: string;
		/** Intl.NumberFormat options: style, currency, unit, notation, fraction digits. */
		options?: Intl.NumberFormatOptions;
		/** Text shown when there is no valid number to format. */
		fallback?: string;
		/** What a screen reader hears instead of the fallback. */
		fallbackLabel?: string;
		/** Tabular figures, so digits line up in columns and do not shift as they change. */
		tabular?: boolean;
		/** Extra classes on the element. */
		class?: string;
		/** Custom rendering of the formatToParts output, to set a symbol or fraction apart. */
		parts?: Snippet<[Intl.NumberFormatPart[]]>;
	}

	let {
		value,
		locale,
		options,
		fallback = '—',
		fallbackLabel,
		tabular = true,
		class: className,
		parts
	}: Props = $props();

	// -0 is a rounding artefact, not a value anyone means to show as "−0".
	const number = $derived(
		typeof value === 'bigint' || (typeof value === 'number' && !Number.isNaN(value))
			? Object.is(value, -0)
				? 0
				: value
			: null
	);
	// One snapshot of the options, so the cache key and the formatter read the same values.
	const settings = $derived<Intl.NumberFormatOptions>({ ...options });
	const formatter = $derived(number === null ? null : formatterFor(locale, settings));
	const text = $derived(formatter && number !== null ? formatter.format(number) : null);
	const spoken = $derived.by(() => {
		if (text === null || number === null) return null;
		const full = formatterFor(locale, spokenOptions(settings))?.format(number);
		return full && full !== text ? full : null;
	});
	// The default dash is not a word, so it is announced as "Not available" unless told otherwise.
	const label = $derived(fallbackLabel ?? (fallback === '—' ? 'Not available' : undefined));
</script>

{#snippet visible()}
	{#if parts && formatter && number !== null}
		{@render parts(formatter.formatToParts(number))}
	{:else}
		{text}
	{/if}
{/snippet}

{#if text !== null && number !== null}
	<!-- dir="auto" isolates the number with its own direction, so -1,234.5 keeps its sign in RTL text. -->
	<data
		value={String(number)}
		lang={locale}
		dir="auto"
		class={[
			'formatted-number whitespace-nowrap text-(--_ink)',
			tabular && 'tabular-nums',
			className
		]}
	>
		{#if spoken}
			<span aria-hidden="true">{@render visible()}</span><span class="sr-only select-none"
				>{spoken}</span
			>
		{:else}
			{@render visible()}
		{/if}
	</data>
{:else}
	<span
		class={[
			'formatted-number formatted-number--fallback whitespace-nowrap text-(--_ink)',
			className
		]}
	>
		{#if label}
			<span aria-hidden="true" class="text-(--_muted)">{fallback}</span><span
				class="sr-only select-none">{label}</span
			>
		{:else}
			{fallback}
		{/if}
	</span>
{/if}

<style>
	/*
	 * Public tokens: by default the number takes the surrounding text colour and the fallback
	 * dash a softer mix of it, so it sits in a table, a price or a dark band without a change.
	 * Only the dash is softened: a fallback written as words is text, and keeps the full ink.
	 */
	.formatted-number {
		--_ink: var(--formatted-number-ink, currentColor);
		--_muted: var(--formatted-number-muted, color-mix(in srgb, currentColor 64%, transparent));
	}
</style>
```

## Artifacts

### Inherited colour (`default`) (default)

- Artifact digest: `sha256-1c879b5a8693bc42b0a555751e0a3729a9a2460f8f52ef7aa93c17a6e96a5e15`
- Entry: `FormattedNumber.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_formatted_number_01/1.0.0/default/sha256-1c879b5a8693bc42b0a555751e0a3729a9a2460f8f52ef7aa93c17a6e96a5e15/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_formatted_number_01/1.0.0/default/sha256-1c879b5a8693bc42b0a555751e0a3729a9a2460f8f52ef7aa93c17a6e96a5e15/bundle.zip (5907 bytes, sha256 `84b2dc8ce216f7521ef1c579db2c08a2daa9eb007ff54f1e0cdec743d6d19a9e`)

Files:

- `FormattedNumber.svelte` (entry, 5927 bytes): https://pagesugar.com/artifacts/cmp_formatted_number_01/1.0.0/default/sha256-1c879b5a8693bc42b0a555751e0a3729a9a2460f8f52ef7aa93c17a6e96a5e15/source/FormattedNumber.svelte
