Measurement field
A number joined to its unit in one shape: a fixed unit segment or a native unit select. Stores amount and unit together, parses the locale's decimal comma, and converts only when you pass convert.
cmp_measurement_field_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_measurement_field_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
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-c4555612b0ab1849be1ada501c1b54a6c657069c8b4d5998f22d5fd33b21ecf5
<!--
A number with its unit: a labelled text input for the amount, joined to either a fixed unit
segment (kg) or a native unit select (cm / in) inside one border. The value is always an
{ amount, unit } pair. Changing the unit converts the amount only when the consumer supplies
convert; otherwise the amount is kept and a reserved line under the field says so. Both are
announced politely. Parsing follows the locale's decimal separator.
-->
<script lang="ts" module>
export interface MeasurementUnit {
/** Stored in value.unit and submitted, e.g. 'cm'. */
id: string;
/** Shown in the segment or the select, e.g. 'cm'. */
label: string;
/** Full name for assistive technology and sentences, e.g. 'centimetres'. */
spokenLabel: string;
}
export interface MeasurementValue {
/** The parsed amount in the current unit; null when empty or not a number. */
amount: number | null;
/** The id of the current unit. */
unit: string;
}
export type MeasurementPlacement = 'end' | 'below';
/** Every string the field writes itself, for other languages. */
export interface MeasurementFieldMessages {
/** Accessible name of the unit select. */
unit: string;
/** Read before an error by screen readers only. */
errorPrefix: string;
/** Shown when the text is not a number. */
notANumber: string;
/** Shown when the amount is outside min or max. Bounds arrive formatted for the locale. */
outOfRange: (range: { min?: string; max?: string; unit: string }) => string;
/** Shown after convert changed the amount. */
converted: (from: { amount: string; unit: string }) => string;
/** Shown when the unit changed but the amount was kept. */
unchanged: (unit: string) => string;
}
const defaultMessages: MeasurementFieldMessages = {
unit: 'Unit',
errorPrefix: 'Error:',
notANumber: 'Enter a number, using digits and one decimal point.',
outOfRange: ({ min, max, unit }) =>
min !== undefined && max !== undefined
? `Enter an amount from ${min} to ${max} ${unit}.`
: min !== undefined
? `Enter ${min} ${unit} or more.`
: `Enter ${max} ${unit} or less.`,
converted: ({ amount, unit }) => `Converted from ${amount} ${unit}.`,
unchanged: (unit) => `Amount not converted. Check it is in ${unit}.`
};
</script>
<script lang="ts">
import { untrack } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
type Passthrough = Omit<
HTMLInputAttributes,
| 'type'
| 'value'
| 'id'
| 'name'
| 'min'
| 'max'
| 'step'
| 'disabled'
| 'class'
| 'children'
| 'inputmode'
| 'aria-invalid'
>;
interface Props extends Passthrough {
/** Visible label, e.g. 'Height'. Also labels the group. */
label: string;
/** Bindable amount and unit. The unit is always one of units. */
value: MeasurementValue;
/** Available units. One entry renders a fixed unit segment; more render a select. */
units: MeasurementUnit[];
/** Converts the amount when the unit changes. Without it the amount is kept. */
convert?: (amount: number, from: string, to: string) => number;
/** Minimum in the current unit. */
min?: number;
/** Maximum in the current unit. */
max?: number;
/** Expected precision. A whole-number step brings up a numeric keypad on phones. */
step?: number | 'any';
/** Locale for parsing and showing the amount, e.g. 'de-DE' accepts 1,5. */
locale?: string;
/** Validation message from the consumer. Takes the place of the field's own range message. */
error?: string | null;
/** Help text under the label. */
hint?: string;
/** Where the unit select sits. Below 15rem of container it stacks anyway. */
unitPlacement?: MeasurementPlacement;
/** Size the amount for this many digits instead of filling the container. */
digits?: number;
/** Form name for the amount; the unit submits as unitName. */
name?: string;
/** Form name for the unit. Defaults to `${name}-unit`. */
unitName?: string;
/** Disables the amount and the unit. */
disabled?: boolean;
/** The field's own strings, for other languages. */
messages?: Partial<MeasurementFieldMessages>;
/** Input id. Defaults to one generated per instance. */
id?: string;
/** Extra classes on the outer wrapper, for placement. */
class?: string;
}
let {
label,
value = $bindable(),
units,
convert,
min,
max,
step = 'any',
locale = 'en',
error = null,
hint,
unitPlacement = 'end',
digits,
name,
unitName,
disabled = false,
messages,
id,
class: className,
oninput: consumerInput,
onblur: consumerBlur,
...rest
}: Props = $props();
const uid = $props.id();
const inputId = $derived(id ?? `${uid}-input`);
const labelId = `${uid}-label`;
const hintId = `${uid}-hint`;
const unitId = `${uid}-unit`;
const errorId = `${uid}-error`;
const t = $derived({ ...defaultMessages, ...messages });
/* units should never be empty; if it is, the field still renders with the value's own unit. */
const unit = $derived<MeasurementUnit>(
units.find((u) => u.id === value?.unit) ??
units[0] ?? { id: value?.unit ?? '', label: '', spokenLabel: '' }
);
const selectable = $derived(units.length > 1);
/* Phones: a numeric keypad for whole steps, a decimal one otherwise, full text when negatives are allowed. */
const inputmode = $derived<HTMLInputAttributes['inputmode']>(
min !== undefined && min < 0
? 'text'
: typeof step === 'number' && Number.isInteger(step)
? 'numeric'
: 'decimal'
);
/* ---- Locale-aware parsing and formatting ---- */
const separators = $derived.by(() => {
const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
return {
group: parts.find((p) => p.type === 'group')?.value ?? ',',
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.'
};
});
/* Twenty places, the most Intl allows: rounding is the consumer's, so nothing is cut here. */
const formatter = $derived(
new Intl.NumberFormat(locale, { useGrouping: false, maximumFractionDigits: 20 })
);
function format(amount: number | null | undefined): string {
return amount === null || amount === undefined || !Number.isFinite(amount)
? ''
: formatter.format(amount);
}
/** The amount in text, or null when empty, or NaN when it is not a finite number. */
function parse(text: string): number | null {
let s = text
/* Direction marks that Arabic and Persian formatting puts around a sign. */
.replace(/[\u200e\u200f\u061c]/g, '')
.trim()
/* Arabic-Indic and Persian digits, and the Arabic decimal and group marks. */
.replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660))
.replace(/[\u06f0-\u06f9]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/\u066b/g, separators.decimal)
.replace(/\u066c/g, separators.group)
.replace(/\u2212/g, '-');
if (!s) return null;
/* Spaces, which some locales group with, are never a decimal mark. */
s = s.replace(/[\s\u00a0\u202f]/g, '');
const { group, decimal } = separators;
if (group.trim()) {
/*
* A point or comma that is the locale's group mark but appears once, with one or two digits
* after it and no decimal mark, is a decimal: "1,5" in en is 1.5, "1,500" is 1500. Anywhere
* else, group marks count only in threes ("1,234,567"), so "2,25,0" is not a number.
*/
const at = s.indexOf(group);
if (
!s.includes(decimal) &&
(group === '.' || group === ',') &&
at !== -1 &&
at === s.lastIndexOf(group) &&
/^\d{1,2}$/.test(s.slice(at + 1))
) {
s = `${s.slice(0, at)}${decimal}${s.slice(at + 1)}`;
}
const cut = s.indexOf(decimal);
const whole = (cut === -1 ? s : s.slice(0, cut)).replace(/^-/, '');
if (whole.includes(group)) {
const g = group.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!new RegExp(`^\\d{1,3}(${g}\\d{3})+$`).test(whole)) return NaN;
s = s.split(group).join('');
}
}
if (decimal !== '.') s = s.split(decimal).join('.');
const n = /^-?(\d+\.?\d*|\.\d+)$/.test(s) ? Number(s) : NaN;
return Number.isFinite(n) ? n : NaN;
}
const sameAmount = (a: number | null | undefined, b: number | null | undefined) =>
(a ?? null) === (b ?? null);
/* ---- State ---- */
let text = $state(format(value?.amount));
/* Range and number messages wait until the person leaves the field, unless a value arrived set. */
let checked = $state(value?.amount !== null && value?.amount !== undefined);
let note = $state('');
let noteKind = $state<'converted' | 'unchanged'>('converted');
/*
* Telling the field's own writes from the consumer's. After the field writes a value, the next
* value object it sees with that content is its own echo (a bound $state hands back a proxy, so
* identity alone cannot say). Any other new value object came from outside: the text is
* re-formatted from it unless it already reads as that amount, and it is checked at once.
*/
let seen: MeasurementValue | undefined = value;
let seenFormatter: Intl.NumberFormat | undefined;
let echo: MeasurementValue | null = null;
function write(next: MeasurementValue) {
echo = next;
value = next;
}
$effect.pre(() => {
const v = value;
const f = formatter;
untrack(() => {
const localeChanged = seenFormatter !== undefined && f !== seenFormatter;
seenFormatter = f;
if (v === seen && !localeChanged) return;
seen = v;
const own = echo !== null && sameAmount(v?.amount, echo.amount) && v?.unit === echo.unit;
echo = null;
if (own && !localeChanged) return;
const shown = parse(text);
if (localeChanged || Number.isNaN(shown) || !sameAmount(shown, v?.amount)) {
text = format(v?.amount);
}
if (!own) checked = v?.amount !== null && v?.amount !== undefined;
});
});
/* The value always carries a unit from the list, even when it arrived without one. */
$effect(() => {
if (unit.id && value?.unit !== unit.id) write({ amount: value?.amount ?? null, unit: unit.id });
});
const parsed = $derived(parse(text));
const ownError = $derived.by(() => {
if (!checked || parsed === null) return null;
if (Number.isNaN(parsed)) return t.notANumber;
if ((min !== undefined && parsed < min) || (max !== undefined && parsed > max)) {
return t.outOfRange({
min: min === undefined ? undefined : format(min),
max: max === undefined ? undefined : format(max),
unit: unit.spokenLabel
});
}
return null;
});
const shownError = $derived(error || ownError);
const invalid = $derived(!!shownError);
/* Hint, then the fixed unit's name, then the error, then anything the consumer attached. */
const describedBy = $derived(
[
...new Set(
[
hint && hintId,
!selectable && unitId,
invalid && errorId,
...(rest['aria-describedby'] ?? '').split(/\s+/)
].filter(Boolean)
)
].join(' ') || undefined
);
function oninput(event: Event & { currentTarget: HTMLInputElement }) {
text = event.currentTarget.value;
checked = false;
note = '';
write({ amount: Number.isNaN(parsed) ? null : parsed, unit: unit.id });
consumerInput?.(event);
}
function onblur(event: FocusEvent & { currentTarget: HTMLInputElement }) {
checked = true;
consumerBlur?.(event);
}
function onunitchange(event: Event & { currentTarget: HTMLSelectElement }) {
const from = unit;
const to = units.find((u) => u.id === event.currentTarget.value) ?? from;
if (to.id === from.id) return;
const amount = value?.amount ?? null;
const converted = amount !== null && convert ? convert(amount, from.id, to.id) : NaN;
if (amount === null) {
write({ amount, unit: to.id });
note = '';
} else if (Number.isFinite(converted)) {
write({ amount: converted, unit: to.id });
text = format(converted);
note = t.converted({ amount: format(amount), unit: from.spokenLabel });
noteKind = 'converted';
} else {
/* No convert, or one that returned something that is not a number: keep the amount. */
write({ amount, unit: to.id });
note = t.unchanged(to.spokenLabel);
noteKind = 'unchanged';
}
checked = true;
}
let input: HTMLInputElement | undefined = $state();
/* A press on the fixed unit or the border focuses the amount, as a press inside one box should. */
function focusInput(event: PointerEvent) {
if (!input || disabled || event.button !== 0 || event.target === input) return;
if ((event.target as Element).closest('select')) return;
event.preventDefault();
input.focus();
}
</script>
<div
class={['measurement-field @container min-w-0', className]}
data-placement={selectable ? unitPlacement : undefined}
data-invalid={invalid ? '' : undefined}
data-sized={digits ? '' : undefined}
style:--_digits={digits}
>
<div role="group" aria-labelledby={labelId}>
<label
id={labelId}
for={inputId}
class="block text-sm leading-5 font-medium text-pretty break-words text-(--_ink)"
>
{label}
</label>
{#if hint}
<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
{hint}
</p>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="measurement-field__control mt-2 flex min-h-(--_h) max-w-full overflow-hidden rounded-lg border border-(--_edge) bg-(--_surface) text-(--_ink)"
data-disabled={disabled ? '' : undefined}
onpointerdown={focusInput}
>
<input
bind:this={input}
{...rest}
type="text"
{inputmode}
autocomplete="off"
spellcheck={false}
{name}
{disabled}
id={inputId}
value={text}
{oninput}
{onblur}
aria-describedby={describedBy}
aria-invalid={invalid ? 'true' : undefined}
class="measurement-field__input min-w-0 flex-1 bg-transparent px-3 text-sm leading-5 text-(--_ink) tabular-nums outline-none placeholder:text-(--_placeholder) disabled:cursor-not-allowed pointer-coarse:text-base"
/>
{#if selectable}
<span class="measurement-field__unit relative flex shrink-0">
<select
name={unitName ?? (name ? `${name}-unit` : undefined)}
{disabled}
aria-label={t.unit}
value={unit.id}
onchange={onunitchange}
class="measurement-field__select w-full cursor-pointer appearance-none bg-(--_segment) ps-3 pe-8 text-sm leading-5 font-medium text-(--_ink) outline-none disabled:cursor-not-allowed pointer-coarse:text-base"
>
{#each units as option (option.id)}
<option value={option.id}>{option.label}</option>
{/each}
</select>
<svg
class="pointer-events-none absolute end-3 top-1/2 size-4 -translate-y-1/2 text-(--_muted)"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="m4.5 6.5 3.5 3.5 3.5-3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
{:else}
<span
class="measurement-field__unit flex shrink-0 items-center bg-(--_segment) px-3 text-sm leading-5 whitespace-nowrap text-(--_muted) pointer-coarse:text-base"
aria-hidden="true"
>
{unit.label}
</span>
<span id={unitId} class="sr-only">{unit.spokenLabel}</span>
{#if name}
<input type="hidden" name={unitName ?? `${name}-unit`} value={unit.id} {disabled} />
{/if}
{/if}
</div>
<!-- One line is held under the field, so the first message moves nothing below. An error
and a unit note can both show: the note says what happened to the number, the error
what is wrong with it. -->
<div class="mt-1 min-h-5 text-[13px] leading-5">
{#if invalid}
<p id={errorId} class="flex items-start gap-1 text-(--_danger)">
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 4.75v3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="11" r="1" fill="currentColor" />
</svg>
</span>
<span class="min-w-0 text-pretty break-words"
><span class="sr-only">{`${t.errorPrefix} `}</span>{shownError}</span
>
</p>
{/if}
{#if selectable}
<p
role="status"
class={['flex items-start gap-1 text-(--_muted)', invalid && note && 'mt-1']}
>
{#if note}
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
{#if noteKind === 'converted'}
<!-- Two opposed arrows: the amount moved between units. -->
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<path
d="M3 5.5h9.5M10 3l2.5 2.5L10 8M13 10.5H3.5M6 8l-2.5 2.5L6 13"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else}
<!-- An information mark: the amount stayed and wants a second look. -->
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 7.25V11"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="5" r="1" fill="currentColor" />
</svg>
{/if}
</span>
<span class="min-w-0 text-pretty break-words">{note}</span>
{/if}
</p>
{/if}
</div>
</div>
</div>
<style>
/* Public tokens: set --measurement-field-* on the field or any ancestor to retone it. */
.measurement-field {
--_accent: var(--measurement-field-accent, #18181b);
--_danger: var(--measurement-field-danger, #b91c1c);
--_ink: var(--measurement-field-ink, #18181b);
--_muted: var(--measurement-field-muted, #52525b);
--_placeholder: var(--measurement-field-placeholder, #71717a);
/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
--_hairline: var(--measurement-field-hairline, rgb(0 0 0 / 0.44));
--_surface: var(--measurement-field-surface, #ffffff);
/* Formulas: the unit segment is one raised step on the surface, behind a half-strength divider. */
--_segment: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
--_segment-hover: color-mix(in oklab, var(--_surface), var(--_ink) 8%);
--_divider: color-mix(in oklab, var(--_hairline) 50%, transparent);
/* The shape's rounded-lg, for the select's inner corners. */
--_radius: 0.5rem;
--_h: 2.25rem;
--_edge: var(--_hairline);
--_ring: var(--_accent);
}
/* Touch screens: every row of the shape grows to 44 px. */
/* Touch screens: each row of the shape is 44 px inside its 1 px border. */
@media (pointer: coarse) {
.measurement-field__control {
--_h: calc(2.75rem + 2px);
}
}
.measurement-field[data-invalid] {
--_edge: var(--_danger);
--_ring: var(--_danger);
--_divider: color-mix(in oklab, var(--_danger) 50%, transparent);
}
/*
* The shape: one border and radius around amount and unit. Its edge, halo and the segment
* fill follow the controls inside, so their transitions live here with those rules.
*/
.measurement-field__control {
transition-property: border-color, box-shadow;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.measurement-field__unit {
border-inline-start: 1px solid var(--_divider);
}
.measurement-field__select {
border-start-end-radius: calc(var(--_radius) - 1px);
border-end-end-radius: calc(var(--_radius) - 1px);
transition: background-color 150ms cubic-bezier(0.2, 0, 0, 1);
}
/* Sized for its digits: the amount takes that many tabular figures plus its padding. */
.measurement-field[data-sized] .measurement-field__control {
inline-size: fit-content;
}
.measurement-field[data-sized] .measurement-field__input {
flex: none;
inline-size: calc(var(--_digits) * 1ch + 1.5rem);
max-inline-size: 100%;
}
/*
* The unit below the amount, by choice or when the container is under 15rem (a 320 px phone
* inside a padded card): the shape turns into two rows, the divider runs across, and the
* select takes the shape's width, which stays sized to the digits when digits is set.
*/
.measurement-field[data-placement='below'] .measurement-field__control {
flex-direction: column;
}
.measurement-field[data-placement='below'] .measurement-field__input {
min-block-size: var(--_h);
}
.measurement-field[data-placement='below'] .measurement-field__unit {
min-block-size: var(--_h);
border-inline-start: 0;
border-block-start: 1px solid var(--_divider);
}
.measurement-field[data-placement='below'] .measurement-field__select {
border-start-end-radius: 0;
border-end-start-radius: calc(var(--_radius) - 1px);
}
@container (width < 15rem) {
.measurement-field[data-placement] .measurement-field__control {
flex-direction: column;
}
.measurement-field[data-placement] .measurement-field__input {
min-block-size: var(--_h);
}
.measurement-field[data-placement] .measurement-field__unit {
min-block-size: var(--_h);
border-inline-start: 0;
border-block-start: 1px solid var(--_divider);
}
.measurement-field[data-placement] .measurement-field__select {
border-start-end-radius: 0;
border-end-start-radius: calc(var(--_radius) - 1px);
}
}
/* Hover only where a click lands: the edge darkens over the amount, the fill over the select. */
@media (hover: hover) {
.measurement-field__control:not([data-disabled]):has(input:hover) {
--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
}
.measurement-field[data-invalid] .measurement-field__control:has(input:hover) {
--_edge: var(--_danger);
}
.measurement-field__select:hover:not(:disabled) {
background-color: var(--_segment-hover);
}
}
/* Amount focused: a two-pixel edge around the whole shape, unit included, and a soft halo. */
.measurement-field__control:has(input:focus-visible) {
border-color: var(--_ring);
box-shadow:
0 0 0 1px var(--_ring),
0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
}
/* Unit focused: a two-pixel ring inside its own segment, so it never collides with the edge. */
.measurement-field__select:focus-visible {
outline: 2px solid var(--_accent);
outline-offset: -2px;
}
.measurement-field__control[data-disabled] {
background-color: var(--_segment);
opacity: 0.55;
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
.measurement-field__control,
.measurement-field__select {
transition: none;
}
}
/* Forced colours drop shadows, so focus on the amount becomes a system-colour outline. */
@media (forced-colors: active) {
.measurement-field__control:has(input:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
</style>
Usage#
On this pagePass a label, the units and a bound value, and it renders a text input for the amount joined to a fixed unit or a unit select. It parses what is typed for the locale and writes { amount, unit } back. It converts only through your convert function and rounds nothing itself; it ships no unit library, and it checks only min and max.
- Suggested location
src/lib/components/measurement-field-01
Limitations
- Conversion and its rounding are yours: pass convert and round inside it. The field shows what you return, up to 20 decimal places. Without convert, a unit change keeps the amount and shows a note asking the person to check it.
- min and max are in the current unit. Pass bounds for the new unit when it changes; the field re-checks against whatever you pass.
- The range and not-a-number messages appear once the person leaves the field, or at once for a value you set from outside. Your error prop replaces them.
- The amount submits as typed, in the locale's format (1,5 for de-DE); the unit submits as
unitName, `${name}-unit` by default. Readvalue.amountfor the parsed number. - No compound units such as feet and inches in one field, no stepper buttons and no arrow-key increments; step only picks the phone keyboard.
- Phones show a decimal keypad, which has no minus key on iOS. Set min below 0 to get a full keyboard for negative amounts.
- The field never checks physical plausibility beyond min and max.
- One line is always held under the field for messages. A message that wraps, or an error and a unit note together, grows the field by the extra lines.
Example
<script lang="ts">
import MeasurementField from '$lib/components/measurement-field-01/MeasurementField.svelte';
let height = $state({ amount: 172 as number | null, unit: 'cm' });
const toCm: Record<string, number> = { cm: 1, in: 2.54 };
const convert = (amount: number, from: string, to: string) =>
Math.round(((amount * toCm[from]) / toCm[to]) * 100) / 100;
</script>
<form method="POST" class="max-w-sm">
<MeasurementField
label="Height"
name="height"
bind:value={height}
units={[
{ id: 'cm', label: 'cm', spokenLabel: 'centimetres' },
{ id: 'in', label: 'in', spokenLabel: 'inches' }
]}
{convert}
min={height.unit === 'cm' ? 50 : 20}
max={height.unit === 'cm' ? 250 : 98}
digits={5}
hint="Without shoes."
/>
</form>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.
Measurement field#
A number and its unit, read as one object. Pass one unit for a fixed segment (kg) or several
for a native select (cm / in). The value is always { amount, unit }.
Converting, or not#
When the unit changes, the field calls your convert(amount, from, to) if you passed one and
writes its result back. Round inside it; the field shows what you return, up to 20 decimal
places. A result that is not a finite number is treated as no conversion.
const toCm: Record<string, number> = { cm: 1, in: 2.54 };
const convert = (amount: number, from: string, to: string) =>
Math.round(((amount * toCm[from]) / toCm[to]) * 100) / 100;Without convert, the amount is kept and the line under the field says it was not converted,
naming the new unit in full. That suits forms where the person probably measured in the unit
they meant to pick.
Either way the note goes into a polite status line. One line is always held under the field for the note or an error, so the first message moves nothing below.
Bounds per unit#
min and max are in the current unit. Derive them from the bound value so they follow a
unit change:
<MeasurementField
bind:value={height}
min={height.unit === 'cm' ? 50 : 20}
max={height.unit === 'cm' ? 250 : 98}
...
/>The field's range message appears once the person leaves the field. Your error replaces it.
Locale#
locale sets the decimal mark the field reads and writes: de-DE takes 1,5, ar takes
Arabic-Indic digits. The form submits the amount as typed, so parse value.amount (or the
locale string on the server). The unit submits as unitName, ${name}-unit by default.
Translate every sentence the field writes through messages:
messages={{
unit: 'Einheit',
errorPrefix: 'Fehler:',
notANumber: 'Geben Sie eine Zahl ein.',
outOfRange: ({ min, max, unit }) => `Geben Sie ${min} bis ${max} ${unit} ein.`,
converted: ({ amount, unit }) => `Umgerechnet aus ${amount} ${unit}.`,
unchanged: (unit) => `Nicht umgerechnet. Prüfen Sie, ob der Wert in ${unit} ist.`
}}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label, e.g. 'Height'. Names the input and the group around input and unit. |
value | { amount: number | null; unit: string } | Yes | None | Bindable amount and unit. amount is null while the field is empty or holds something that is not a number; unit is always one of units. |
units | { id: string; label: string; spokenLabel: string }[] | Yes | None | Available units, at least one. One entry renders a fixed unit segment; more render a select. spokenLabel is the full name ('centimetres') used for assistive technology and in the field's sentences. |
convert | (amount: number, from: string, to: string) => number | No | None | Called when the unit changes and there is an amount. Its result replaces the amount; round inside it. Without it, or if it returns something that is not a finite number, the amount is kept and a note says so. |
min | number | No | None | Minimum in the current unit. |
max | number | No | None | Maximum in the current unit. |
step | number | 'any' | No | 'any' | Expected precision. A whole-number step brings up a numeric keypad on phones, otherwise a decimal one. It does not round or validate. |
locale | string | No | 'en' | BCP 47 locale for parsing and showing the amount: its decimal mark and grouping, e.g. 'de-DE' reads 1,5. |
error | string | null | No | null | Validation message from your code. Shown with an icon in place of the field's own range message, and sets aria-invalid. |
hint | string | No | None | Help text under the label, linked first in aria-describedby. |
unitPlacement | 'end' | 'below' | No | 'end' | Where a unit select sits in the shape. Under 15rem of container width it stacks below either way, keeping its digits width. |
digits | number | No | None | Size the amount for this many figures instead of filling the container. |
name | string | No | None | Form name for the amount, so native submission includes it. |
unitName | string | No | None | Form name for the unit. Defaults to `${name}-unit`. |
disabled | boolean | No | false | Disables the amount and the unit select. |
messages | Partial<MeasurementFieldMessages> | No | None | The field's own strings for other languages: unit (the select's name), errorPrefix, notANumber, outOfRange, converted and unchanged. The last three are functions given formatted amounts and full unit names. |
oninput | HTMLInputAttributes['oninput'] | No | None | Called after the field has parsed the typing and written value. |
onblur | HTMLInputAttributes['onblur'] | No | None | Called after the field has run its range check. |
id | string | No | None | Input id. Defaults to one generated per instance; the other ids are always generated. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange the copy through props and messages, and retone the field through seven --measurement-field-* CSS variables. The unit segment, its hover and the divider are mixed from those, so they follow any retone.
- Accent:
--measurement-field-accentcolours the focus edge around the shape and the ring inside the unit select. Keep it at 3:1 against the page. - Danger:
--measurement-field-dangercolours the error edge, icon and message. The default isred-700(6.5:1 on white). - Neutrals:
--measurement-field-inkis the label, amount and select;--measurement-field-mutedthe hint, fixed unit and note;--measurement-field-placeholderthe placeholder;--measurement-field-hairlinethe resting edge (keep it at 3:1, since it is the only thing marking an empty field);--measurement-field-surfacethe fill. The unit segment is the surface mixed 4% toward the ink. - Worked retone for a
#09090bband:--measurement-field-accent:#fafafa;--measurement-field-ink:#fafafa;--measurement-field-muted:#a1a1aa;--measurement-field-placeholder:#a1a1aa;--measurement-field-hairline: rgb(255 255 255 / 0.35);--measurement-field-surface:#18181b;--measurement-field-danger:#f87171. Add color-scheme: dark on the band so the select's list follows. - Cream page such as a bakery's
#fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set--measurement-field-surface:#fffdf9. - Width: pass digits for the figures you expect (5 fits 172.5, 4 fits 38.2) so a short number does not float in a wide box. Leave it out to fill a narrow column.
- Units: keep labels short in the segment (kg, cm, °F); the
spokenLabelcarries the full name.unitPlacement'below' suits long unit names such as 'metres' and 'feet'. - Language: pass locale for the decimal mark and messages for every sentence the field writes, as the Arabic preview fixture does.
Public CSS variables
| Variable | Token |
|---|---|
--measurement-field-accent | accent |
--measurement-field-danger | danger |
--measurement-field-ink | ink |
--measurement-field-muted | muted |
--measurement-field-placeholder | placeholder |
--measurement-field-hairline | hairline |
--measurement-field-surface | surface |
Accessibility#
On this page- Input and unit sit in a
role=grouplabelled by the visible label, so they are announced together; the label also names the input through <label for>. - A fixed unit is hidden from assistive technology as an abbreviation and its
spokenLabel('kilograms') is in the input'saria-describedby, after the hint and before the error and anyaria-describedbyyou pass. - The unit select is a native <select> named by
messages.unit('Unit' by default). Translate it with the page. - A unit change writes a note into a polite
role=statusline under the field: what the amount was converted from (two arrows), or that it was kept (an information mark). The line is reserved, so the note moves nothing; when an error shows too, both stay visible, the error first. - Errors carry an icon, text and a visually hidden
errorPrefix, and are linked byaria-describedbywitharia-invalidset. They are not a live region; announce a summary on submit as your form decides. - The amount is a text input with inputmode decimal or numeric, not
type=number, so the mouse wheel and arrow keys never change it by accident. - Focus on the amount draws a two-pixel accent edge around the whole shape; focus on the select draws a two-pixel ring inside its segment, so the two never collide. Targets grow to 44 px on touch screens.
Known limitations
- Screen readers announce the group and the input with the same label text. That is the cost of naming both; keep labels short.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- 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 · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.