Skip to content
Palette Inherited colour
Download ZIP

Inherited colour palette · 5.8 KB ZIP File receipt View as Markdown View code

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.
cmp_formatted_number_01 · version 1.0.0 · Inherited colour palette
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
FormattedNumber.svelte Svelte · 5.8 KB Raw
<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>

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.

Suggested location
src/lib/components/formatted-number-01
Required props
valuelocale

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.

Example

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>

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
valuenumber | bigint | null | undefinedYesNoneThe number to format. NaN, null, undefined or a non-number renders the fallback; -0 is shown as 0.
localestringYesNoneBCP 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.
optionsIntl.NumberFormatOptionsNoNonePassed to Intl.NumberFormat: style (decimal, percent, currency, unit), currency, unit, unitDisplay, notation, signDisplay, fraction and significant digits.
fallbackstringNo'—'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.
fallbackLabelstringNoNoneWhat screen readers hear in place of the fallback. Defaults to 'Not available' for the default dash; a custom fallback is read as written.
tabularbooleanNotrueTabular 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.
classstringNoNoneExtra classes on the element, for weight, size or colour.
partsSnippet<[Intl.NumberFormatPart[]]>NoNoneCustom 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 page

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.

Public CSS variables

VariableToken
--formatted-number-inkink
--formatted-number-mutedmuted

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 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.

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.