# Number field

> A labelled number input sized to its bounds: parses the locale's separators and digits, states min and max in the hint, steps with arrow keys, keeps invalid text visible and submits a canonical value.

- ID: `cmp_number_field_01`
- Slug: `number-field-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T04:02:27Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/number-field-01?variant=blue
- Preview: https://pagesugar.com/preview/number-field-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-17d0997350e793bf0cc1d5dc0ce3938b0f54017fbd4df57c81f1123b749353fd` |
| `blue` | Blue accent | no | `sha256-92e056a5b3fbe868719acc9aeffa43a203dbd18ad1e8c2a90d3b7bd3a4b54198` |

## 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: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/number-field-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Give it a label, a name and bounds, and bind value to a number or null. It parses what people type in your locale, states the bounds, and shows its own error or clamps on blur. It does not handle currency or percentages, does not keep more precision than a JavaScript number, and is not a substitute for checking the value on the server.

Required props: `label`

```svelte
<script lang="ts">
	import NumberField from '$lib/components/number-field-01/NumberField.svelte';

	let teamSize = $state<number | null>(48);
</script>

<form method="POST" class="max-w-sm">
	<NumberField
		label="Team size"
		name="team-size"
		min={1}
		max={10000}
		hint="People who will plan in Halcyon."
		bind:value={teamSize}
	/>
</form>
```

Limitations:

- Values are JavaScript numbers: about 15 significant digits and at most 20 decimal places, no arbitrary-precision decimals. Use a string-based field for money or identifiers.
- text restores a draft on mount only. To change the field from outside afterwards, set value.
- A native form reset restores the value or text the field was mounted with.
- formatOptions is for plain decimal display (fraction digits, grouping). Currency, percent and unit styles are separate components; their output will not parse back.
- The visible input has no name. The hidden input submits the value, so a form posted before hydration sends the initial value, not what was typed.
- Errors and clamping happen on blur; a value that was given out of range is shown with its error at once but only clamped when someone leaves the field.
- While the text cannot be accepted the field sets a custom validity message, so a native submit stops on it. Forms with novalidate submit an empty value instead.
- Fields that accept negatives use the full keyboard on phones, because numeric and decimal keypads have no minus key.
- Server code must check the bounds again; the browser can be bypassed.
- reserveError holds one line, or the number of lines you pass; a longer error or note still grows the field. Size it to your longest message where layout stability matters.

## Usage guide

### Number field

A text input that holds a number. It is the base for every numeric field: the quantity stepper,
currency, percentage and measurement inputs copy its parser, `source/parse-number.ts`.

```svelte
<NumberField label="Team size" name="team-size" min={1} max={10000} bind:value={teamSize} />
```

#### Why not `type="number"`

A native number input changes when someone scrolls past it, empties `value` when the text is not
a number (so `12a` silently becomes nothing), ignores the page's locale and announces itself as a
spin button with buttons that are hard to hit. This field is `type="text"` with
`inputmode="numeric"` or `"decimal"`, so the right keyboard still appears and the text someone
typed stays put until they fix it.

#### Value and text

`value` is a number or `null`. It is `null` while the field is empty and while the text cannot be
accepted: not a number, outside the bounds, or off the step grid. `text` is the raw text. Bind it
to keep a draft; pass it when the field mounts to restore one, and the field checks it straight away. Changes from outside after that go through `value`. A native form reset restores what the field was mounted with.

The field shows `value` formatted (`1,234.5`, or `1.234,5` in `de-DE`) and swaps in plain digits
(`1234.5`) while it has focus, so editing never fights the group separators.

#### Parsing

Separators and digits come from `Intl.NumberFormat` for the locale:

| Typed in `de-DE` | Reads as |
| ---------------- | -------- |
| `1.234,5`        | 1234.5   |
| `1234,5`         | 1234.5   |
| `2.5`            | 2.5      |
| `1.234`          | 1234     |
| `12a`            | an error |

A group separator followed by groups of three digits is grouping; a single one followed by
anything else is a decimal point, because a phone keyboard often offers only its own region's
separator. Spaces of every width, the locale's own digits, Arabic-Indic digits and the Unicode
minus sign are all accepted.

#### Bounds, errors and clamping

`min` and `max` become a sentence after the hint ("Between 1 and 10,000."). On blur, text that
cannot be accepted keeps its place and gets an error, and `onInvalid` is called with the reason.
With `clamp`, an out-of-range or off-step number moves to the nearest accepted value instead,
with a note under the field ("Changed to 20 cards, the highest allowed.") that is also announced.
Use clamp for settings, not for orders or bookings, where the person should decide.

Set `error` to show your own message; it wins over the field's. Rewrite the field's sentences
with `messages`:

```svelte
<NumberField
	label="Beckenvolumen"
	locale="de-DE"
	unit="m³"
	min={1}
	max={5000}
	step={0.5}
	messages={{ between: 'Zwischen {min} und {max} {unit}.', aboveMax: 'Höchstens {max} {unit}.' }}
/>
```

#### Keyboard

Up and Down add or take one `step`; Page Up and Page Down ten. Steps land on the grid that starts
at `min` and never leave the bounds. The wheel does nothing. Home and End move the caret, as in any
text field.

#### Submitting

