Skip to content
SproutAceve · v3.22.0
Get started
Overview What is your role? Connect to Sprout 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 Alert Avatar Badge Dialog Tabs Floating tabs Menu
Practice
Interaction & motion Voice & content Product patterns
Artificial Intelligence
AI patterns AI transparency & disclosure AI disclosure label
People & ethics
Design principles Personas Trust and ethics Meet the makers
Playground
Build the systemplay Tower of tokensplay
Behind the system
How it works Changelogv3.22 Roadmap Contribute
Draft
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 →AI disclosure label
Sprout · Aceve Design System · v3.22.0
Figma library GitLabReleases