# Price display

> One price, formatted from a number with Intl.NumberFormat for an explicit locale and currency, with an optional From prefix, billing unit and tax qualifier on the amount's baseline, and one full phrase for screen readers.

- ID: `cmp_price_display_01`
- Slug: `price-display-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: control
- Primary category: `pricing`
- Detail page: https://pagesugar.com/components/price-display-01?variant=neutral
- Preview: https://pagesugar.com/preview/price-display-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Inherited colour | yes | `sha256-563c511d3d928dd20a7cf4cc7c8824a02ddf1095e16ffd4060ad6bee6ba916cb` |
| `accent` | Accent amount | no | `sha256-3dae7fa0db92b2ed61a08da6704c61ee1e731fa6bdcc424992be44f348759f18` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/price-display-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass a number, an ISO 4217 currency and a locale, plus optional unit, prefix and qualifier text. It renders one inline price lockup. It does not convert currencies, calculate tax, detect the visitor's locale or take payment; the amount is a display value only.

Required props: `amount`, `currency`, `locale`

```svelte
<script lang="ts">
	import PriceDisplay from '$lib/components/price-display-01/PriceDisplay.svelte';
</script>

<PriceDisplay amount={12} currency="USD" locale="en-US" unit="per seat per month" size="lg" />

<PriceDisplay
	amount={20}
	currency="GBP"
	locale="en-GB"
	prefix="From"
	unit="per user per month"
	qualifier="excl. VAT"
/>
```

Limitations:

- Formatting comes from the JavaScript engine's ICU data, and ECMA-402 lets engines differ. Spaces are pinned so the commonest difference (which no-break space) cannot change the bytes, but server and browser only print identical output when their locale data agrees on symbols, digits and bidi marks; current Node and evergreen browsers agree on every fixture here.
- A locale the engine does not support falls back to 'en' rather than the host's default. A malformed currency code shows the number followed by the code; a well-formed but unknown three-letter code is shown as the code itself (for example ZZZ 12).
- A non-finite amount (NaN or Infinity, usually a failed calculation) renders as an em dash instead of a formatted price.
- The raised symbol is tuned for fonts whose cap height is about 0.72 em (Inter and most neo-grotesques). A serif or condensed face may want the align value in the SIZES map adjusted.
- fractionDigits={0} rounds (12.5 shows as 13). Use it only for amounts that are whole or where rounding is acceptable.
- Unit and qualifier are plain text read as written. Write them in words ("per month", not "/mo") so screen readers do not say "slash".
- There is no separate dark mode: the price follows the text colour of its container.

## Usage guide

### Price display

Pass a number, not a string. The locale decides where the symbol goes, which separators are
used and which digits are drawn, so the markup never hard-codes a `$` on the left.

```svelte
<script lang="ts">
	import PriceDisplay from '$lib/components/price-display-01/PriceDisplay.svelte';
</script>

<!-- A plan card: large, the symbol raised to the cap height. -->
<PriceDisplay amount={12} currency="USD" locale="en-US" unit="per seat per month" size="lg" />

<!-- A German shop: 49,50 € pro Monat zzgl. MwSt. -->
<PriceDisplay
	amount={49.5}
	currency="EUR"
	locale="de-DE"
	unit="pro Monat"
	qualifier="zzgl. MwSt."
/>

<!-- A free tier. -->
<PriceDisplay
	amount={0}
	freeLabel="Free"
	currency="USD"
	locale="en-US"
	unit="for up to three people"
