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
/**
* 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('−', '-');
}
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.