A hidden input carries `name` and the canonical value: dot decimal, no grouping, ASCII minus
(`1234.5`, `-3`). It is empty when the field is empty or invalid, and the visible input reports a
custom validity message while invalid, so a native submit stops on it. Check the bounds on the
server again.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label, always shown. The placeholder never stands in for it. |
| `value` | number \| null | no | `null` | Bindable parsed value. Null when the field is empty or its text cannot be accepted (not a number, out of range, off the step grid). |
| `text` | `string` | no |  | Bindable raw text in the field. Pass it when the field mounts to restore a draft, which is checked at once; otherwise the field shows value formatted. Later changes from outside go through value, not text. |
| `name` | `string` | no |  | Form field name, given to a hidden input that submits the value as a dot-decimal string, or an empty string when there is none. |
| `hint` | `string` | no |  | Help text under the label. The bounds sentence follows on its own line; both are linked by aria-describedby. |
| `error` | string \| null | no | `null` | Error from your own validation. Wins over the field's own message while set and sets aria-invalid. |
| `min` | `number` | no |  | Lowest accepted value. Also the start of the step grid. |
| `max` | `number` | no |  | Highest accepted value. |
| `step` | `number` | no | `1` | Arrow-key step and the grid accepted values sit on, counted from min (or 0). |
| `mode` | 'integer' \| 'decimal' | no |  | 'integer' accepts whole numbers only and asks for a numeric keypad; 'decimal' accepts a decimal separator. Defaults to integer for a whole step and decimal otherwise. |
| `locale` | `string` | no | `'en-GB'` | BCP 47 locale for parsing and formatting. 'de-DE' reads and shows 1.234,5; 'ar-EG' shows Arabic-Indic digits. |
| `formatOptions` | `Intl.NumberFormatOptions` | no |  | Display formatting applied when the field loses focus, such as { minimumFractionDigits: 2 } or { useGrouping: false }. Plain decimal styles only. |
| `clamp` | `boolean` | no | `false` | On blur, move an out-of-range or off-step number to the nearest accepted value and show a note saying so, instead of an error. Text that is not a number is still an error. |
| `unit` | `string` | no |  | Unit shown inside the field's end edge, such as 'kg' or 'weeks'. Hidden from screen readers there and read with the bounds instead. |
| `align` | 'start' \| 'end' | no | `'start'` | Text alignment inside the input. 'end' suits figures in a table or settings column. |
| `width` | 'fit' \| 'full' | no | `'fit'` | 'fit' sizes the input to its longest bound plus the step's decimals, so the field's width says what it expects, and widens for longer text such as an invalid draft; 'full' fills the container. |
| `required` | `boolean` | no | `false` | Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. |
| `requiredIndicator` | 'required' \| 'optional' \| 'none' | no |  | Which marker follows the label. Defaults to 'required' for required fields and 'none' otherwise. |
| `requiredLabel` | `string` | no | `'Required'` | Marker text for required fields. |
| `optionalLabel` | `string` | no | `'Optional'` | Marker text for optional fields. |
| `errorPrefix` | `string` | no | `'Error:'` | Visually hidden text read before the error. |
| `messages` | `Partial<NumberFieldMessages>` | no |  | Replace any sentence the field writes: the bounds (between, atLeast, atMost), the errors (notANumber, belowMin, aboveMax, wholeNumber, step) and the clamp notes (clampedMin, clampedMax, rounded). {min}, {max}, {step} and {value} are formatted for the locale; {unit} is the unit. In the step error {value} is the nearest accepted value, in the clamp notes the value the field moved to. |
| `size` | 'sm' \| 'md' \| 'lg' | no | `'md'` | 32, 36 or 40 px tall with 13, 14 or 16 px text, matching buttons of the same size. Every size is 44 px with 16 px text on touch screens. |
| `reserveError` | boolean \| number | no | `false` | Hold space under the field for the error or clamp note, so a message moves nothing below: true holds one line, a number holds that many lines. A message longer than the space still grows the field. |
| `onInvalid` | (reason: 'not-a-number' \| 'below-min' \| 'above-max' \| 'step') =\> void | no |  | Called on blur when the text cannot be accepted. Not called when clamp fixes the value. |
| `onfocus` | `FocusEventHandler<HTMLInputElement>` | no |  | Runs before the field swaps in its editable text. |
| `onblur` | `FocusEventHandler<HTMLInputElement>` | no |  | Runs after the field has checked and formatted its text. |
| `oninput` | `FormEventHandler<HTMLInputElement>` | no |  | Runs on every keystroke, before the field parses the text. |
| `onkeydown` | `KeyboardEventHandler<HTMLInputElement>` | no |  | Runs before the field's own arrow-key stepping; call preventDefault to stop it. |
| `id` | `string` | no |  | Input id. Defaults to one generated per instance; the hint and error always take generated ids. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

Change the copy through props and messages, and retone the field through seven --number-field-\* CSS variables. Hover and the read-only fill are mixed from those, so they follow any retone.

- Accent: --number-field-accent colours the focus edge and its halo. Keep it at 3:1 against the page.
- Danger: --number-field-danger colours the error edge, the error focus ring, the icon and the message. The default is red-700 (6.5:1 on white).
- Neutrals: --number-field-ink is the label and value, --number-field-muted the hint, bounds, unit and clamp note, --number-field-placeholder the placeholder, --number-field-hairline the resting edge (keep it at 3:1 against the page) and --number-field-surface the fill.
- Worked retone for a #09090b band: --number-field-accent: #fafafa; --number-field-ink: #fafafa; --number-field-muted: #a1a1aa; --number-field-placeholder: #a1a1aa; --number-field-hairline: rgb(255 255 255 / 0.35); --number-field-surface: #18181b; --number-field-danger: #f87171. Add color-scheme: dark on the band.
- Cream page such as a bakery's #fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set --number-field-surface: #fffdf9.
- Width: keep 'fit' in forms, where a narrow field tells people the number is short; use width 'full' with align 'end' in a settings column or beside other figures.
- Bounds: the hint states them from min and max. Rewrite the sentence through messages.between, atLeast and atMost; put the unit where your language wants it with {unit}.
- Another language: pass locale and translate messages, requiredLabel, optionalLabel and errorPrefix. Separators and digits come from the locale.
- Behaviour: leave clamp off where an out-of-range number is a mistake worth telling someone about (a booking, an order); turn it on for settings where the nearest allowed value is obviously what they meant (a limit, a zoom level).

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--number-field-accent` |
| `danger` | `--number-field-danger` |
| `ink` | `--number-field-ink` |
| `muted` | `--number-field-muted` |
| `placeholder` | `--number-field-placeholder` |
| `hairline` | `--number-field-hairline` |
| `surface` | `--number-field-surface` |

## Accessibility

- The input is type=text, not type=number, and has no spinbutton role because it has no stepper buttons (see the quantity stepper for that). Arrow keys still step it, and the new value is announced in a polite status region.
- A native \<label for\> names the input. aria-describedby lists the hint, then the bounds, then the error or clamp note, then any aria-describedby you pass. aria-invalid is set only while an error shows.
- The bounds are text in the hint, including the unit, so a screen reader hears 'Between 1 and 20 cards' rather than nothing. The visual unit beside the value is hidden from assistive technology.
- Errors are never colour alone: an icon, text, and a visually hidden errorPrefix. They are not live regions; when a submit fails, move focus to the first invalid field or an error summary.
- Clamping says what changed in a visible note and announces it politely, so a value never changes silently.
- Inputs use 16 px text and grow to 44 px on touch screens, so iOS does not zoom and targets clear 44 px. The fit width keeps at least two characters of input and widens for longer text.

## License

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

## Source

- Palette: Blue accent (`blue`)
- Entry: `NumberField.svelte`
- Suggested directory: `src/lib/components/number-field-01`
- Files: 2
- Artifact digest: `sha256-92e056a5b3fbe868719acc9aeffa43a203dbd18ad1e8c2a90d3b7bd3a4b54198`

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

#### `NumberField.svelte`

Role: entry · 24016 bytes · SHA-256 `e5790f5964ff82f11c8907c04ec002b60420fa253d8aec951e98fa6e5695fa39`

```svelte
<!--
	A number field: a labelled text input that reads and writes a JavaScript number. It parses the
	locale's own separators and digits, states its bounds in the hint, formats the value when the
	field loses focus and shows plain editable digits while it has it. Arrow keys step within the
	bounds; the wheel does nothing. Text it cannot accept stays in the field with an error, or, with
	clamp, is moved into range with a note saying so. A hidden input submits the value as a
	canonical dot-decimal string.

	Sized to the number it holds: the input is as wide as its longest bound, so an age field looks
	like an age field and a field for 10,000 looks like one.
-->
<script lang="ts" module>
	export type NumberFieldInvalidReason = 'not-a-number' | 'below-min' | 'above-max' | 'step';
	export type NumberFieldSize = 'sm' | 'md' | 'lg';
	export type NumberFieldRequiredIndicator = 'required' | 'optional' | 'none';
	/**
	 * Copy the field writes itself. {min}, {max}, {step} and {value} are formatted for the locale;
	 * {unit} is the unit prop, dropped with its space when there is none. In the step error {value}
	 * is the nearest accepted value; in the clamp notes it is the value the field moved to.
	 */
	export interface NumberFieldMessages {
		/** Bounds in the hint when both are set. */
		between: string;
		/** Bounds in the hint when only min is set. */
		atLeast: string;
		/** Bounds in the hint when only max is set. */
		atMost: string;
		notANumber: string;
		belowMin: string;
		aboveMax: string;
		/** Step error when the step is 1. */
		wholeNumber: string;
		/** Step error for any other step. */
		step: string;
		/** Clamp notes. */
		clampedMin: string;
		clampedMax: string;
		rounded: string;
	}

	export const defaultNumberFieldMessages: NumberFieldMessages = {
		between: 'Between {min} and {max} {unit}.',
		atLeast: '{min} {unit} or more.',
		atMost: 'Up to {max} {unit}.',
		notANumber: 'Enter a number without letters or units.',
		belowMin: 'Enter {min} {unit} or more.',
		aboveMax: 'Enter {max} {unit} or less.',
		wholeNumber: 'Enter a whole number.',
		step: 'Use steps of {step}, such as {value}.',
		clampedMin: 'Changed to {value} {unit}, the lowest allowed.',
		clampedMax: 'Changed to {value} {unit}, the highest allowed.',
		rounded: 'Rounded to {value} {unit}.'
	};
</script>

