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.
cmp_number_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_number_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-17d0997350e793bf0cc1d5dc0ce3938b0f54017fbd4df57c81f1123b749353fd
This component needs all 2 files. Download the ZIP
<!--
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"
> {requiredLabel}</span
>{:else if marker === 'optional'}
<span class="text-[13px] font-normal text-(--_muted)"> {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, #18181b);
--_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>
Usage#
On this pageGive 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.
- Suggested location
src/lib/components/number-field-01- Required props
label
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.
formatOptionsis 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.
reserveErrorholds 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.
Example
<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>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.
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.
<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:
<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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | 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 | None | 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 | None | 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 | None | 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 | None | Lowest accepted value. Also the start of the step grid. |
max | number | No | None | 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 | None | '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 | None | 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 | None | 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 | None | 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 | None | 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 | None | Called on blur when the text cannot be accepted. Not called when clamp fixes the value. |
onfocus | FocusEventHandler<HTMLInputElement> | No | None | Runs before the field swaps in its editable text. |
onblur | FocusEventHandler<HTMLInputElement> | No | None | Runs after the field has checked and formatted its text. |
oninput | FormEventHandler<HTMLInputElement> | No | None | Runs on every keystroke, before the field parses the text. |
onkeydown | KeyboardEventHandler<HTMLInputElement> | No | None | Runs before the field's own arrow-key stepping; call preventDefault to stop it. |
id | string | No | None | Input id. Defaults to one generated per instance; the hint and error always take generated ids. |
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 --number-field-* CSS variables. Hover and the read-only fill are mixed from those, so they follow any retone.
- Accent:
--number-field-accentcolours the focus edge and its halo. Keep it at 3:1 against the page. - Danger:
--number-field-dangercolours the error edge, the error focus ring, the icon and the message. The default isred-700(6.5:1 on white). - Neutrals:
--number-field-inkis the label and value,--number-field-mutedthe hint, bounds, unit and clamp note,--number-field-placeholderthe placeholder,--number-field-hairlinethe resting edge (keep it at 3:1 against the page) and--number-field-surfacethe fill. - Worked retone for a
#09090bband:--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,atLeastandatMost; put the unit where your language wants it with{unit}. - Another language: pass locale and translate messages,
requiredLabel,optionalLabelanderrorPrefix. 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).
Public CSS variables
| Variable | Token |
|---|---|
--number-field-accent | accent |
--number-field-danger | danger |
--number-field-ink | ink |
--number-field-muted | muted |
--number-field-placeholder | placeholder |
--number-field-hairline | hairline |
--number-field-surface | surface |
Accessibility#
On this page- The input is
type=text, nottype=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-describedbylists the hint, then the bounds, then the error or clamp note, then anyaria-describedbyyou pass.aria-invalidis 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.
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.