# Bounded meter

> A measurement within a known range, such as storage used or a password score: label, value text and a bar on role="meter", toned by the HTML meter regions, with the track cut at its thresholds.

- ID: `cmp_bounded_meter_01`
- Slug: `bounded-meter-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T23:13:54Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `typography`
- Detail page: https://pagesugar.com/components/bounded-meter-01
- Preview: https://pagesugar.com/preview/bounded-meter-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-cc0e00d818e4a4294c95cd424854ac97b852699f4c603e4b7c91efac81bcd730` |
| `green` | Green good region | no | `sha256-be9e32614c6adbb40e357013acf96221df9a53e2902a971a72ce42f299316509` |
| `blue` | Blue good region | no | `sha256-a1dc18b0cc9f2355c0cc0e0eea528e89424a83b92e39a0ee6182d445dbbfd63e` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/bounded-meter-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass a label, value and max, plus low, high and optimum to say which region is good; give valueText with units and regionLabels for the words. It renders one static measurement. It is not a progress bar for loading or uploads, does not compute or fetch usage, and does not animate on load.

Required props: `label`, `value`, `max`

```svelte
<script lang="ts">
	import Meter from '$lib/components/bounded-meter-01/Meter.svelte';
</script>

<Meter
	label="File storage"
	value={7.2}
	max={10}
	low={8}
	high={9.5}
	optimum={0}
	valueText="7.2 GB of 10 GB"
	regionLabels={{ suboptimal: 'Nearly full', critical: 'Full' }}
	description="Attachments and exports across every board."
	markers
/>
```

Limitations:

- Without regionLabels, a warning or critical region shows its colour, a shape mark and the value text, but no word, and screen readers hear only the value. Give a word for every region your thresholds can reach.
- Without valueText, the visible text and aria-valuetext are a rounded percentage. Pass valueText with units in the reader's language; the component does not format numbers.
- The value clamps to min and max for the bar and aria-valuenow, as the HTML meter does. Say an over-limit figure in valueText; the bar cannot show it.
- Threshold cuts are a 2 px CSS mask, so they show whatever is behind the meter. Direction comes from Tailwind's rtl: variant (a dir="rtl" ancestor).
- Light pages by default. On a dark page set --bounded-meter-good, -ink, -muted and -track to light values.

## Usage guide

### Bounded meter

A meter shows where one measurement sits inside a known range: storage against a plan limit, a
password score, a pool temperature. It is not a progress bar. Nothing is being completed, so
use a progress component for uploads and loading.

#### Choosing low, high and optimum

The three numbers follow the HTML `<meter>` element, and the tone follows its algorithm:

| What you are measuring            | Set                    | Regions                                              |
| --------------------------------- | ---------------------- | ---------------------------------------------------- |
| A quota, where less is better     | `optimum` below `low`  | good up to `low`, warning to `high`, critical above  |
| A score, where more is better     | `optimum` above `high` | good from `high`, warning from `low`, critical below |
| A reading that should stay inside | `optimum` between them | good between `low` and `high`, warning at both ends  |

Values on a boundary count as the better region, as browsers do. With no `low` or `high`, the
whole range is one region and the meter stays in the good tone.

#### Say it in words

Colour alone is not enough. Pass `valueText` with units in the reader's language, and a
`regionLabels` word for every region your thresholds can reach:

```svelte
<Meter
	label="Automation runs"
	value={4410}
	max={5000}
	low={4000}
	high={4750}
	optimum={0}
	valueText="4,410 of 5,000"
	regionLabels={{ suboptimal: 'Near limit', critical: 'Almost out' }}
	description="Resets on 1 November."
	markers
/>
```

If the value can exceed `max`, keep the true figure in `valueText` ("11.4 GB of 10 GB"); the bar
and `aria-valuenow` stop at `max`.

#### Lists of meters