<script lang="ts">
	import { untrack } from 'svelte';
	import type { HTMLInputAttributes } from 'svelte/elements';
	import {
		decimalsOf,
		formatEditable,
		onStep,
		parseNumber,
		roundTo,
		toCanonical
	} from './parse-number';

	type Passthrough = Omit<
		HTMLInputAttributes,
		| 'type'
		| 'value'
		| 'id'
		| 'name'
		| 'size'
		| 'min'
		| 'max'
		| 'step'
		| 'required'
		| 'class'
		| 'children'
		| 'inputmode'
		| 'aria-invalid'
		| 'onfocus'
		| 'onblur'
		| 'oninput'
		| 'onkeydown'
	>;

	interface Props extends Passthrough {
		/** Visible label. Always shown; the placeholder never stands in for it. */
		label: string;
		/** Bindable parsed value. Null when the field is empty or its text cannot be accepted. */
		value?: number | null;
		/** Bindable raw text in the field. Set it to restore a draft; read it to see what was typed. */
		text?: string;
		/** Form field name. A hidden input submits the value as a dot-decimal string. */
		name?: string;
		/** Help text under the label. The bounds sentence gets its own line below it. */
		hint?: string;
		/** Error from your own validation. Wins over the field's own message while set. */
		error?: string | null;
		/** Lowest accepted value. */
		min?: number;
		/** Highest accepted value. */
		max?: number;
		/** Step for arrow keys and the accepted grid, counted from min (or 0). */
		step?: number;
		/** 'integer' accepts whole steps only; 'decimal' takes a decimal separator. Follows step by default. */
		mode?: 'integer' | 'decimal';
		/** BCP 47 locale for parsing and formatting, such as 'de-DE' for 1.234,5. */
		locale?: string;
		/** Display formatting applied when the field loses focus. Plain decimal styles only. */
		formatOptions?: Intl.NumberFormatOptions;
		/** Move out-of-range or off-step values into range on blur, with a note, instead of an error. */
		clamp?: boolean;
		/** Unit shown inside the field's end edge and read out with the bounds, such as 'kg'. */
		unit?: string;
		/** Text alignment inside the input. */
		align?: 'start' | 'end';
		/** 'fit' sizes the input to its longest bound; 'full' fills the container. */
		width?: 'fit' | 'full';
		/** Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. */
		required?: boolean;
		/** Which marker follows the label. Defaults to 'required' for required fields, otherwise 'none'. */
		requiredIndicator?: NumberFieldRequiredIndicator;
		/** Marker text for required fields, for other languages. */
		requiredLabel?: string;
		/** Marker text for optional fields, for other languages. */
		optionalLabel?: string;
		/** Read before the error by screen readers only, for other languages. */
		errorPrefix?: string;
		/** Replace any of the field's own sentences, for tone or another language. */
		messages?: Partial<NumberFieldMessages>;
		/** sm 32 px, md 36 px, lg 40 px tall, matching action buttons; 44 px on touch screens. */
		size?: NumberFieldSize;
		/** Hold one line under the field for the error or clamp note, so neither moves what is below. */
		reserveError?: boolean | number;
		/** Called on blur when the text cannot be accepted. Not called when clamp fixes it. */
		onInvalid?: (reason: NumberFieldInvalidReason) => void;
		onfocus?: HTMLInputAttributes['onfocus'];
		onblur?: HTMLInputAttributes['onblur'];
		oninput?: HTMLInputAttributes['oninput'];
		onkeydown?: HTMLInputAttributes['onkeydown'];
		/** Input id. Defaults to one generated per instance. */
		id?: string;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		label,
		value = $bindable(null),
		text = $bindable(),
		name,
		hint,
		error = null,
		min,
		max,
		step = 1,
		mode,
		locale = 'en-GB',
		formatOptions,
		clamp = false,
		unit,
		align = 'start',
		width = 'fit',
		required = false,
		requiredIndicator,
		requiredLabel = 'Required',
		optionalLabel = 'Optional',
		errorPrefix = 'Error:',
		messages,
		size = 'md',
		reserveError = false,
		onInvalid,
		onfocus,
		onblur,
		oninput,
		onkeydown,
		id,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	const hintId = `${uid}-hint`;
	const boundsId = `${uid}-bounds`;
	const unitId = `${uid}-unit`;
	const errorId = `${uid}-error`;
	const noteId = `${uid}-note`;

	const integer = $derived(
		(mode ?? (Number.isInteger(step) ? 'integer' : 'decimal')) === 'integer'
	);
	const base = $derived(min ?? 0);
	const copy = $derived({ ...defaultNumberFieldMessages, ...messages });

	const display = $derived(
		new Intl.NumberFormat(locale, { maximumFractionDigits: 20, ...formatOptions })
	);
	const formatDisplay = (n: number) => display.format(n);

	/** Fill a message template, dropping {unit} and its space when there is no unit. */
	function say(template: string, vars: { value?: number } = {}): string {
		const values: Record<string, string> = {
			min: min === undefined ? '' : formatDisplay(min),
			max: max === undefined ? '' : formatDisplay(max),
			step: formatDisplay(step),
			value: vars.value === undefined ? '' : formatDisplay(vars.value),
			unit: unit ?? ''
		};
		return template
			.replace(/\{(min|max|step|value|unit)\}/g, (_, key: string) => values[key])
			.replace(/\s+/g, ' ')
			.replace(/\s+([.,;:!?،])/g, '$1')
			.trim();
	}

	type Check = { value: number | null; reason: NumberFieldInvalidReason | null };

	/** Why a number cannot be accepted, if it cannot. */
	function checkValue(n: number | null): Check {
		if (n === null) return { value: null, reason: null };
		if (min !== undefined && n < min) return { value: n, reason: 'below-min' };
		if (max !== undefined && n > max) return { value: n, reason: 'above-max' };
		if ((integer && !Number.isInteger(n)) || !onStep(n, step, base)) {
			return { value: n, reason: 'step' };
		}
		return { value: n, reason: null };
	}

	function checkText(t: string): Check {
		const parsed = parseNumber(t, locale);
		if (parsed.kind === 'empty') return { value: null, reason: null };
		if (parsed.kind === 'invalid') return { value: null, reason: 'not-a-number' };
		return checkValue(parsed.value);
	}

	function message({ reason, value: n }: Check): string {
		if (reason === 'not-a-number') return say(copy.notANumber);
		if (reason === 'below-min') return say(copy.belowMin);
		if (reason === 'above-max') return say(copy.aboveMax);
		if (integer && step === 1) return say(copy.wholeNumber);
		return say(copy.step, { value: n === null ? undefined : fit(n) });
	}

	/* The first render shows what it was given: a draft's raw text, or the value formatted. */
	const draft = text !== undefined;
	if (text === undefined) text = value === null ? '' : formatDisplay(value);
	const given = draft ? checkText(text) : checkValue(value);
	const givenText = text ?? '';
	let result: Check = $state(given);
	/* What it was given is checked at once; what is typed afterwards is checked on blur. */
	let shown: Check | null = $state(given.reason ? given : null);
	let note = $state('');
	let announcement = $state('');
	let focused = false;
	/** The value this field last wrote or was given, to tell a consumer's change from its own. */
	let written: number | null = value;

	const accepted = $derived(result.reason ? null : result.value);
	const shownError = $derived(error || (shown ? message(shown) : null));
	const invalid = $derived(!!shownError);
	const marker = $derived(requiredIndicator ?? (required ? 'required' : 'none'));
	const bounds = $derived(
		min !== undefined && max !== undefined
			? say(copy.between)
			: min !== undefined
				? say(copy.atLeast)
				: max !== undefined
					? say(copy.atMost)
					: ''
	);
	/* Narrow keyboards: digits only when nothing negative or fractional is accepted. */
	const inputmode = $derived<HTMLInputAttributes['inputmode']>(
		min === undefined || min < 0 ? 'text' : integer ? 'numeric' : 'decimal'
	);
	/*
	 * As wide as the longest bound's whole part plus the step's decimals, so the unit sits just
	 * after the value and the padding holds the caret. With no max there is nothing to measure, so
	 * it is wider. Text longer than that (a draft such as 12a, a value past max) widens the slot
	 * rather than being clipped, up to the container.
	 */
	const stepPlaces = $derived(decimalsOf(step));
	const chars = $derived(
		Math.max(
			2,
			[...(text ?? '')].length,
			...[min, max]
				.filter((n): n is number => n !== undefined)
				.map((n) => formatDisplay(Math.trunc(n)).length + (stepPlaces ? stepPlaces + 1 : 0))
		) + (max === undefined ? 4 : 0)
	);
	const reserveLines = $derived(reserveError === true ? 1 : reserveError || 0);
	const describedBy = $derived(
		[
			...new Set(
				[
					hint && hintId,
					bounds && boundsId,
					unit && !bounds && unitId,
					invalid && errorId,
					note && !invalid && noteId,
					...(rest['aria-describedby'] ?? '').split(/\s+/)
				].filter(Boolean)
			)
		].join(' ') || undefined
	);

	let input: HTMLInputElement | undefined = $state();
	let announceTimer: ReturnType<typeof setTimeout> | undefined;

	/* Native submission is blocked while the text cannot be accepted, even by Enter before blur. */
	$effect(() => {
		const blank = required && result.value === null && !result.reason && (text ?? '') !== '';
		input?.setCustomValidity(result.reason ? message(result) : blank ? say(copy.notANumber) : '');
	});

	/* A value set from outside replaces the text; one this field wrote is already showing. */
	$effect(() => {
		const next = value;
		if (Object.is(next, written)) return;
		written = next;
		text = next === null ? '' : focused ? formatEditable(next, locale) : formatDisplay(next);
		result = checkValue(next);
		shown = result.reason ? result : null;
		note = '';
	});

	/* New bounds, step or locale re-check the number held and reformat it for the locale. */
	const constraintKey = () => `${min}|${max}|${step}|${integer}|${locale}`;
	let constraints = untrack(constraintKey);
	$effect(() => {
		const next = constraintKey();
		if (next === constraints) return;
		constraints = next;
		const held = untrack(() => result);
		if (held.reason === 'not-a-number') return;
		const check = checkValue(held.value);
		write(check);
		shown = check.reason ? check : null;
		if (check.value !== null && !focused) text = formatDisplay(check.value);
	});

	/* A native form reset puts back what the field was given. */
	$effect(() => {
		const form = input?.form;
		if (!form) return;
		const reset = () =>
			queueMicrotask(() => {
				text = givenText;
				if (input) input.value = givenText;
				write(given);
				shown = given.reason ? given : null;
				note = '';
			});
		form.addEventListener('reset', reset);
		return () => form.removeEventListener('reset', reset);
	});

	$effect(() => () => clearTimeout(announceTimer));

	function write(check: Check) {
		result = check;
		const next = check.reason ? null : check.value;
		if (!Object.is(next, written)) {
			written = next;
			value = next;
		}
	}

	/* Clear then set, so the same sentence twice is still announced. */
	function announce(sentence: string) {
		announcement = '';
		clearTimeout(announceTimer);
		announceTimer = setTimeout(() => (announcement = sentence), 60);
	}

	/** The nearest value on the step grid that sits within the bounds. */
	function fit(n: number): number {
		const places = Math.max(decimalsOf(step), decimalsOf(base));
		const at = (k: number) => roundTo(base + k * step, places);
		let k = Math.round((n - base) / step);
		if (max !== undefined) {
			k = Math.min(k, Math.ceil((max - base) / step));
			while (at(k) > max) k--;
		}
		if (min !== undefined) k = Math.max(k, 0);
		return at(k);
	}

	function handleInput(event: Event & { currentTarget: EventTarget & HTMLInputElement }) {
		oninput?.(event);
		text = event.currentTarget.value;
		const check = checkText(text);
		write(check);
		note = '';
		/* Errors appear on blur, but a fix clears one at once. */
		if (shown && !check.reason) shown = null;
	}

	function handleFocus(event: FocusEvent & { currentTarget: EventTarget & HTMLInputElement }) {
		onfocus?.(event);
		focused = true;
		if (result.value !== null) text = formatEditable(result.value, locale);
	}

	function handleBlur(event: FocusEvent & { currentTarget: EventTarget & HTMLInputElement }) {
		focused = false;
		commit();
		onblur?.(event);
	}

	function commit() {
		if (rest.readonly) {
			if (result.value !== null) text = formatDisplay(result.value);
			return;
		}
		const check = checkText(text ?? '');
		if (!check.reason) {
			write(check);
			shown = null;
			text = check.value === null ? '' : formatDisplay(check.value);
			return;
		}
		const fitted = clamp && check.value !== null ? checkValue(fit(check.value)) : null;
		if (fitted && !fitted.reason && fitted.value !== null) {
			const next = fitted.value;
			write(fitted);
			text = formatDisplay(next);
			shown = null;
			const template =
				check.reason === 'below-min'
					? copy.clampedMin
					: check.reason === 'above-max'
						? copy.clampedMax
						: copy.rounded;
			note = say(template, { value: next });
			announce(note);
			return;
		}
		write(check);
		shown = check;
		onInvalid?.(check.reason);
	}

	/* Up and Down step by one step, Page Up and Page Down by ten, always landing inside the bounds. */
	function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
		onkeydown?.(event);
		if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
		const by = ({ ArrowUp: 1, ArrowDown: -1, PageUp: 10, PageDown: -10 } as const)[
			event.key as 'ArrowUp'
		];
		if (!by || rest.readonly || rest.disabled) return;
		const parsed = parseNumber(text ?? '', locale);
		if (parsed.kind === 'invalid') return;
		event.preventDefault();

		let next: number;
		if (parsed.kind === 'empty') {
			next = fit(min ?? (max !== undefined && max < 0 ? max : 0));
		} else {
			const places = Math.max(decimalsOf(step), decimalsOf(base));
			const k = roundTo((parsed.value - base) / step, Math.max(places, 6));
			const from = by > 0 ? Math.floor(k) : Math.ceil(k);
			next = roundTo(base + (from + by) * step, places);
			if (max !== undefined && next > max) next = fit(max);
			if (min !== undefined && next < min) next = fit(min);
		}
		const check = checkValue(next);
		/* A grid the bounds or integer mode rule out entirely: leave the text alone. */
		if (check.reason) return;
		text = formatEditable(next, locale);
		write(check);
		shown = null;
		note = '';
		announce(say('{value} {unit}', { value: next }));
	}

	/* A press on the unit or the border focuses the input, as a press inside one box should. */
	function focusInput(event: PointerEvent) {
		if (!input || event.button !== 0 || event.target === input) return;
		if (input.matches(':disabled')) return;
		event.preventDefault();
		input.focus();
	}
