Theme
On this page (9)
Component Skill — components/number-input/component-skill.md

Native basis

<input type="number"> with custom increment/decrement buttons.

Web Platform APIs

<input type="number">

Classes

.number-input.number-input-unit

Data attributes

• data-action - decrement / increment on the stepper buttons

• data-decimals - fixed fraction digits on the wrapper (19 → 19.0)

• data-currency / data-locale / data-currency-display - locale-aware money mask (Intl.NumberFormat)

• data-number-output - hidden field receiving the machine value (1234.5)

Notes

• A unit is a <label class="number-input-unit" for> before or after the input - it joins the accessible name.

• Native spinner buttons are hidden with ::-webkit-inner-spin-button.

• Use min, max, and step for range constraints.

• Keyboard: arrow keys increment/decrement by step value.

§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), and getState().config.value reports 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:

StateTypeValuesDefaultDescription
valuenumber—3Current 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