On a usage page, put each meter in a row of one bordered panel with hairline dividers between
rows. Use `size="thin"` and leave `markers` off when there are more than four or five.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | What is measured. Shown above the bar and used as the meter's accessible name. |
| `value` | `number` | yes |  | The current value. Clamped to min and max for the bar and aria-valuenow. |
| `min` | `number` | no | `0` | Lower bound of the range. |
| `max` | `number` | yes |  | Upper bound of the range. A max below min is raised to min. |
| `low` | `number` | no | `min` | Upper edge of the low region. Defaults to min; clamped into the range. |
| `high` | `number` | no | `max` | Lower edge of the high region. Defaults to max; clamped between low and max. |
| `optimum` | `number` | no | `midpoint of min and max` | The preferred value. Below low means low is good (a quota), above high means high is good (a strength score), between them means both ends warn. |
| `valueText` | `string` | no |  | Visible value with units, such as "7.2 GB of 10 GB". Also the aria-valuetext. Omitted, a rounded percentage is shown. |
| `regionLabels` | Partial\<Record\<'optimum' \| 'suboptimal' \| 'critical', string\>\> | no |  | A word for each region, shown after the value with a shape mark and appended to aria-valuetext. Regions without a word show none. |
| `description` | `string` | no |  | One short line under the bar, such as when the quota resets. Linked with aria-describedby. |
| `markers` | `boolean` | no | `false` | Cut the track at low and high so the regions show; a threshold ahead of the fill also gets a tick in the muted tone. Thresholds at min or max are not cut. |
| `size` | 'thin' \| 'regular' | no | `'regular'` | Bar height: thin is 4 px for dense lists, regular is 8 px. |
| `placement` | 'inline' \| 'below' | no | `'inline'` | Value text at the end of the label's line, or under the bar. |
| `class` | `string` | no |  | Extra classes on the root, for width or margins. |

## Customization

Retone through six --bounded-meter-\* CSS variables: one per region tone, the track, and two text tones. Thresholds, size and placement are props.

- Brand colour for the good region: set --bounded-meter-good (near-black by default, so a healthy quota reads as plain ink). A green suits scores where high is good; keep it at 3:1 against the track and 4.5:1 as text.
- Warning and critical: --bounded-meter-warn (amber-700) and --bounded-meter-bad (red-700) colour the fill, the mark and the region word.
- Track: --bounded-meter-track is an 8% black tint, so it works on white, zinc-50 or a cream page.
- Text: --bounded-meter-ink is the label, --bounded-meter-muted the value text and description.
- Dark or tinted page: set --bounded-meter-good to #fafafa, --bounded-meter-warn to #fbbf24, --bounded-meter-bad to #f87171, --bounded-meter-track to rgb(255 255 255 / 0.12), --bounded-meter-ink to #fafafa and --bounded-meter-muted to #a1a1aa.
- Thresholds: a quota sets optimum below low (optimum={0}); a strength score sets it above high; a reading that should stay in a band puts it between low and high.
- Dense lists: size="thin" and no markers; one meter on its own: regular with markers and a description.
- Width: the meter fills its parent. Pass class="max-w-sm" or put it in a grid cell to bound it.
- Threshold ticks use --bounded-meter-muted, so they follow the value text's tone.

| Token | Public CSS variable |
| --- | --- |
| `good` | `--bounded-meter-good` |
| `warn` | `--bounded-meter-warn` |
| `bad` | `--bounded-meter-bad` |
| `track` | `--bounded-meter-track` |
| `ink` | `--bounded-meter-ink` |
| `muted` | `--bounded-meter-muted` |

## Accessibility

- The bar is a div with role="meter" (WAI-ARIA 1.2), named by the visible label through aria-labelledby, with aria-valuemin, aria-valuemax, aria-valuenow and aria-valuetext. It is not a progressbar and is not focusable.
- aria-valuetext is valueText plus the current region word, so units and state are announced. Write valueText in words a screen reader reads well ("GB" is fine; avoid symbols only).
- The region is shown by colour and a shape mark (a triangle for warning, a filled circle for critical, a check for good when it has a word), plus a word from regionLabels. Give words for every reachable region; the marks are hidden from assistive technology.
- The description line is linked with aria-describedby.
- Default tones clear 3:1 against the track and 4.5:1 as text on white. Re-check any colour you set.
- The fill's width transition is turned off under prefers-reduced-motion, and nothing animates on first render.

Known limitations:

- Support for role="meter" varies by screen reader; older ones announce it as a generic element or a progress bar. The visible value text keeps the meaning available either way.

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `Meter.svelte`
- Suggested directory: `src/lib/components/bounded-meter-01`
- Files: 1
- Artifact digest: `sha256-cc0e00d818e4a4294c95cd424854ac97b852699f4c603e4b7c91efac81bcd730`

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

#### `Meter.svelte`

Role: entry · 10125 bytes · SHA-256 `1cf020a10e8313a6045fc280f0d0e6f8f0d8f11dc6b80fe00d6c2c124d59d361`