</script>

<div
	class={['number-field min-w-0', className]}
	data-size={size}
	data-invalid={invalid ? '' : undefined}
>
	<label
		for={inputId}
		class={[
			'block leading-5 font-medium text-pretty break-words text-(--_ink)',
			size === 'sm' ? 'text-[13px]' : 'text-sm'
		]}
	>
		{label}{#if marker === 'required'}
			<!-- The native required attribute already says so to assistive technology. -->
			<span class="text-[13px] font-normal text-(--_muted)" aria-hidden="true"
				>&ensp;{requiredLabel}</span
			>{:else if marker === 'optional'}
			<span class="text-[13px] font-normal text-(--_muted)">&ensp;{optionalLabel}</span>{/if}
	</label>
	{#if hint}
		<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
			{hint}
		</p>
	{/if}
	<!-- The bounds get a line of their own, directly over the slot they size. -->
	{#if bounds}
		<p
			id={boundsId}
			class="number-field__bounds mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted) tabular-nums"
		>
			{bounds}
		</p>
	{/if}
	{#if unit && !bounds}
		<span id={unitId} class="sr-only">{unit}</span>
	{/if}

	<!-- svelte-ignore a11y_no_static_element_interactions -->
	<div
		class={[
			'number-field__control mt-2 min-h-(--_h) max-w-full items-stretch border',
			'border-(--_edge) bg-(--_surface) text-(--_ink)',
			width === 'fit' ? 'inline-flex' : 'flex',
			size === 'sm' ? 'rounded-md' : 'rounded-lg'
		]}
		style:--_chars={chars}
		onpointerdown={focusInput}
	>
		<input
			bind:this={input}
			autocomplete="off"
			spellcheck={false}
			{...rest}
			value={text}
			type="text"
			{inputmode}
			{required}
			id={inputId}
			aria-describedby={describedBy}
			aria-invalid={invalid ? 'true' : undefined}
			oninput={handleInput}
			onfocus={handleFocus}
			onblur={handleBlur}
			onkeydown={handleKeydown}
			class={[
				'number-field__input min-w-0 bg-transparent text-(--_ink) tabular-nums outline-none',
				'placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
				'pointer-coarse:text-base',
				width === 'fit' ? 'box-content w-[calc(var(--_chars)*1ch)]' : 'flex-1',
				align === 'end' ? 'text-end' : 'text-start',
				size === 'sm' && 'text-[13px] leading-5',
				size === 'md' && 'text-sm leading-5',
				size === 'lg' && 'text-base leading-6',
				'ps-3',
				unit ? 'pe-2' : 'pe-3'
			]}
		/>
		{#if unit}
			<!-- Read with the bounds in the hint, or alone when there are none. -->
			<span
				class={[
					'flex shrink-0 items-center pe-3 whitespace-nowrap text-(--_muted) select-none',
					'pointer-coarse:text-base',
					size === 'sm' && 'text-[13px] leading-5',
					size === 'md' && 'text-sm leading-5',
					size === 'lg' && 'text-base leading-6'
				]}
				aria-hidden="true">{unit}</span
			>
		{/if}
	</div>
	{#if name}
		<input
			type="hidden"
			{name}
			value={accepted === null ? '' : toCanonical(accepted)}
			disabled={rest.disabled}
			form={rest.form}
		/>
	{/if}

	<!-- Feedback: an error, or a clamp note, in a region that can hold its lines in advance. -->
	{#if invalid || note || reserveLines}
		<div class="mt-1" style:min-height={reserveLines ? `${reserveLines * 1.25}rem` : undefined}>
			{@render feedback()}
		</div>
	{/if}
	<span class="sr-only" role="status">{announcement}</span>
</div>

{#snippet feedback()}
	{#if invalid}
		<p
			id={errorId}
			class="flex items-start gap-1 text-[13px] leading-5 text-(--_danger) tabular-nums"
		>
			<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
				<svg class="size-4" 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">{`${errorPrefix} `}</span>{shownError}</span
			>
		</p>
	{:else if note}
		<p
			id={noteId}
			class="text-[13px] leading-5 text-pretty break-words text-(--_muted) tabular-nums"
		>
			{note}
		</p>
	{/if}
{/snippet}

<style>
	/* Public tokens: set --number-field-* on the field or any ancestor to retone it. */
	.number-field {
		--_accent: var(--number-field-accent, #1d4ed8);
		--_danger: var(--number-field-danger, #b91c1c);
		--_ink: var(--number-field-ink, #18181b);
		--_muted: var(--number-field-muted, #52525b);
		--_placeholder: var(--number-field-placeholder, #71717a);
		/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
		--_hairline: var(--number-field-hairline, rgb(0 0 0 / 0.44));
		--_surface: var(--number-field-surface, #ffffff);

		--_h: 2.25rem;
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
	}
	.number-field[data-size='sm'] {
		--_h: 2rem;
	}
	.number-field[data-size='lg'] {
		--_h: 2.5rem;
	}
	@media (pointer: coarse) {
		.number-field[data-size] {
			--_h: 2.75rem;
		}
	}

	/* Invalid: the edge and the focus ring both carry the error colour. */
	.number-field[data-invalid] {
		--_edge: var(--_danger);
		--_ring: var(--_danger);
	}

	/*
	 * The edge, fill and halo change through the rules below, which follow the input inside, so the
	 * transition that animates them lives here with them.
	 */
	.number-field__control {
		transition-property: border-color, box-shadow, background-color;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	@media (prefers-reduced-motion: reduce) {
		.number-field__control {
			transition-duration: 0s;
		}
	}

	/* Hover only where typing is possible: one step darker edge. */
	@media (hover: hover) {
		.number-field__control:hover:not(:has(input:disabled, input[readonly])) {
			--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
		}
		.number-field[data-invalid] .number-field__control:hover {
			--_edge: var(--_danger);
		}
	}

	/* Focus: a two-pixel edge in the accent (one border plus one ring), with a soft halo outside. */
	.number-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);
	}

	/* Read-only: a raised fill, full-strength text, still focusable and selectable. */
	.number-field__control:has(input[readonly]) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}

	/* Disabled: raised fill, faded, and not interactive. */
	.number-field__control:has(input:disabled) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
		opacity: 0.55;
		cursor: not-allowed;
	}

	/* Forced colours drop shadows and repaint borders, so focus becomes a system-colour outline. */
	@media (forced-colors: active) {
		.number-field__control:has(input:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>
```

#### `parse-number.ts`

Role: helper · 7295 bytes · SHA-256 `ae9972a4bd1bfe0d79703bd684623b391b0d4360dc3881d6aa3b1ef9ed6f6639`

```ts
/**
 * Locale-aware number parsing and formatting for text inputs, built on Intl.NumberFormat alone.
 * Self-contained so a currency, percentage or measurement field can copy it as is.
 *
 * Parsing is deliberately forgiving about how a number was typed and strict about what it means:
 * it accepts the locale's own digits and separators, Latin digits, any minus sign and any space,
 * and it rejects anything left over ('12a', '1.2.3', '5 kg').
 */

export interface NumberSymbols {
	/** The locale's decimal separator, such as '.' in en-GB or ',' in de-DE. */
	decimal: string;
	/** The locale's group separator: ',' in en-GB, '.' in de-DE, a narrow space in fr-FR. */
	group: string;
	/** Digits in the last group and in the groups before it: 3 and 3, or 3 and 2 in en-IN. */
	grouping: [primary: number, secondary: number];
	/** Maps each of the locale's own digits (Arabic-Indic, for example) to its Latin digit. */
	digits: Map<string, string>;
}

export type ParseResult =
	{ kind: 'empty' } | { kind: 'number'; value: number } | { kind: 'invalid' };

const symbolCache = new Map<string, NumberSymbols>();

/** The locale's separators and digits, read from Intl.NumberFormat's own output. */
export function numberSymbols(locale: string): NumberSymbols {
	const cached = symbolCache.get(locale);
	if (cached) return cached;
	const format = new Intl.NumberFormat(locale, { useGrouping: true });
	const parts = format.formatToParts(12345.6);
	const digits = new Map<string, string>();
	const nativeDigits = new Intl.NumberFormat(locale, { useGrouping: false }).format(9876543210);
	[...nativeDigits].forEach((d, i) => digits.set(d, String(9 - i)));
	const groups = format
		.formatToParts(1234567890)
		.filter((p) => p.type === 'integer')
		.map((p) => p.value.length);
	const primary = groups.at(-1) ?? 3;
	const symbols: NumberSymbols = {
		decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
		group: parts.find((p) => p.type === 'group')?.value ?? ',',
		grouping: [primary, groups.length > 2 ? groups.at(-2)! : primary],
		digits
	};
	symbolCache.set(locale, symbols);
	return symbols;
}

/** Spaces of every width: NBSP, narrow NBSP (fr-FR's group separator), figure and thin spaces. */
const SPACES = /[\s\u00a0\u2007\u2009\u202f]/gu;
/** Bidi marks and other invisible format characters that Intl puts around Arabic numbers. */
const FORMAT_CHARS = /\p{Cf}/gu;
/** Hyphen-minus, the minus sign, and the dashes people paste in their place. */
const MINUS = /^[-\u2212\u2012\u2013\ufe63\uff0d]/u;
/** Arabic-Indic and extended Arabic-Indic digits, accepted in every locale. */
const ARABIC_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/gu;

function latinDigits(text: string, symbols: NumberSymbols): string {
	let out = '';
	for (const ch of text) out += symbols.digits.get(ch) ?? ch;
	return out.replace(ARABIC_DIGITS, (d) => String((d.codePointAt(0)! - 0x0660) % 0x90));
}

/** Whether chunks split at the group separator follow the locale's grouping, as 12,34,567 does in en-IN. */
function grouped(chunks: string[], [primary, secondary]: [number, number]): boolean {
	if (chunks.length < 2) return true;
	const [first, ...rest] = chunks;
	const last = rest.pop()!;
	const lead = rest.length ? secondary : primary;
	return (
		new RegExp(`^\\d{1,${lead}}$`).test(first) &&
		new RegExp(`^\\d{${primary}}$`).test(last) &&
		rest.every((c) => new RegExp(`^\\d{${secondary}}$`).test(c))
	);
}

/**
 * Parse what a person typed. '1.234,5' in de-DE is 1234.5, '1,234.5' in en-GB is 1234.5.
 *
 * Group separators must sit where the locale puts them, so '1,23.4' is rejected rather than
 * read as 123.4. Without a decimal separator, a single group separator that does not group is
 * read as a decimal point, because phone keyboards often offer only the separator of the
 * device's region ('2.5' typed into a de-DE field means 2,5). The other of '.' and ',' is
 * accepted as a decimal point too, unless the locale groups with it.
 */
export function parseNumber(text: string, locale: string): ParseResult {
	const symbols = numberSymbols(locale);
	let s = latinDigits(text.replace(FORMAT_CHARS, ''), symbols).trim();
	if (s === '') return { kind: 'empty' };

	let sign = '';
	if (MINUS.test(s)) {
		sign = '-';
		s = s.slice(1);
	} else if (s.startsWith('+')) {
		s = s.slice(1);
	}

	/* Spaces group digits in many locales and are harmless in the rest, so they all go. */
	const groupIsSpace = symbols.group.replace(SPACES, '') === '';
	s = s.replace(SPACES, '');

	const group = groupIsSpace ? null : symbols.group;
	const alternate = symbols.decimal === ',' ? '.' : ',';
	const decimals = [symbols.decimal, ...(alternate !== group ? [alternate] : [])];

	let whole = s;
	let fraction = '';
	const decimalAt = decimals.map((d) => s.indexOf(d)).find((i) => i >= 0);
	if (decimalAt !== undefined) {
		whole = s.slice(0, decimalAt);
		fraction = s.slice(decimalAt + 1);
	}

	if (group && whole.includes(group)) {
		const chunks = whole.split(group);
		if (grouped(chunks, symbols.grouping)) {
			whole = chunks.join('');
		} else if (decimalAt === undefined && chunks.length === 2) {
			[whole, fraction] = chunks;
		} else {
			return { kind: 'invalid' };
		}
	}

	if (!/^\d*$/.test(whole) || !/^\d*$/.test(fraction)) return { kind: 'invalid' };
	if (whole === '' && fraction === '') return { kind: 'invalid' };

	const value = Number(`${sign}${whole || '0'}.${fraction || '0'}`);
	return Number.isFinite(value)
		? { kind: 'number', value: value === 0 ? 0 : value }
		: { kind: 'invalid' };
}

/** Digits after the point in a step such as 0.25, used to round away floating-point noise. */
export function decimalsOf(n: number): number {
	if (Number.isInteger(n)) return 0;
	const [, fraction = '', exponent] = /^-?\d*\.?(\d*)(?:e-(\d+))?$/i.exec(String(n)) ?? [];
	return fraction.length + Number(exponent ?? 0);
}

/** Round to a number of decimals without 0.1 + 0.2 drift. */
export function roundTo(n: number, decimals: number): number {
	const factor = 10 ** decimals;
	return Math.round(n * factor) / factor;
}

/** Whether the value sits on the step grid that starts at base (min, or 0). */
export function onStep(value: number, step: number, base: number): boolean {
	const steps = (value - base) / step;
	/* Tolerance scaled to the quotient: absorbs 0.3 / 0.1 = 2.9999999999999996, nothing more. */
	return Math.abs(steps - Math.round(steps)) <= 1e-10 * Math.max(1, Math.abs(steps));
}

/** The nearest value on the step grid, preferring the larger one on a tie. */
export function snapToStep(value: number, step: number, base: number): number {
	const places = Math.max(decimalsOf(step), decimalsOf(base));
	return roundTo(base + Math.round((value - base) / step) * step, places);
}

/** The text a person edits: the locale's decimal separator, no grouping, every digit kept. */
export function formatEditable(value: number, locale: string): string {
	return new Intl.NumberFormat(locale, { useGrouping: false, maximumFractionDigits: 20 }).format(
		value
	);
}

/** The value a form submits: dot decimal, ASCII minus, no grouping, no exponent. */
export function toCanonical(value: number): string {
	return new Intl.NumberFormat('en-US', {
		useGrouping: false,
		maximumFractionDigits: 20,
		numberingSystem: 'latn'
	})
		.format(value)
		.replace('−', '-');
}
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-92e056a5b3fbe868719acc9aeffa43a203dbd18ad1e8c2a90d3b7bd3a4b54198`
- Entry: `NumberField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_number_field_01/1.0.0/blue/sha256-92e056a5b3fbe868719acc9aeffa43a203dbd18ad1e8c2a90d3b7bd3a4b54198/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_number_field_01/1.0.0/blue/sha256-92e056a5b3fbe868719acc9aeffa43a203dbd18ad1e8c2a90d3b7bd3a4b54198/bundle.zip (16168 bytes, sha256 `38454d9dc5a0a4f2e1427982230b9fb4d334b5e29554f623d546d68e0f98c50f`)

Files:

- `NumberField.svelte` (entry, 24016 bytes): https://pagesugar.com/artifacts/cmp_number_field_01/1.0.0/blue/sha256-92e056a5b3fbe868719acc9aeffa43a203dbd18ad1e8c2a90d3b7bd3a4b54198/source/NumberField.svelte
- `parse-number.ts` (helper, 7295 bytes): https://pagesugar.com/artifacts/cmp_number_field_01/1.0.0/blue/sha256-92e056a5b3fbe868719acc9aeffa43a203dbd18ad1e8c2a90d3b7bd3a4b54198/source/parse-number.ts
