Skip to content
Palette Inherited colour
Download ZIP

Inherited colour palette · 8.5 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_metric_display_01 · version 1.0.0 · Inherited colour palette
Using the PageSugar MCP server, fetch component cmp_metric_display_01 version 1.0.0 with variant "neutral", 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-ea66d7b2d1eaf68936394c7220dc667d2f2f5be8cfbc0f3adb2c5d6d69cd090d
MetricDisplay.svelte Svelte · 13.3 KB Raw
<script lang="ts" module>
	export type MetricDisplaySize = 'md' | 'lg';
	export type MetricDisplayDirection = 'up' | 'down' | 'none';

	export interface MetricDisplayChange {
		/**
		 * The change since the comparison period. With the default percent format this is a
		 * fraction: 0.12 is "12%" and -0.3 is "30%". The sign sets the direction.
		 */
		value: number;
		/** What the change is measured against, e.g. 'vs last month'. Shown after the change. */
		period: string;
		/** Which direction is good news. Omitted, the change is shown in the muted tone. */
		goodWhen?: 'up' | 'down';
		/** Format for the change's magnitude. Defaults to a percent with up to one decimal. */
		format?: Intl.NumberFormatOptions;
	}

	/** The words around the numbers, so another language can replace them. */
	export interface MetricDisplayText {
		/** Change upwards; {value} is the formatted magnitude. */
		up: string;
		/** Change downwards; {value} is the formatted magnitude. */
		down: string;
		/** A change that rounds to zero. */
		none: string;
		/** Said after the period when the change is good news, so the judgement is not colour alone. */
		better: string;
		/** Said after the period when the change is bad news. */
		worse: string;
		/** Read in place of a missing value, which is drawn as a dash. */
		unavailable: string;
	}
</script>

