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.
cmp_formatted_number_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_formatted_number_01 version 1.0.0 with variant "default", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Inherited colour
- Version
- 1.0.0
- Digest
Full digest
sha256-1c879b5a8693bc42b0a555751e0a3729a9a2460f8f52ef7aa93c17a6e96a5e15
<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>
Usage#
On this pagePass 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.
- Suggested location
src/lib/components/formatted-number-01
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
fallbackLabelin that language.
Example
<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>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.
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.
<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#
<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.
<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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
value | number | bigint | null | undefined | Yes | None | The number to format. NaN, null, undefined or a non-number renders the fallback; -0 is shown as 0. |
locale | string | Yes | None | 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 | None | 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 | None | 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 | None | Extra classes on the element, for weight, size or colour. |
parts | Snippet<[Intl.NumberFormatPart[]]> | No | None | 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#
On this pageThe 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-inkto colour only the number, and--formatted-number-mutedfor 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-50onbg-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-5xlfont-semiboldtracking-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.mdhas 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.
Public CSS variables
| Variable | Token |
|---|---|
--formatted-number-ink | ink |
--formatted-number-muted | muted |
Accessibility#
On this page- 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
fallbackLabelto 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-950on 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.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.