Skip to content
SproutAceve · v3.13.0
Get started
Overview What is your role? Getting started Install Adopting Sprout
Foundations
Brand & logos Color Typography Spacing & layout Elevation Iconography Accessibility
Components
Overview Buttons Inputs & forms Numeric input Data grid Ribbon toolbar Navigation Cards Dialog Tabs Context menu
Practice
Interaction & motion Voice & content Product patterns AI patterns
People & ethics
Design principles Personas UX ethics Trust & direction Meet the makers UX Strategi
Playground
Build the systemplay Tower of tokensplay
Behind the system
How it works Why it pays Changelogv3.13 Roadmap Contribute
Components

Numeric input.

Beta — usable, but the API may still change.

One field for quantities, cost, markup and VAT — built on a text input, never <input type="number">. Integer or decimal mode, fixed precision, comma and period both accepted, formatted to the user's locale on blur. No spinner arrows, no scroll-wheel surprises.

The rules

Every numeric field in an Aceve product follows these six. They exist because the native number input breaks all of them.

1 — Never type="number"Use <input type="text"> with inputMode. The native number input steps in broken decimals, ignores locale and edits on scroll.
2 — Integer or decimal, explicitlyA pcs field is mode="integer" and physically cannot hold 1,8 — the separator key does nothing. Money and percentages are mode="decimal".
3 — Accept both , and .Swedish and Norwegian users type 1,8; a pasted spreadsheet value may carry 1.8. Both parse. Formatting normalises on blur.
4 — Format on blur, never mid-typingWhile the user types, the field holds exactly what they typed. On blur it parses, clamps to min/max and formats to precision — 1,8 becomes 1,80.
5 — No spinners, no scroll-to-changeValue changes come from the keyboard only. Nothing increments on hover-scroll; nothing steps by 0,30000000000000004.
6 — Right-align, tabular numeralsNumbers align right with fontVariantNumeric: tabular-nums, so columns of costs stay scannable — same convention as the data grid.

Try it

Type 1,8 in the quantity field — the comma never lands. Type 1,8 in unit cost and blur — it formats to 1,80.

pcs
Integer mode — 1,8 is impossible to type.
kr
Decimal, precision 2 — formats on blur.
%
%
Clamped 0–100 on blur.

Parsing rules

What goes in, what comes out. The parsed number (onValueChange) is what your data model binds to — never the display string.

User doesModeResult
Types 1,8 then blursdecimal · 2Field shows 1,80 — parsed value 1.8
Types 1.8 then blursdecimal · 2Same: 1,80 (sv-SE) — both separators parse
Types 1,8integerThe comma never enters the field — value stays 18
Types abc or 1,2,3anyRejected keystroke by keystroke — the field never holds it
Types 150 in a 0–100 field, blursdecimal · 2Clamped to 100,00
Clears the field, blursanyStays empty — parsed value is null, never silently 0
Scrolls over the fieldanyNothing. The page scrolls; the value does not move

Do & don't

pcs
Do — text input with inputMode. Right-aligned, unit visible, integers enforced.
Don't — native number input. It holds 1.8 pcs, rejects 1,8, and scroll-wheel edits it silently.

Spec

Same anatomy as the text input — one numeric addition per row.

Height32px
Horizontal padding10px — 34px right when a suffix is set
AlignmentRight, fontVariantNumeric: tabular-nums
Suffix12px, var(--ink-4), inside the field, not focusable
Border1px solid var(--line-strong) — focus var(--accent), error var(--danger)
Radius4px
Mobile keyboardinputMode: numeric (integer) / decimal (decimal)
Helper / error12px caption directly below

Props (React)

type NumericInputProps = {
  mode?: "integer" | "decimal";   // default "decimal"
  precision?: number;             // fraction digits on blur, default 2
  min?: number;                   // clamped on blur
  max?: number;                   // clamped on blur
  locale?: string;                // blur format, default: browser locale
  suffix?: string;                // unit inside the field — "kr", "%", "pcs"
  value: string;                  // display string (controlled)
  onChange?: (next: string) => void;
  onValueChange?: (n: number | null) => void; // parsed value, on blur
  label?: string;
  placeholder?: string;
  help?: string;
  error?: string;
  disabled?: boolean;
};

Copy the implementation