<script lang="ts">
	interface Props {
		/** What the number measures, e.g. 'Weekly active members'. */
		label: string;
		/** The raw number. null, NaN or Infinity renders a dash and no change. */
		value: number | null;
		/** BCP 47 locale for Intl.NumberFormat, e.g. 'en-GB'. Explicit so server and browser agree. */
		locale: string;
		/** Formatting for the value: compact notation, currency, percent, fraction digits. */
		format?: Intl.NumberFormatOptions;
		/** Unit text after the value when the format does not supply one, e.g. 'days'. */
		unit?: string;
		/** The comparison: a signed change, its period and which direction is good. */
		change?: MetricDisplayChange;
		/** Footnote naming the data source or date. */
		source?: string;
		/** md for dashboards and cards; lg for stat rows on landing pages and case studies. */
		size?: MetricDisplaySize;
		/** Replaces the English direction words, the better/worse judgement and the unavailable text. */
		text?: Partial<MetricDisplayText>;
		/** Extra classes on the root, for margins or a width. */
		class?: string;
	}

	let {
		label,
		value,
		locale,
		format = {},
		unit,
		change,
		source,
		size = 'md',
		text = {},
		class: className
	}: Props = $props();

	const TEXT: MetricDisplayText = {
		up: 'Up {value}',
		down: 'Down {value}',
		none: 'No change',
		better: 'better',
		worse: 'worse',
		unavailable: 'Not available'
	};

	/*
	 * One entry per size, as complete class strings. The value leads; the label sits close under
	 * it so the pair reads as one, and the comparison stands three times as far off, so it reads
	 * as context for the pair rather than the next line of a stack.
	 */
	const SIZES: Record<
		MetricDisplaySize,
		{ value: string; unit: string; label: string; change: string; source: string }
	> = {
		md: {
			value: 'text-4xl/[1.1] tracking-[-0.02em]',
			unit: 'text-base/none',
			label: 'mt-2 text-sm/5',
			change: 'mt-6 text-sm/5',
			source: 'mt-3'
		},
		lg: {
			value: 'text-5xl/[1.1] tracking-[-0.03em] sm:text-6xl/[1.1]',
			unit: 'text-lg/none sm:text-xl/none',
			label: 'mt-3 text-base/6',
			change: 'mt-9 text-base/6',
			source: 'mt-4'
		}
	};

	/* Tone per meaning: good news, bad news, or neither. Complete class names, never built. */
	const TONES = {
		good: '[--_tone:var(--_positive)]',
		bad: '[--_tone:var(--_negative)]',
		neutral: '[--_tone:var(--_muted)]'
	} as const;

	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';

	/*
	 * 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 number. 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 toParts = (n: number, options: Intl.NumberFormatOptions): Part[] => {
		try {
			return new Intl.NumberFormat(resolvedLocale, options).formatToParts(n).map(normalise);
		} catch {
			// Options the engine rejects (an unknown currency code): show the plain number.
			return [{ type: 'integer', value: String(n) }];
		}
	};
	const join = (parts: Part[]) => parts.map((part) => part.value).join('');

	/** Digits and their punctuation are the numeral; everything else is an affix set smaller. */
	const NUMERAL = new Set(['integer', 'group', 'decimal', 'fraction', 'minusSign', 'plusSign']);

	const available = $derived(typeof value === 'number' && Number.isFinite(value));
	/* Each phrase falls back on its own, so { up: undefined } still reads 'Up'. */
	const words = $derived({
		up: text.up ?? TEXT.up,
		down: text.down ?? TEXT.down,
		none: text.none ?? TEXT.none,
		better: text.better ?? TEXT.better,
		worse: text.worse ?? TEXT.worse,
		unavailable: text.unavailable ?? TEXT.unavailable
	});

	/*
	 * The formatted value runs in its locale's own direction, so on a right-to-left page an
	 * en-US "-12,400" keeps its minus in front, and an Arabic figure keeps its order on an
	 * English page.
	 */
	const RTL_LANGUAGES = new Set([
		'ar',
		'he',
		'iw',
		'fa',
		'ur',
		'ps',
		'yi',
		'dv',
		'ckb',
		'sd',
		'ug'
	]);
	const valueDir = $derived(
		RTL_LANGUAGES.has(resolvedLocale.split('-')[0].toLowerCase()) ? 'rtl' : 'ltr'
	);

	/*
	 * The visible value is split into the numeral and its affixes. A currency symbol before the
	 * digits is raised to their cap height; a percent sign, a compact suffix (M, 万) or a symbol
	 * after them sits on the baseline. Spaces touching an affix take its size.
	 */
	const parts = $derived(available ? toParts(value as number, format) : []);
	const first = $derived(parts.findIndex((part) => NUMERAL.has(part.type)));
	/*
	 * A unit the format writes itself ("mins", "ms") is set like the unit prop: smaller, muted and
	 * untracked, with the space before it at the same size, so "6 mins" and "3.4 days" match.
	 */
	const isUnit = (index: number) =>
		parts[index]?.type === 'unit' ||
		(parts[index]?.type === 'literal' &&
			(parts[index - 1]?.type === 'unit' || parts[index + 1]?.type === 'unit'));
	const placed = $derived(
		parts.map((part, index) => ({
			...part,
			role: NUMERAL.has(part.type)
				? 'numeral'
				: isUnit(index)
					? 'unit'
					: index < first
						? 'prefix'
						: 'suffix'
		}))
	);

	/*
	 * What a screen reader hears: the whole number in standard notation, so compact "1.2M" is read
	 * as "1,240,000", followed by the unit. The rounding that suited the compact form is dropped.
	 */
	const COMPACT_ONLY = new Set([
		'notation',
		'compactDisplay',
		'minimumFractionDigits',
		'maximumFractionDigits',
		'minimumSignificantDigits',
		'maximumSignificantDigits',
		'roundingPriority',
		'roundingIncrement'
	]);
	const full = $derived.by(() => {
		if (!available) return words.unavailable;
		// A currency keeps its own minor units; anything else keeps every digit it was given.
		const options: Intl.NumberFormatOptions =
			format.notation === 'compact'
				? {
						...Object.fromEntries(Object.entries(format).filter(([key]) => !COMPACT_ONLY.has(key))),
						...(format.style === 'currency' ? {} : { maximumFractionDigits: 20 })
					}
				: format;
		return [join(toParts(value as number, options)), unit].filter(Boolean).join(' ');
	});

	/*
	 * The change's magnitude is formatted without a sign, because the word carries the direction.
	 * Its direction follows the rounded figure: a change that prints as 0% says "No change".
	 */
	const comparison = $derived.by(() => {
		if (!change || !available || !Number.isFinite(change.value)) return undefined;
		const own = change.format ?? {};
		const digits = Object.keys(own).some((key) => /Digits$|^rounding/.test(key));
		const options: Intl.NumberFormatOptions = {
			style: 'percent',
			// The one-decimal default only applies when the consumer set no precision of their own.
			...(digits ? {} : { maximumFractionDigits: 1 }),
			...own,
			signDisplay: 'never'
		};
		const magnitude = join(toParts(Math.abs(change.value), options));
		const zero = magnitude === join(toParts(0, options));
		const direction: MetricDisplayDirection = zero ? 'none' : change.value > 0 ? 'up' : 'down';
		const tone =
			direction === 'none' || !change.goodWhen
				? 'neutral'
				: direction === change.goodWhen
					? 'good'
					: 'bad';
		return {
			direction,
			tone: tone as keyof typeof TONES,
			phrase: words[direction].replace('{value}', magnitude),
			period: change.period,
			// The tone's meaning in words: green or red alone would leave it to colour.
			judgement: tone === 'good' ? words.better : tone === 'bad' ? words.worse : ''
		};
	});

	const step = $derived(SIZES[size] ?? SIZES.md);
