Number InputATM
A numeric input with increment/decrement buttons. Built on <input type="number"> with custom stepper controls.
On this page (9)
§Default
Number input with +/- buttons.
§Unit and fixed decimals
A temperature wants both: data-decimals='1' keeps one fraction digit (19 shows as 19.0, a step from 19.5 lands on 20.0) and a .number-input-unit label after the input shows °C. The unit is a second label for the input - clicking it focuses the field and screen readers announce 'Temperature °C'.
§Units
Any unit, before or after the value: % for a discount, kg with one decimal, hours in quarter steps, € in front with cents - even an emoji. The value aligns toward its unit so the pair reads as one, and the unit is part of the text (it is copied along with the number).
§Currency
data-currency turns the field into a masked money input driven by Intl.NumberFormat: the locale (data-locale, else the page's lang) decides the decimal and group separators, the currency symbol and its side, and the minor unit (EUR 2 digits, JPY none). Typing is masked live - letters are dropped, groups appear as you type, the caret stays put - and blur pads the cents. An input[data-number-output] submits the plain machine value (1234.5).
§Currency per locale
Change the locale and the SAME amount re-renders: separators, symbol, symbol side and grouping all come from the platform (Intl.NumberFormat) - including Indian lakh grouping (12,34,567.80) and Swiss apostrophes. Set data-locale / data-currency at any time; data-currency-display switches between symbol, narrowSymbol, code and name.
§Sizes
The stepper follows the shared input ladder via data-size - md is 2.25rem like every other form control.
§States
Named states via the shared State API, driven per instance through the bound api:
default- enabled;setState('default', { value })presets the number through the native input (events fire), andgetState().config.valuereports the live value
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/number-input-{state}.png.
Machine contract - verified against number-input.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | number | — | 3 | Current value - step buttons, typing, and the panel all drive the native input. |
§CSS view file
/* -- Number Input component ------------------------------------- */@layer components { .number-input { display: inline-flex; align-items: center; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); box-shadow: var(--shadow-xs); overflow: hidden; /* explicit border-box: the size ladder below sets an explicit container height and must land on it exactly (a div is content-box by default; inputs get border-box from the UA). The unsized default keeps its auto height (children drive it), so this changes nothing today. */ box-sizing: border-box; /* explicit default box height (the size ladder below does the rest): an auto-height box let the UA-padding children overshoot to 44px. The unsized default IS the ladder's md step - field standard, see .input */ height: 2.25rem; & input { border: none; outline: none; background: transparent; height: 100%; /* 4rem basis, but GROWS when the container is widened (w-full demos, grid cells): a fixed width left dead space after the + button. width pins the intrinsic size too - Chrome otherwise derives a number input's width from the digit count of max (no max: ~170px, max="100": 34px), so the field width followed a validation attr. */ flex: 1 1 4rem; width: 4rem; min-width: 0; text-align: center; font-size: 0.875rem; font-family: var(--font-sans); color: var(--foreground); -moz-appearance: textfield; &::-webkit-inner-spin-button, &::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } } & button { display: flex; align-items: center; justify-content: center; width: 2rem; /* height:100% so the button can't overshoot the container's border-box height (the ladder below already sets children to 100%) */ height: 100%; border: none; background: transparent; color: var(--muted-foreground); cursor: pointer; font-size: 1rem; font-family: var(--font-sans); transition: background 150ms, color 150ms; flex-shrink: 0; &:hover { background: var(--accent); color: var(--accent-foreground); } &:first-child { border-right: 1px solid var(--input); } &:last-child { border-left: 1px solid var(--input); } } /* -- Unit affix: <label class="number-input-unit" for="…"> ----- A unit (°C, %, kg, €, h) sits before or after the input. It is a second <label> for the input: clicking it focuses the field and the unit joins the accessible name ("Temperature °C"). The value aligns TOWARD the unit so number + unit read as one ("19.0 °C", "€ 24.90"). */ & .number-input-unit { display: flex; align-items: center; align-self: stretch; flex-shrink: 0; padding-inline: 0.25rem; color: var(--muted-foreground); font-size: 0.875rem; font-family: var(--font-sans); white-space: nowrap; cursor: text; /* edge of the box (no stepper on that side): field padding */ &:first-child { padding-inline-start: 0.75rem; } &:last-child { padding-inline-end: 0.75rem; } } /* NOT user-select: none - the unit is part of the value when text is selected, copied or exported ("72,5 kg", not a bare "72,5") */ &:has(input:not([type="hidden"]) + .number-input-unit) input { text-align: end; } &:has(.number-input-unit + input:not([type="hidden"])) input { text-align: start; } /* currency fields carry grouped amounts ("1.234.567,89") */ &[data-currency] input { width: 8rem; } /* stepper-less field (currency, measurements): the input reaches the box edge - give it the regular field padding there */ & input:first-child { padding-inline-start: 0.75rem; } & input:last-child { padding-inline-end: 0.75rem; } &:focus-within { border-color: var(--ring); box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2); } /* -- Sizes ------------------------------------------------- Container height + input/button/font scale together, matching the input/button ladders (md = 2.25rem; the unsized default stays at the historical 2.5rem, exactly like .input does). Children switch to height:100% so they can't fight the container. */ &[data-size="xs"] { height: 1.75rem; & input { height: 100%; width: 3rem; font-size: 0.75rem; } & button { height: 100%; width: 1.5rem; font-size: 0.875rem; } & .number-input-unit { font-size: 0.75rem; } } &[data-size="sm"] { height: 2rem; & input { height: 100%; width: 3.5rem; font-size: 0.8125rem; } & button { height: 100%; width: 1.75rem; font-size: 0.9375rem; } & .number-input-unit { font-size: 0.8125rem; } } &[data-size="md"] { height: 2.25rem; & input { height: 100%; font-size: 0.875rem; } & button { height: 100%; } } &[data-size="lg"] { height: 2.75rem; & input { height: 100%; width: 4.5rem; font-size: 1rem; } & button { height: 100%; width: 2.25rem; font-size: 1.125rem; } & .number-input-unit { font-size: 1rem; } } &[data-size="xl"] { height: 3.25rem; & input { height: 100%; width: 5rem; font-size: 1.125rem; } & button { height: 100%; width: 2.5rem; font-size: 1.25rem; } & .number-input-unit { font-size: 1.125rem; } } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .number-input, .number-input *, .number-input::before, .number-input::after, .number-input *::before, .number-input *::after, .number-input::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}§JavaScript view file
Number Input
// -- Number Input ---------------------------------------------// Increment/decrement buttons for .number-input containers, plus the// named-state API (AGENTS.md "State API"). The component's only observable// state is the number itself, so 'default' carries an optional { value }// preset and getState().config.value reports the live value.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const numberInputStates = ['default'];// the editable field: <input type="number">, or the text field of a// currency-masked wrapper (hidden outputs are never the field)const getInput = (wrapper) => wrapper.querySelector('input:not([type="hidden"])');// -- Currency mask (data-currency on the wrapper) -----------------------------// A native number input cannot show grouping, a locale's decimal comma or a// currency symbol, so a currency field is <input type="text" inputmode=// "decimal"> masked through Intl.NumberFormat: the LOCALE decides decimal and// group separators, the symbol, its side and the fraction digits (EUR 2,// JPY 0, …). data-locale picks it (else the nearest [lang], else the// browser's); data-currency-display = symbol | narrowSymbol | code | name./** Everything the mask needs to know about a (currency, locale) pair. */function currencyConfig(wrapper) { const currency = String(wrapper.dataset.currency || 'USD').toUpperCase(); const locale = wrapper.dataset.locale || wrapper.closest('[lang]')?.getAttribute('lang') || navigator.language; const currencyDisplay = wrapper.dataset.currencyDisplay || 'symbol'; const money = new Intl.NumberFormat(locale, { style: 'currency', currency, currencyDisplay }); const parts = money.formatToParts(1234567.5); const index = (type) => parts.findIndex((p) => p.type === type); const fraction = money.resolvedOptions().maximumFractionDigits ?? 2; return { locale, currency, fraction, decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.', symbol: parts.find((p) => p.type === 'currency')?.value ?? currency, prefix: index('currency') < index('integer'), grouping: new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }), fixed: new Intl.NumberFormat(locale, { minimumFractionDigits: fraction, maximumFractionDigits: fraction }), };}/** * Split typed text into integer + fraction digits. The locale's decimal * separator marks the fraction; the OTHER of "," / "." counts as decimal too * when it is followed by no more digits than the currency allows - so a * numpad "." in de-DE ("12." → "12,") works, while "1.234" (a group) stays * an integer. Everything else that isn't a digit is dropped. */function parseMoney(text, cfg) { let dec = text.lastIndexOf(cfg.decimal); if (dec < 0 && cfg.fraction > 0) { const alt = cfg.decimal === ',' ? '.' : ','; const i = text.lastIndexOf(alt); // only digits after it, and no more than the currency's fraction digits if (i >= 0 && /^\d*$/.test(text.slice(i + 1)) && text.length - i - 1 <= cfg.fraction) dec = i; } if (cfg.fraction === 0) dec = -1; const int = (dec < 0 ? text : text.slice(0, dec)).replace(/\D/g, '').replace(/^0+(?=\d)/, ''); const frac = dec < 0 ? null : text.slice(dec + 1).replace(/\D/g, '').slice(0, cfg.fraction); return { int, frac };}/** Locale text for integer + fraction digits (grouped; BigInt: no float loss). */const moneyText = (int, frac, cfg) => (int ? cfg.grouping.format(BigInt(int)) : frac !== null ? '0' : '') + (frac !== null ? cfg.decimal + frac : '');/** The machine value for forms / getState, normalised - "1234.5" whether the * field shows 1.234,5 or 1.234,50; '' when empty. */const moneyValue = ({ int, frac }) => { if (!int && !frac) return ''; const f = (frac ?? '').replace(/0+$/, ''); return `${int || '0'}${f ? '.' + f : ''}`;};/** * Re-mask the field after typing, keeping the caret after the same number of * digits it followed before (grouping characters come and go under it). */function maskMoney(wrapper, input, cfg) { const raw = input.value; const caret = input.selectionStart ?? raw.length; const digitsBefore = raw.slice(0, caret).replace(/\D/g, '').length; const parsed = parseMoney(raw, cfg); const text = moneyText(parsed.int, parsed.frac, cfg); if (text !== raw) { input.value = text; let pos = 0; for (let seen = 0; pos < text.length && seen < digitsBefore; pos++) if (/\d/.test(text[pos])) seen++; // typed the decimal separator right here: land after it if (text[pos] === cfg.decimal && raw.slice(0, caret).match(/[.,]$/)) pos++; input.setSelectionRange(pos, pos); } wrapper._moneyExact = moneyValue(parsed); writeMoneyOutput(wrapper, wrapper._moneyExact);}/** Commit (blur / Enter / step / preset): full fraction digits, "0" for empty int. */function commitMoney(wrapper, input, cfg, number = null, remember = true) { const value = number ?? moneyValue(parseMoney(input.value, cfg)); if (remember) wrapper._moneyExact = value === '' || !Number.isFinite(Number(value)) ? '' : String(Number(value)); if (value === '' || !Number.isFinite(Number(value))) { input.value = ''; writeMoneyOutput(wrapper, ''); return; } input.value = cfg.fixed.format(Number(value)); writeMoneyOutput(wrapper, moneyValue(parseMoney(input.value, cfg)));}/** Mirror the machine value into input[data-number-output] (+ change) and the wrapper. */function writeMoneyOutput(wrapper, value) { wrapper.dataset.value = value; wrapper.querySelectorAll('input[data-number-output]').forEach((out) => { if (out.value === value) return; out.value = value; out.dispatchEvent(new Event('change', { bubbles: true })); });}/** Show the locale's symbol on the locale's side (a unit label is created if missing). */function placeCurrencySymbol(wrapper, input, cfg) { let unit = wrapper.querySelector('.number-input-unit'); if (!unit) { unit = document.createElement('label'); unit.className = 'number-input-unit'; if (input.id) unit.htmlFor = input.id; } unit.textContent = cfg.symbol; if (cfg.prefix && unit.nextElementSibling !== input) input.before(unit); if (!cfg.prefix && input.nextElementSibling !== unit) input.after(unit);}/** Set up (or re-set after data-locale / data-currency changes) a currency field. */function setupCurrency(wrapper, input) { const cfg = currencyConfig(wrapper); wrapper._money = cfg; placeCurrencySymbol(wrapper, input, cfg); input.setAttribute('inputmode', cfg.fraction > 0 ? 'decimal' : 'numeric'); // re-setup (locale / currency switch): render the remembered EXACT amount; // first setup: the authored value - a machine number ("1234.5") or // already-localised text ("1.234,50", e.g. a re-serialised field) if (wrapper._moneyExact !== undefined) { commitMoney(wrapper, input, cfg, wrapper._moneyExact, false); return; } const authored = (input.getAttribute('value') ?? '').trim(); const machine = /^\d+(\.\d+)?$/.test(authored) ? authored : moneyValue(parseMoney(authored, cfg)); commitMoney(wrapper, input, cfg, machine === '' ? '' : machine);}/** * Fixed decimals (data-decimals="N" on the wrapper): the value is shown with * exactly N fraction digits - 19 → "19.0", a step from 19.5 → "20.0" (native * stepUp() would print "20"). Applied on init, after every step, on commit * (change = blur/Enter - never mid-typing) and on setState presets. The * input's value stays a plain number string, so forms submit it unchanged. */function formatDecimals(wrapper, input) { const d = parseInt(wrapper.dataset.decimals ?? '', 10); if (!Number.isFinite(d) || d < 0 || input.value === '') return; const n = input.valueAsNumber; if (!Number.isFinite(n)) return; const fixed = n.toFixed(d); if (input.value !== fixed) input.value = fixed;}/** * UI side of setState: 'default' optionally presets { value } through the * native input (events dispatched so listeners see the change). */function triggerStateChange(wrapper, config) { const input = getInput(wrapper); if (!input || config?.value === undefined) return; if (wrapper._money) { // currency: { value } is the machine number (1234.5), rendered per locale commitMoney(wrapper, input, wrapper._money, config.value === '' ? '' : String(config.value)); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); return; } input.value = String(config.value); formatDecimals(wrapper, input); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true }));}/** Registry-level API; pass the wrapper explicitly. Unknown names throw. */export const numberInputApi = { setState(wrapper, stateName, config = {}) { if (!numberInputStates.includes(stateName)) { throw new Error(`number-input: unknown state "${stateName}" (supported: ${numberInputStates.join(', ')})`); } triggerStateChange(wrapper, config); // state lives on the ELEMENT, not the module (many inputs per page) wrapper.dataset.stateName = stateName; wrapper._stateConfig = config; }, getState(wrapper) { const input = getInput(wrapper); return { name: wrapper.dataset.stateName || 'default', // live value - reflects stepper clicks and typing, not just setState // currency fields add the machine value + currency/locale; value stays // what the field shows config: { ...wrapper._stateConfig, value: input ? input.value : '', ...(wrapper._money ? { number: wrapper.dataset.value ?? '', currency: wrapper._money.currency, locale: wrapper._money.locale } : {}), }, }; },};df$.numberInputApi = numberInputApi;df$.numberInputStates = numberInputStates;function init() { document.querySelectorAll('.number-input:not([data-init])').forEach((wrapper) => { wrapper.dataset.init = ''; // bind-scope the api per instance: `$('#qty').api.setState('default', { value: 5 })` wrapper.api = { setState: (stateName, config) => numberInputApi.setState(wrapper, stateName, config), getState: () => numberInputApi.getState(wrapper), }; const input = getInput(wrapper); const decBtn = wrapper.querySelector('[data-action="decrement"]'); const incBtn = wrapper.querySelector('[data-action="increment"]'); if (!input) return; if (wrapper.hasAttribute('data-currency')) { setupCurrency(wrapper, input); input.addEventListener('input', (e) => { if ((e as InputEvent).isComposing) return; maskMoney(wrapper, input, wrapper._money); }); input.addEventListener('blur', () => { commitMoney(wrapper, input, wrapper._money); }); // steppers + ArrowUp/Down nudge by data-step (default 1), clamped to data-min / data-max const nudge = (direction) => { const step = Number(input.dataset.step || 1); const min = input.dataset.min === undefined ? -Infinity : Number(input.dataset.min); const max = input.dataset.max === undefined ? Infinity : Number(input.dataset.max); const current = Number(moneyValue(parseMoney(input.value, wrapper._money)) || 0); const next = Math.min(max, Math.max(min, Math.round((current + direction * step) * 1e6) / 1e6)); commitMoney(wrapper, input, wrapper._money, String(next)); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); }; input.addEventListener('keydown', (e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); nudge(e.key === 'ArrowUp' ? 1 : -1); } }); if (decBtn) decBtn.addEventListener('click', () => { nudge(-1); }); if (incBtn) incBtn.addEventListener('click', () => { nudge(1); }); // switching locale / currency / display at runtime re-renders the same amount new MutationObserver(() => { setupCurrency(wrapper, input); }).observe(wrapper, { attributes: true, attributeFilter: ['data-locale', 'data-currency', 'data-currency-display'] }); return; } formatDecimals(wrapper, input); // commit (blur / Enter) re-applies the fixed decimals to typed values input.addEventListener('change', () => { formatDecimals(wrapper, input); }); const update = (direction) => { try { if (direction > 0) input.stepUp(); else input.stepDown(); formatDecimals(wrapper, input); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } catch { /* min/max boundary */ } }; if (decBtn) decBtn.addEventListener('click', () => { update(-1); }); if (incBtn) incBtn.addEventListener('click', () => { update(1); });});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub