Skip to content
Download ZIP

Accent amount palette · 6.3 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_price_display_01 · version 1.0.0 · Accent amount palette
Using the PageSugar MCP server, fetch component cmp_price_display_01 version 1.0.0 with variant "accent", 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
Accent amount
Version
1.0.0
Digest
Full digest
sha256-3dae7fa0db92b2ed61a08da6704c61ee1e731fa6bdcc424992be44f348759f18
PriceDisplay.svelte Svelte · 7.9 KB Raw
<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, #2563eb);
		--_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>

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.

Suggested location
src/lib/components/price-display-01
Required props
amountcurrencylocale

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.

Example

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"
/>

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

On this page
NameTypeRequiredDefaultDescription
amountnumberYesNoneAmount in major units, e.g. 12.5. Display only; never parsed back or charged.
currencystringYesNoneISO 4217 code such as 'USD', 'EUR' or 'JPY'. A malformed code falls back to the plain number followed by the code.
localestringYesNoneBCP 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'.
unitstringNoNoneBilling unit after the amount, e.g. 'per month' or 'per seat per month'.
prefixstringNoNoneQualifier before the amount, e.g. 'From' or 'Starting at'.
qualifierstringNoNoneQualifier after the unit, e.g. 'excl. VAT' or 'billed annually'.
freeLabelstringNoNoneShown in place of the amount when it is zero, e.g. 'Free'. Omitted or blank, zero is formatted like any other amount.
fractionDigits0 | 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.
classstringNoNoneExtra classes on the root span, for margins or alignment.

Customization#

On this page

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.

Public CSS variables

VariableToken
--price-display-accentaccent
--price-display-inkink
--price-display-mutedmuted

Accessibility#

On this page
  • 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.

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 · 30 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.