</script>

<dl class={['metric-display flex min-w-0 flex-col text-(--_ink)', className]} data-size={size}>
	<dt
		class={[
			'metric-display__label min-w-0 font-medium text-pretty break-words text-(--_muted)',
			step.label
		]}
	>
		{label}
	</dt>
	<dd class="order-first flex min-w-0 flex-wrap items-baseline gap-x-2">
		<span class="sr-only">{full}</span>
		<span
			class={[
				'metric-display__value font-semibold whitespace-nowrap tabular-nums [unicode-bidi:isolate] rtl:tracking-normal',
				available ? 'text-(--_accent)' : 'text-(--_muted)',
				step.value
			]}
			lang={resolvedLocale}
			dir={valueDir}
			aria-hidden="true"
			>{#if available}{#each placed as part, index (index)}{#if part.role === 'prefix'}<span
							class="align-[0.72em] text-[0.5em]">{part.value}</span
						>{:else if part.role === 'unit'}<span
							class={['font-medium tracking-normal text-(--_muted)', step.unit]}>{part.value}</span
						>{:else if part.role === 'suffix'}<span class="text-[0.5em]">{part.value}</span
						>{:else}{part.value}{/if}{/each}{:else}<span class="font-normal">&mdash;</span
				>{/if}</span
		>
		{#if unit && available}
			<span
				class={['min-w-0 font-medium break-words text-(--_muted)', step.unit]}
				aria-hidden="true">{unit}</span
			>
		{/if}
	</dd>
	{#if comparison}
		<dd
			class={['flex min-w-0 items-start gap-2', TONES[comparison.tone], step.change]}
			data-direction={comparison.direction}
			data-tone={comparison.tone}
		>
			<span class="flex h-lh shrink-0 items-center text-(--_tone)" aria-hidden="true">
				<svg
					class="size-4"
					viewBox="0 0 16 16"
					fill="none"
					stroke="currentColor"
					stroke-width="1.75"
					stroke-linecap="round"
					stroke-linejoin="round"
				>
					{#if comparison.direction === 'up'}
						<path d="M8 13V3M4 7l4-4 4 4" />
					{:else if comparison.direction === 'down'}
						<path d="M8 3v10M4 9l4 4 4-4" />
					{:else}
						<path d="M3.5 8h9" />
					{/if}
				</svg>
			</span>
			<span class="min-w-0 text-pretty break-words">
				<span class="font-medium whitespace-nowrap text-(--_tone) tabular-nums"
					>{comparison.phrase}</span
				>
				<!-- The judgement is held to the period's last word, so it never wraps onto a line alone. -->
				<span class="text-(--_muted)">{comparison.period}</span>{#if comparison.judgement}<span
						class="whitespace-nowrap text-(--_tone)"
						>&nbsp;<span aria-hidden="true">&middot;</span> {comparison.judgement}</span
					>{/if}
			</span>
		</dd>
	{/if}
	{#if source}
		<dd
			class={[
				'metric-display__source min-w-0 text-xs/4 text-pretty break-words text-(--_muted)',
				step.source
			]}
		>
			{source}
		</dd>
	{/if}
</dl>

<style>
	/*
	 * Public tokens: set them on the component or any ancestor. Ink defaults to the surrounding
	 * text colour and is set on the root, so currentColor below is the ink: the value takes the
	 * accent, else the ink, and the muted tone is a 72% mix of the ink. The metric then sits on a
	 * white card, a cream page or a dark band unchanged. Positive
	 * and negative colour the change by whether it is good news; both clear 4.5:1 on white.
	 */
	.metric-display {
		--_ink: var(--metric-display-ink, currentColor);
		--_accent: var(--metric-display-accent, currentColor);
		--_muted: var(--metric-display-muted, color-mix(in srgb, currentColor 72%, transparent));
		--_positive: var(--metric-display-positive, #047857);
		--_negative: var(--metric-display-negative, #b91c1c);
	}

	/* Japanese labels and footnotes break at phrases, Korean at spaces, never mid-word. */
	.metric-display__label:lang(ko),
	.metric-display__source:lang(ko) {
		word-break: keep-all;
	}

	@supports (word-break: auto-phrase) {
		.metric-display__label:lang(ja),
		.metric-display__source:lang(ja) {
			word-break: auto-phrase;
		}
	}
</style>

Pass a label, the raw value and a locale; add a format, unit, change and source as needed. It renders one description list: label, formatted value, an optional comparison and footnote. It does not fetch or calculate metrics, animate or count up, update live, draw a chart, or check that the figure is true.

Suggested location
src/lib/components/metric-display-01
Required props
labelvaluelocale

Limitations

  • The change's value is a fraction under the default percent format (0.12 prints 12%). For an absolute change, such as 40 ms or 4 points, pass change.format; the component does not calculate a change from two values.
  • Direction words are English by default. Pass the text prop with up, down, none and unavailable in the page's language; {value} marks where the magnitude goes.
  • A locale the JavaScript engine does not support falls back to 'en' on both server and browser, rather than to the host's default.
  • The positive and negative defaults clear 4.5:1 on white and light pages only. On a dark or tinted page set --metric-display-positive and --metric-display-negative to tones that clear it there.
  • The value never wraps. A very long number at lg in a column narrower than about 280 px will overflow; use md or compact notation there.
  • Formatting depends on the JavaScript engine's locale data. A server whose Node build lacks a locale falls back to 'en' and the browser may then print the figure differently on hydration; full-ICU Node (the default since Node 13) avoids it.
  • Light mode is what is tested. Because ink and muted follow the surrounding text colour, a dark band works with the two tone variables set, but no dark mode is declared.

Example

Svelte
<script lang="ts">
	import MetricDisplay from '$lib/components/metric-display-01/MetricDisplay.svelte';
</script>

<MetricDisplay
	label="Weekly active members"
	value={12400}
	locale="en-US"
	size="lg"
	change={{ value: 0.082, period: 'vs Q2', goodWhen: 'up' }}
	source="Signed in at least once a week"
/>

<MetricDisplay
	label="Median time from card to Done"
	value={3.4}
	unit="days"
	locale="en-US"
	change={{ value: -0.12, period: 'vs Q2', goodWhen: 'down' }}
/>

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.

Metric display#

One headline number with its label, an optional unit, a comparison and a footnote. Use it alone on a card, or several in a row for a stat band, a dashboard header or a case study.

Values and changes#

  • value is the raw number. Formatting is Intl.NumberFormat(locale, format), with the locale passed explicitly so the server render and the browser agree.
  • change.value is a signed fraction under the default percent format: 0.12 prints 12%, -0.3 prints 30% with the word Down. For an absolute change pass change.format, for example { style: 'unit', unit: 'millisecond', unitDisplay: 'short' } with value: 40.
  • The direction follows the rounded figure. A change that prints as 0% says No change in the muted tone, whatever goodWhen says.
  • goodWhen: 'down' is for figures where less is better: wait times, error rates, cost, churn. With goodWhen set, the change ends with better or worse, so the judgement is in words and not only in the green or red tone.

In a row#

Svelte
<ul class="grid border-t border-black/10 sm:grid-cols-3">
	{#each metrics as metric, i}
		<li
			class={[
				'py-8 sm:px-8 sm:first:ps-0',
				i > 0 && 'border-t border-black/10 sm:border-s sm:border-t-0'
			]}
		>
			<MetricDisplay {...metric} locale="en-US" size="lg" />
		</li>
	{/each}
</ul>

Keep every metric in a row at the same size, and the same period wording, so they compare.

Other languages#

Pass text with the six phrases. {value} marks where the magnitude goes, so word order can change: { up: '{value}増', down: '{value}減', none: '変化なし', better: '改善', worse: '悪化', unavailable: 'データなし' }.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneWhat the number measures, e.g. 'Weekly active members'. Read before the value.
valuenumber | nullYesNoneThe raw number. null, NaN or Infinity draws a dash, reads the unavailable text and hides the change.
localestringYesNoneBCP 47 locale for Intl.NumberFormat, e.g. 'en-GB' or 'de-DE'. Explicit so server and browser agree.
formatIntl.NumberFormatOptionsNo{}Formatting for the value: compact notation, currency, percent, unit style or fraction digits.
unitstringNoNoneUnit text after the value when the format does not supply one, e.g. 'days'. Set smaller and muted.
changeMetricDisplayChangeNoNone{ value, period, goodWhen?, format? }. value is the signed change (a fraction by default: 0.12 is 12%); period follows it, e.g. 'vs last month'; goodWhen 'up' or 'down' picks the positive or negative tone and adds 'better' or 'worse' after the period, omitted it stays muted with no judgement; format overrides the percent default.
sourcestringNoNoneFootnote naming the data source or date, set small and muted under the comparison.
size'md' | 'lg'No'md'md sets the value at 36 px for dashboards and cards; lg at 48 to 60 px for stat rows on landing pages.
textPartial<MetricDisplayText>NoNoneReplaces the English words: up ('Up {value}'), down ('Down {value}'), none ('No change'), better and worse (the judgement after the period when goodWhen is set) and unavailable ('Not available').
classstringNoNoneExtra classes on the root, for margins or a width.

Customization#

On this page

Set format, unit, size and text through props. Retone with five --metric-display-* variables: ink and muted follow the surrounding text colour by default, so most pages need none of them.

  • Content: label says what is measured, change.period says against what ('vs September', 'vs the same week last year'). Keep the footnote to the source and date.
  • Numbers: pass Intl options in format, e.g. { notation: 'compact', maximumFractionDigits: 1 }, { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } or { style: 'unit', unit: 'minute', unitDisplay: 'short' }. The currency symbol is raised, and suffixes such as %, M or min are set at half size.
  • Good news: set goodWhen to 'down' for wait times, error rates, costs and churn, so a fall takes the positive tone. Leave it out for a figure that is neither good nor bad. The change then ends with 'better' or 'worse', so the judgement is in words as well as tone.
  • Colours: --metric-display-positive and --metric-display-negative colour the change; --metric-display-accent colours the value (defaults to the ink); --metric-display-ink and --metric-display-muted set the text tones.
  • Dark or tinted page: set the band's text colour and the component follows it. Then set --metric-display-positive to a light green such as #6ee7b7 and --metric-display-negative to #fca5a5, which clear 4.5:1 on zinc-950.
  • Stat row: place several metrics in a grid or flex row with a hairline between them (border-s on all but the first) and the same size on each.
  • Other languages: pass the locale and the text prop, e.g. { up: 'Plus {value}', down: 'Minus {value}', none: 'Unverändert', better: 'besser', worse: 'schlechter', unavailable: 'Nicht verfügbar' }.

Public CSS variables

VariableToken
--metric-display-accentaccent
--metric-display-inkink
--metric-display-mutedmuted
--metric-display-positivepositive
--metric-display-negativenegative

Accessibility#

On this page
  • Renders a description list: the label is the dt and the value the first dd, so a screen reader reads the label first ('Weekly active members, 12,400') even though the value is drawn above it.
  • The visible value is hidden from assistive technology and replaced by one visually hidden phrase in standard notation, so compact '1.2M' is read as '1,240,000' and the unit follows it.
  • The change states its direction in words ('Up 8.2% vs Q2') and, when goodWhen is set, its judgement too ('· better'). The arrow and middle dot are aria-hidden and the tone repeats what the words say, so nothing relies on colour alone.
  • The default positive and negative tones and the muted text clear 4.5:1 on white and zinc-50. Check your own tones when you set them.
  • Not interactive and not a live region. If the figure updates while the page is open, announcing it is the consumer's job.
  • Pass the page's language through text when it is not English, so the direction words match the lang of the page.

Known limitations

  • The better and worse words are English by default; pass them through text with the other phrases when the page is in another language.

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.