Self-contained reference — parseNumeric and formatNumeric included. Drop it into your product and restyle; the behaviour is the contract.

import { useId } from 'react';

// While typing: digits, optional leading minus, and (decimal mode
// only) one `,` or `.` separator. Anything else never enters the field.
const INT_RE = /^-?\d*$/;
const DEC_RE = /^-?\d*[.,]?\d*$/;

/** '1,8' | '1.8' → 1.8 — empty / partial / garbage → null. */
export function parseNumeric(raw) {
  const s = String(raw ?? '').trim().replace(',', '.');
  if (s === '' || s === '-' || s === '.' || s === '-.') return null;
  const n = Number(s);
  return Number.isFinite(n) ? n : null;
}

/** 1.8 → '1,80' (sv-SE, precision 2) / '1.80' (en-GB). null → ''. */
export function formatNumeric(n, { mode = 'decimal', precision = 2, locale } = {}) {
  if (n == null) return '';
  const digits = mode === 'integer' ? 0 : precision;
  return new Intl.NumberFormat(locale, {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
    useGrouping: false,
  }).format(n);
}

export default function NumericInput({
  label, value, onChange, onValueChange,
  mode = 'decimal', precision = 2, min, max, locale,
  suffix, placeholder, help, error, disabled, id,
}) {
  const reactId = useId();
  const inputId = id || reactId;
  const describedById = error ? `${inputId}-err` : help ? `${inputId}-help` : undefined;
  const allowMinus = min === undefined || min < 0;

  const handleChange = (raw) => {
    const re = mode === 'integer' ? INT_RE : DEC_RE;
    if (!re.test(raw)) return; // reject the keystroke
    if (!allowMinus && raw.startsWith('-')) return;
    onChange?.(raw);
  };

  const handleBlur = () => {
    let n = parseNumeric(value);
    if (n != null) {
      if (min !== undefined) n = Math.max(min, n);
      if (max !== undefined) n = Math.min(max, n);
      if (mode === 'integer') n = Math.round(n);
    }
    onChange?.(formatNumeric(n, { mode, precision, locale }));
    onValueChange?.(n);
  };

  return (
    <div className="field">
      {label && <label htmlFor={inputId}>{label}</label>}
      <div className="numeric-wrap">
        <input
          id={inputId}
          type="text"
          inputMode={mode === 'integer' ? 'numeric' : 'decimal'}
          value={value || ''}
          onChange={(e) => handleChange(e.target.value)}
          onBlur={handleBlur}
          placeholder={placeholder}
          disabled={disabled}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedById}
        />
        {suffix && <span className="suffix" aria-hidden>{suffix}</span>}
      </div>
      {error && <div id={`${inputId}-err`} className="error">{error}</div>}
      {help && !error && <div id={`${inputId}-help`} className="help">{help}</div>}
    </div>
  );
}

Using Tailwind? Same logic, utility classes instead of the CSS:

{/* Same logic — Tailwind classes instead of the .field CSS.
   parseNumeric / formatNumeric / handleChange / handleBlur are
   identical to the React snippet above. */}
<div className="flex min-w-60 flex-col gap-1">
  <label htmlFor={inputId} className="text-xs text-neutral-600">
    {label}
  </label>
  <div className="relative flex">
    <input
      id={inputId}
      type="text"
      inputMode={mode === 'integer' ? 'numeric' : 'decimal'}
      value={value || ''}
      onChange={(e) => handleChange(e.target.value)}
      onBlur={handleBlur}
      aria-invalid={error ? true : undefined}
      className={`h-8 flex-1 rounded border bg-white px-2.5 text-right
        text-sm tabular-nums outline-none
        focus:border-[#447952] focus:ring-1 focus:ring-[#447952]
        disabled:opacity-50
        ${suffix ? 'pr-9' : ''}
        ${error ? 'border-red-600' : 'border-neutral-300'}`}
    />
    {suffix && (
      <span aria-hidden className="pointer-events-none absolute inset-y-0
        right-2.5 flex items-center text-xs text-neutral-400">
        {suffix}
      </span>
    )}
  </div>
  {error && <div className="text-xs text-red-600">{error}</div>}
  {help && !error && <div className="text-xs text-neutral-400">{help}</div>}
</div>
← PreviousInputs & formsNext →Data grid
Sprout · Aceve Design System · v3.13.0
Figma library GitLabReleases