/>
```

#### Choosing the locale

Pass the locale your page is written in, from your own routing or content, and pass the same
value on the server and in the browser. The component never reads `navigator.language`, so
the server render and the hydrated page print the same characters. Spaces are pinned (a
no-break space in literals, a narrow no-break space in group separators) because different
ICU versions disagree about them.

#### Sizes

| Size | Amount     | Symbol                   | Notes | Use                         |
| ---- | ---------- | ------------------------ | ----- | --------------------------- |
| `sm` | 18 px      | full size, on baseline   | 14 px | inside a line, a price list |
| `md` | 36 px      | half size, raised to cap | 14 px | comparison rows, tiles      |
| `lg` | 48 · 60 px | half size, raised to cap | 16 px | plan cards, a single offer  |

#### Fraction digits

- `'auto'` (default): `$12` for a whole amount, `$12.50` otherwise.
- `2`: always the currency's minor units: `$12.00`, but `￥1,500` because yen has none.
- `0`: rounds to whole units. `12.5` becomes `$13`, so use it only where that is acceptable.

#### What it does not do

It does not convert currencies, calculate tax or detect the visitor's region. The qualifier is
plain text. The amount is a display value; never charge from it.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `amount` | `number` | yes |  | Amount in major units, e.g. 12.5. Display only; never parsed back or charged. |
| `currency` | `string` | yes |  | ISO 4217 code such as 'USD', 'EUR' or 'JPY'. A malformed code falls back to the plain number followed by the code. |
| `locale` | `string` | yes |  | BCP 47 locale for Intl.NumberFormat, e.g. 'en-GB'. Required so server and browser format alike; the visitor's locale is never detected, and an unsupported locale falls back to 'en'. |
| `unit` | `string` | no |  | Billing unit after the amount, e.g. 'per month' or 'per seat per month'. |
| `prefix` | `string` | no |  | Qualifier before the amount, e.g. 'From' or 'Starting at'. |
| `qualifier` | `string` | no |  | Qualifier after the unit, e.g. 'excl. VAT' or 'billed annually'. |
| `freeLabel` | `string` | no |  | Shown in place of the amount when it is zero, e.g. 'Free'. Omitted or blank, zero is formatted like any other amount. |
| `fractionDigits` | 0 \| 2 \| 'auto' | no | `'auto'` | 'auto' drops .00 from whole amounts and keeps minor units otherwise; 2 always shows the currency's minor units (none for JPY); 0 rounds to whole units. |
| `size` | 'sm' \| 'md' \| 'lg' | no | `'md'` | Visual scale: sm (18 px) sits in a line of text, md (36 px) in a comparison row, lg (48 to 60 px) in a plan card. From md up the currency symbol is set small and raised. |
| `class` | `string` | no |  | Extra classes on the root span, for margins or alignment. |

## Customization

Pick size and fractionDigits through props. The price inherits its container's text colour; --price-display-accent colours the amount, --price-display-ink and --price-display-muted retone the amount and notes. Edit the SIZES map to change the scale.

- Colour: by default the amount is the surrounding text colour and the notes a 72% mix of it, so the price works in a white card, on a dark band or on a cream page without changes. The mix clears 4.5:1 only when the surrounding colour is strong (about 7:1 or better, such as zinc-900 on white); on a softer text colour set --price-display-muted.
- Accent: set --price-display-accent for a brand-coloured amount and check it against every background it sits on: 3:1 for md and lg, 4.5:1 at sm and for the md symbol. #2563eb clears white, cream and zinc-950 at lg.
- Dark or tinted page: set the container's text colour (text-zinc-50 on bg-zinc-950, or a warm brown on cream) and the price follows. Set --price-display-muted only if the notes need a different tone, and check it clears 4.5:1.
- Sizes: the SIZES map at the top of the script holds each size's amount, symbol and note classes as complete Tailwind strings. Change a step there; keep the tracking tightening as the size grows.
- Raised symbol: md and lg set the symbol at 0.5em with vertical-align 0.72em so its top meets the digits' cap height. For a font with a different cap height, adjust 0.72em; for a full-size symbol, clear the symbol string.
- Copy: write the unit and qualifier in the page's language and in words. For a free plan, pass freeLabel and a unit that reads with it ("Free for up to three people").
- Alignment: the root is an inline-flex row. Pass class="justify-end" to right-align in a price list, or wrap it in a block element.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--price-display-accent` |
| `ink` | `--price-display-ink` |
| `muted` | `--price-display-muted` |

## Accessibility