```svelte
<script lang="ts" module>
	/** The three regions of the HTML meter algorithm; `critical` is the spec's "even less good". */
	export type MeterRegion = 'optimum' | 'suboptimal' | 'critical';
	export type MeterSize = 'thin' | 'regular';
	export type MeterPlacement = 'inline' | 'below';

	export interface MeterRange {
		value: number;
		min: number;
		max: number;
		low: number;
		high: number;
		optimum: number;
	}

	const finite = (n: number | undefined, fallback: number) =>
		typeof n === 'number' && Number.isFinite(n) ? n : fallback;
	const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);

	/**
	 * Normalise the attributes the way the HTML meter element does: max never below min, value
	 * clamped into the range, low within it, high between low and max, optimum within it and
	 * defaulting to the midpoint.
	 */
	export function resolveRange(input: {
		value: number;
		min?: number;
		max: number;
		low?: number;
		high?: number;
		optimum?: number;
	}): MeterRange {
		const min = finite(input.min, 0);
		const max = Math.max(min, finite(input.max, 1));
		const value = clamp(finite(input.value, 0), min, max);
		const low = clamp(finite(input.low, min), min, max);
		const high = clamp(finite(input.high, max), low, max);
		const optimum = clamp(finite(input.optimum, min / 2 + max / 2), min, max);
		return { value, min, max, low, high, optimum };
	}

	/** The gauge region of the HTML meter algorithm, with the boundaries browsers use. */
	export function meterRegion({ value, low, high, optimum }: MeterRange): MeterRegion {
		if (optimum < low) {
			if (value <= low) return 'optimum';
			return value <= high ? 'suboptimal' : 'critical';
		}
		if (optimum > high) {
			if (value >= high) return 'optimum';
			return value >= low ? 'suboptimal' : 'critical';
		}
		return value >= low && value <= high ? 'optimum' : 'suboptimal';
	}
</script>

<script lang="ts">
	interface Props {
		/** What is measured, shown above the bar and used as the meter's accessible name. */
		label: string;
		/** The current value. Values outside min and max are clamped for the bar and for aria. */
		value: number;
		/** Lower bound of the range. */
		min?: number;
		/** Upper bound of the range. */
		max: number;
		/** Upper edge of the low region. Defaults to min. */
		low?: number;
		/** Lower edge of the high region. Defaults to max. */
		high?: number;
		/** The preferred value: which region it falls in decides which region is good. */
		optimum?: number;
		/** Visible value with units, such as "7.2 GB of 10 GB"; also the aria-valuetext. */
		valueText?: string;
		/** A word for each region, such as "Nearly full", shown beside the value with a mark. */
		regionLabels?: Partial<Record<MeterRegion, string>>;
		/** One short line under the bar, such as when the quota resets. */
		description?: string;
		/** Cut the track at low and high so the regions show. */
		markers?: boolean;
		/** Bar height: thin is 4 px for dense lists, regular is 8 px. */
		size?: MeterSize;
		/** Value text on the label's line, or under the bar. */
		placement?: MeterPlacement;
		/** Extra classes on the root, for width or margins. */
		class?: string;
	}

	let {
		label,
		value,
		min = 0,
		max,
		low,
		high,
		optimum,
		valueText,
		regionLabels,
		description,
		markers = false,
		size = 'regular',
		placement = 'inline',
		class: className
	}: Props = $props();

	const uid = $props.id();

	const range = $derived(resolveRange({ value, min, max, low, high, optimum }));
	const region = $derived(meterRegion(range));
	const span = $derived(range.max - range.min);
	/* Halved before subtracting, so a range near the largest double cannot overflow to Infinity. */
	const percent = (n: number) =>
		span > 0 ? ((n / 2 - range.min / 2) / (range.max / 2 - range.min / 2)) * 100 : 0;
	const fill = $derived(percent(range.value));

	const shown = $derived(valueText ?? `${Math.round(fill)}%`);
	const regionText = $derived(regionLabels?.[region]);
	const ariaText = $derived(regionText ? `${shown}, ${regionText}` : shown);

	/* A threshold at either end of the range is not a boundary, so it is parked off the track. */
	const lowAt = $derived(range.low > range.min && range.low < range.max ? percent(range.low) : -10);
	const highAt = $derived(
		range.high > range.min && range.high < range.max ? percent(range.high) : 110
	);
	const marked = $derived(markers && (lowAt >= 0 || highAt <= 100));
	/*
	 * A cut the fill has not reached is a gap in a pale track, too faint to find, so a tick in the
	 * muted tone stands in it. Behind the fill the cut stays a clean gap.
	 */
	const ticks = $derived(
		marked ? [...new Set([lowAt, highAt])].filter((at) => at >= fill && at <= 100) : []
	);

	/* Each region reads one tone; the fill, the mark and the region word all follow it. */
	const TONES: Record<MeterRegion, string> = {
		optimum: '[--_tone:var(--_good)]',
		suboptimal: '[--_tone:var(--_warn)]',
		critical: '[--_tone:var(--_bad)]'
	};

	/* The fill never shrinks below a dot the height of the track, so a small value still shows. */
	const SIZES: Record<MeterSize, { track: string; fill: string; tick: string }> = {
		thin: { track: 'h-1', fill: 'min-w-1', tick: 'h-2' },
		regular: { track: 'h-2', fill: 'min-w-2', tick: 'h-3' }
	};

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

{#snippet valueGroup()}
	<span class="flex min-w-0 flex-wrap items-center gap-x-2 text-sm leading-5 tabular-nums">
		<span class="text-(--_muted)">{shown}</span>
		{#if regionText || region !== 'optimum'}
			<!-- Outside the good region the mark shows even without a word, so tone is never colour alone. -->
			<span class="inline-flex items-center gap-1 font-semibold text-(--_tone)">
				<span class="flex shrink-0" aria-hidden="true">
					{@render mark()}
				</span>
				{regionText}
			</span>
		{/if}
	</span>
{/snippet}

{#snippet mark()}
	{#if region === 'optimum'}
		<svg
			class="size-3.5"
			viewBox="0 0 16 16"
			fill="none"
			stroke="currentColor"
			stroke-width="1.75"
			stroke-linecap="round"
			stroke-linejoin="round"
		>
			<path d="M3.5 8.25 6.5 11l6-6.5" />
		</svg>
	{:else if region === 'suboptimal'}
		<svg
			class="size-3.5"
			viewBox="0 0 16 16"
			fill="none"
			stroke="currentColor"
			stroke-width="1.75"
			stroke-linecap="round"
			stroke-linejoin="round"
		>
			<path d="M8 2.75 14 13H2L8 2.75Z" />
			<path d="M8 6.75v2.5M8 11.25v.01" />
		</svg>
	{:else}
		<svg class="size-3.5" viewBox="0 0 16 16" fill="currentColor">
			<path
				fill-rule="evenodd"
				d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM8.75 4.5v4.25h-1.5V4.5h1.5ZM8 11.75a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"
			/>
		</svg>
	{/if}
{/snippet}

<!-- A size container, so it takes its parent's full width rather than shrinking to content. -->
<div
	class={['bounded-meter @container w-full min-w-0', TONES[region], className]}
	data-region={region}
>
	<!-- In a narrow column the value always drops under the label, so a list of meters keeps one shape. -->
	<div
		class="flex flex-col gap-y-1 @xs:flex-row @xs:flex-wrap @xs:items-baseline @xs:justify-between @xs:gap-x-4"
	>
		<span
			id="{uid}-label"
			class="min-w-0 text-sm leading-5 font-semibold text-pretty break-words text-(--_ink)"
		>
			{label}
		</span>
		{#if placement === 'inline'}{@render valueGroup()}{/if}
	</div>

	<div class="relative mt-2 flex items-center">
		<div
			class={[
				'bounded-meter__track flex w-full overflow-hidden rounded-full bg-(--_track) rtl:[--_toward:to_left]',
				step.track,
				marked && 'bounded-meter__track--marked'
			]}
			style={marked ? `--_low: ${lowAt}%; --_high: ${highAt}%` : undefined}
			role="meter"
			aria-labelledby="{uid}-label"
			aria-describedby={description ? `${uid}-description` : undefined}
			aria-valuemin={range.min}
			aria-valuemax={range.max}
			aria-valuenow={range.value}
			aria-valuetext={ariaText}
		>
			{#if fill > 0}
				<div
					class={[
						'h-full rounded-full bg-(--_tone) transition-[width] duration-200 ease-(--_ease) motion-reduce:transition-none',
						step.fill
					]}
					style="width: {fill}%"
				></div>
			{/if}
		</div>
		{#each ticks as at (at)}
			<span
				class={['absolute w-0.5 rounded-full bg-(--_muted)', step.tick]}
				style="inset-inline-start: calc({at}% - 1px)"
				aria-hidden="true"
			></span>
		{/each}
	</div>

	{#if placement === 'below' || description}
		<div class="mt-2 flex flex-col gap-y-1">
			{#if placement === 'below'}{@render valueGroup()}{/if}
			{#if description}
				<p id="{uid}-description" class="min-w-0 text-xs leading-5 text-pretty text-(--_muted)">
					{description}
				</p>
			{/if}
		</div>
	{/if}
</div>

<style>
	/*
	 * Public tokens: set --bounded-meter-* on the meter or any ancestor to retone it. The good
	 * tone is near-black by default, so a quota that is fine reads as plain ink and only the
	 * warning and critical regions bring colour. Every tone clears 3:1 against the track and
	 * 4.5:1 as text on white.
	 */
	.bounded-meter {
		--_good: var(--bounded-meter-good, #18181b);
		--_warn: var(--bounded-meter-warn, #b45309);
		--_bad: var(--bounded-meter-bad, #b91c1c);
		--_track: var(--bounded-meter-track, rgb(0 0 0 / 0.08));
		--_ink: var(--bounded-meter-ink, #18181b);
		--_muted: var(--bounded-meter-muted, #52525b);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}

	/*
	 * Formula: the thresholds are cut out of the track with a mask, a 2 px gap at low and high,
	 * so the regions read on any background without a surface token. The gradient runs with the
	 * inline direction: the rtl: utility on the track flips --_toward, so in right-to-left text
	 * the cuts sit where the fill reaches them. (A :dir() rule here gets rewritten to a :lang()
	 * list by some CSS builds, which misses a dir="rtl" page in another language.)
	 */
	.bounded-meter__track--marked {
		--_cut: 1px;
		--_cuts:
			#000 calc(var(--_low) - var(--_cut)), transparent 0 calc(var(--_low) + var(--_cut)),
			#000 0 calc(var(--_high) - var(--_cut)), transparent 0 calc(var(--_high) + var(--_cut)),
			#000 0;
		mask-image: linear-gradient(var(--_toward, to right), var(--_cuts));
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-cc0e00d818e4a4294c95cd424854ac97b852699f4c603e4b7c91efac81bcd730`
- Entry: `Meter.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/neutral/sha256-cc0e00d818e4a4294c95cd424854ac97b852699f4c603e4b7c91efac81bcd730/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/neutral/sha256-cc0e00d818e4a4294c95cd424854ac97b852699f4c603e4b7c91efac81bcd730/bundle.zip (7071 bytes, sha256 `774c4ea968f455b45ec5b99f53bedafcebfdec82bfb96f732d8e68956ede1524`)

Files:

- `Meter.svelte` (entry, 10125 bytes): https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/neutral/sha256-cc0e00d818e4a4294c95cd424854ac97b852699f4c603e4b7c91efac81bcd730/source/Meter.svelte

### Green good region (`green`)

- Artifact digest: `sha256-be9e32614c6adbb40e357013acf96221df9a53e2902a971a72ce42f299316509`
- Entry: `Meter.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/green/sha256-be9e32614c6adbb40e357013acf96221df9a53e2902a971a72ce42f299316509/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/green/sha256-be9e32614c6adbb40e357013acf96221df9a53e2902a971a72ce42f299316509/bundle.zip (7085 bytes, sha256 `2c6299a5abb2af518ad66589d6b3b409173bf78611822797e948fb4d8df7e78c`)

Files:

- `Meter.svelte` (entry, 10125 bytes): https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/green/sha256-be9e32614c6adbb40e357013acf96221df9a53e2902a971a72ce42f299316509/source/Meter.svelte

### Blue good region (`blue`)

- Artifact digest: `sha256-a1dc18b0cc9f2355c0cc0e0eea528e89424a83b92e39a0ee6182d445dbbfd63e`
- Entry: `Meter.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/blue/sha256-a1dc18b0cc9f2355c0cc0e0eea528e89424a83b92e39a0ee6182d445dbbfd63e/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/blue/sha256-a1dc18b0cc9f2355c0cc0e0eea528e89424a83b92e39a0ee6182d445dbbfd63e/bundle.zip (7074 bytes, sha256 `edac873a6f6e1a0dd1af0611cc8ed6997d3f1a1cb6c10ce5169822ea2e3a32f4`)

Files:

- `Meter.svelte` (entry, 10125 bytes): https://pagesugar.com/artifacts/cmp_bounded_meter_01/1.0.0/blue/sha256-a1dc18b0cc9f2355c0cc0e0eea528e89424a83b92e39a0ee6182d445dbbfd63e/source/Meter.svelte
