Metric display
One headline number with its label, an optional unit, a signed comparison such as Up 12% vs last month, and a source footnote. Formats with an explicit locale; says direction in words, tones by whether it is good news.
cmp_metric_display_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_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
<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">—</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)"
> <span aria-hidden="true">·</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>
Usage#
On this pagePass 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
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-positiveand--metric-display-negativeto 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
<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#
valueis the raw number. Formatting isIntl.NumberFormat(locale, format), with the locale passed explicitly so the server render and the browser agree.change.valueis a signed fraction under the default percent format:0.12prints12%,-0.3prints30%with the word Down. For an absolute change passchange.format, for example{ style: 'unit', unit: 'millisecond', unitDisplay: 'short' }withvalue: 40.- The direction follows the rounded figure. A change that prints as
0%says No change in the muted tone, whatevergoodWhensays. goodWhen: 'down'is for figures where less is better: wait times, error rates, cost, churn. WithgoodWhenset, the change ends withbetterorworse, so the judgement is in words and not only in the green or red tone.
In a row#
<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| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | What the number measures, e.g. 'Weekly active members'. Read before the value. |
value | number | null | Yes | None | The raw number. null, NaN or Infinity draws a dash, reads the unavailable text and hides the change. |
locale | string | Yes | None | BCP 47 locale for Intl.NumberFormat, e.g. 'en-GB' or 'de-DE'. Explicit so server and browser agree. |
format | Intl.NumberFormatOptions | No | {} | Formatting for the value: compact notation, currency, percent, unit style or fraction digits. |
unit | string | No | None | Unit text after the value when the format does not supply one, e.g. 'days'. Set smaller and muted. |
change | MetricDisplayChange | No | None | { 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. |
source | string | No | None | Footnote 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. |
text | Partial<MetricDisplayText> | No | None | Replaces 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'). |
class | string | No | None | Extra classes on the root, for margins or a width. |
Customization#
On this pageSet 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.periodsays 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
goodWhento '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-positiveand--metric-display-negativecolour the change;--metric-display-accentcolours the value (defaults to the ink);--metric-display-inkand--metric-display-mutedset the text tones. - Dark or tinted page: set the band's text colour and the component follows it. Then set
--metric-display-positiveto a light green such as#6ee7b7and--metric-display-negativeto#fca5a5, which clear 4.5:1 onzinc-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
| Variable | Token |
|---|---|
--metric-display-accent | accent |
--metric-display-ink | ink |
--metric-display-muted | muted |
--metric-display-positive | positive |
--metric-display-negative | negative |
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
goodWhenis set, its judgement too ('· better'). The arrow and middle dot arearia-hiddenand 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.