- Renders inline spans, not a heading or a paragraph; place it inside your own heading, paragraph or card structure.
- The visual fragments are aria-hidden and a visually hidden span carries the whole price as one phrase, e.g. "From 20 British pounds per user per month billed annually, excl. VAT", using the locale's spoken currency name. aria-label is not used because it is not allowed on a generic span.
- Unit and qualifier text default to a 72% mix of the surrounding colour: about 8:1 for zinc-950 on white and 10:1 for zinc-50 on zinc-950. A weaker surrounding colour drops the mix below 4.5:1 (#767676 on white gives about 2.7:1), so set --price-display-muted there.
- The amount never wraps between the symbol and the number; the unit and qualifier wrap as whole groups on narrow widths.
- The amount is isolated for bidirectional text, so in a right-to-left page the number and currency keep the locale's order.
- The spoken phrase is in the language of the page. When the price is in a different language from its surroundings, set lang (and dir for Arabic or Hebrew) on an ancestor so screen readers pronounce it correctly.

Known limitations:

- Screen readers read the unit and qualifier exactly as written, so abbreviations such as "excl." or "/mo" are read literally.
- Text copied from the page includes the visual fragments, not the spoken phrase.

## License

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

## Source

- Palette: Inherited colour (`neutral`)
- Entry: `PriceDisplay.svelte`
- Suggested directory: `src/lib/components/price-display-01`
- Files: 1
- Artifact digest: `sha256-563c511d3d928dd20a7cf4cc7c8824a02ddf1095e16ffd4060ad6bee6ba916cb`

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

#### `PriceDisplay.svelte`

Role: entry · 8109 bytes · SHA-256 `4ba55e8dd95c34fa2ac4818b4689ec65e52987e6889e008b06e4ce8ec7f3e656`

```svelte
<script lang="ts" module>
	export type PriceDisplaySize = 'sm' | 'md' | 'lg';
	export type PriceDisplayFractionDigits = 0 | 2 | 'auto';
</script>

<script lang="ts">
	interface Props {
		/** Amount in major units, e.g. 12.5. Display only; never parsed back or charged. */
		amount: number;
		/** ISO 4217 currency code, e.g. 'USD', 'EUR' or 'JPY'. */
		currency: string;
		/** BCP 47 locale for Intl.NumberFormat, e.g. 'en-GB'. Explicit so server and browser agree. */
		locale: string;
		/** Billing unit after the amount, e.g. 'per seat per month'. */
		unit?: string;
		/** Qualifier before the amount, e.g. 'From'. */
		prefix?: string;
		/** Qualifier after the unit, e.g. 'excl. VAT'. */
		qualifier?: string;
		/** Label shown in place of a zero amount, e.g. 'Free'. Omitted, zero is formatted. */
		freeLabel?: string;
		/** 'auto' drops .00 from whole amounts; 2 always shows the currency's minor units; 0 rounds. */
		fractionDigits?: PriceDisplayFractionDigits;
		/** Visual scale of the amount. */
		size?: PriceDisplaySize;
		/** Extra classes on the root, for margins or alignment. */
		class?: string;
	}

	let {
		amount,
		currency,
		locale,
		unit,
		prefix,
		qualifier,
		freeLabel,
		fractionDigits = 'auto',
		size = 'md',
		class: className
	}: Props = $props();

	/*
	 * One entry per size, as complete class strings. From md up the currency symbol is set at
	 * half size and raised so its top meets the digits' cap height (0.72 of its own em), which
	 * keeps the number as the one thing the eye lands on. At sm the price sits in a line of text,
	 * so the symbol stays at full size on the baseline.
	 */
	const SIZES: Record<
		PriceDisplaySize,
		{ root: string; amount: string; symbol: string; note: string }
	> = {
		sm: {
			root: 'gap-x-1',
			amount: 'text-lg/7 tracking-[-0.011em]',
			symbol: '',
			note: 'text-sm/5'
		},
		md: {
			root: 'gap-x-2 gap-y-1',
			amount: 'text-4xl/none tracking-[-0.02em]',
			symbol: 'text-[0.5em] align-[0.72em]',
			note: 'text-sm/5'
		},
		lg: {
			root: 'gap-x-2 gap-y-2',
			amount: 'text-5xl/none tracking-[-0.03em] sm:text-6xl/none',
			symbol: 'text-[0.5em] align-[0.72em]',
			note: 'text-base/6'
		}
	};

	type Part = { type: string; value: string };

	/** Locale used when the one passed is not supported, so the host's default never leaks in. */
	const FALLBACK_LOCALE = 'en';

	/*
	 * 'auto' decides from the amount rounded to the currency's minor units, rather than relying
	 * on trailingZeroDisplay, so every engine drops .00 the same way (JPY has none to drop).
	 */
	const digitOptions = (
		tag: string,
		digits: PriceDisplayFractionDigits
	): Intl.NumberFormatOptions => {
		if (digits === 0) return { minimumFractionDigits: 0, maximumFractionDigits: 0 };
		if (digits === 2) return {};
		const minor =
			new Intl.NumberFormat(tag, { style: 'currency', currency }).resolvedOptions()
				.maximumFractionDigits ?? 2;
		const whole = Number.isInteger(Math.round(amount * 10 ** minor) / 10 ** minor);
		return whole ? { minimumFractionDigits: 0, maximumFractionDigits: 0 } : {};
	};

	/*
	 * ICU versions disagree about which space goes where (U+0020, U+00A0, U+2009, U+202F), the
	 * commonest reason a server and a browser print different bytes for one price. Every space in
	 * a literal is pinned to a no-break space and every space group separator to a narrow one.
	 */
	const NBSP = String.fromCharCode(0x00a0);
	const NARROW_NBSP = String.fromCharCode(0x202f);
	const SPACES = new RegExp(`[ ${NBSP}${String.fromCharCode(0x2009)}${NARROW_NBSP}]`, 'g');
	const normalise = (part: Part): Part => {
		if (part.type === 'group') return { ...part, value: part.value.replace(SPACES, NARROW_NBSP) };
		if (part.type === 'literal') return { ...part, value: part.value.replace(SPACES, NBSP) };
		return part;
	};

	/** The locale actually used: the one passed when the engine supports it, otherwise 'en'. */
	const resolvedLocale = $derived.by(() => {
		try {
			return Intl.NumberFormat.supportedLocalesOf(locale).length ? locale : FALLBACK_LOCALE;
		} catch {
			return FALLBACK_LOCALE;
		}
	});

	const format = (display: 'symbol' | 'name'): Part[] => {
		// A missing amount or a failed calculation shows a dash, never "$NaN" or "$∞".
		if (!Number.isFinite(amount)) return [{ type: 'literal', value: String.fromCharCode(0x2014) }];
		try {
			const tag = resolvedLocale;
			return new Intl.NumberFormat(tag, {
				style: 'currency',
				currency,
				currencyDisplay: display,
				...digitOptions(tag, fractionDigits)
			})
				.formatToParts(amount)
				.map(normalise);
		} catch {
			// A malformed locale or currency code: show the plain number and the code, never throw.
			return [
				{ type: 'integer', value: String(amount) },
				{ type: 'literal', value: NBSP },
				{ type: 'currency', value: currency }
			];
		}
	};

	const label = $derived(freeLabel?.trim());
	const free = $derived(amount === 0 && Boolean(label));
	const parts = $derived(free ? [] : format('symbol'));
	const spoken = $derived(
		free
			? label
			: format('name')
					.map((part) => part.value)
					.join('')
	);
	/** The whole price as one phrase for screen readers, e.g. "From 12 US dollars per month excl. VAT". */
	const phrase = $derived([prefix, spoken, unit, qualifier].filter(Boolean).join(' '));

	const step = $derived(SIZES[size] ?? SIZES.md);
	const raised = $derived(size !== 'sm');
	/** A space touching the symbol is set at the symbol's size, so a raised € keeps a tight gap. */
	const besideSymbol = (index: number) =>
		parts[index - 1]?.type === 'currency' || parts[index + 1]?.type === 'currency';
</script>

<span
	class={[
		'price-display inline-flex max-w-full flex-wrap items-baseline text-[var(--_ink)]',
		step.root,
		className
	]}
	data-size={size}
>
	<span class="sr-only">{phrase}</span>
	{#if prefix}
		<span class={['price-display__note min-w-0 text-[var(--_muted)]', step.note]} aria-hidden="true"
			>{prefix}</span
		>
	{/if}
	<span
		class={[
			'price-display__amount font-semibold whitespace-nowrap text-[var(--_accent)] tabular-nums [unicode-bidi:isolate]',
			step.amount
		]}
		lang={free ? undefined : resolvedLocale}
		aria-hidden="true"
		>{#if free}{label}{:else}{#each parts as part, index (index)}{#if part.type === 'currency' && raised}<span
						class={['price-display__symbol', step.symbol]}>{part.value}</span
					>{:else if part.type === 'literal' && raised && besideSymbol(index)}<span
						class="text-[0.5em]">{part.value}</span
					>{:else}{part.value}{/if}{/each}{/if}</span
	>
	{#if unit}
		<span
			class={[
				'price-display__note min-w-0 text-pretty break-words text-[var(--_muted)]',
				step.note
			]}
			aria-hidden="true">{unit}</span
		>
	{/if}
	{#if qualifier}
		<span
			class={[
				'price-display__note min-w-0 text-pretty break-words text-[var(--_muted)]',
				step.note
			]}
			aria-hidden="true">{qualifier}</span
		>
	{/if}
</span>

<style>
	/*
	 * Public tokens: set them on the component or any ancestor. Ink is the price's text colour
	 * and defaults to the surrounding one; the amount takes the accent, else the ink, and the
	 * notes a 72% mix of the ink. So the price sits in a white card, on a dark band or on a
	 * tinted page without a change.
	 */
	.price-display {
		--_ink: var(--price-display-ink, currentColor);
		--_accent: var(--price-display-accent, currentColor);
		--_muted: var(--price-display-muted, color-mix(in srgb, currentColor 72%, transparent));
	}

	/* Arabic, Hebrew and other right-to-left scripts are never letter-spaced. */
	.price-display__amount:dir(rtl) {
		letter-spacing: 0;
	}

	/*
	 * Arabic currency abbreviations such as ر.س. sit low in their em box, so the cap-height raise
	 * leaves them hanging below the digits' tops; they take a higher raise to share that line.
	 */
	.price-display__symbol:lang(ar) {
		vertical-align: 0.95em;
	}

	/* CJK notes break at phrases rather than mid-word where the browser supports it. */
	.price-display__note:lang(ko) {
		word-break: keep-all;
	}

	@supports (word-break: auto-phrase) {
		.price-display__note:lang(ja) {
			word-break: auto-phrase;
		}
	}
</style>
```

## Artifacts

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

- Artifact digest: `sha256-563c511d3d928dd20a7cf4cc7c8824a02ddf1095e16ffd4060ad6bee6ba916cb`
- Entry: `PriceDisplay.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_price_display_01/1.0.0/neutral/sha256-563c511d3d928dd20a7cf4cc7c8824a02ddf1095e16ffd4060ad6bee6ba916cb/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_price_display_01/1.0.0/neutral/sha256-563c511d3d928dd20a7cf4cc7c8824a02ddf1095e16ffd4060ad6bee6ba916cb/bundle.zip (6444 bytes, sha256 `e20ca066e72a3c4e14cd0da0438aecae1db922fc44f88141f5d9461a3a2c676c`)

Files:

- `PriceDisplay.svelte` (entry, 8109 bytes): https://pagesugar.com/artifacts/cmp_price_display_01/1.0.0/neutral/sha256-563c511d3d928dd20a7cf4cc7c8824a02ddf1095e16ffd4060ad6bee6ba916cb/source/PriceDisplay.svelte
