Lunia DS

Base components

Number input

A number field with minus and plus buttons or an up and down stepper, where the buttons give a small press and the value changes at once so holding a button never waits for motion.

Loading preview

Two fields at size md: "Seats" (horizontal, min 1, max 50, hint "Enter a whole number.") and "Budget" (vertical, US dollar format, step 100). Replay presses plus on Seats three times.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add number-input

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { NumberInput } from './number-input';

const USD: Intl.NumberFormatOptions = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 };

export default function NumberInputDemo({ replay = 0 }: { replay?: number }) {
  const [seats, setSeats] = React.useState<number | null>(2);

  React.useEffect(() => {
    if (replay === 0) return;
    setSeats(2);
    const timers = [1, 2, 3].map((press) => window.setTimeout(() => setSeats(2 + press), 300 * press));
    return () => {
      for (const timer of timers) window.clearTimeout(timer);
    };
  }, [replay]);

  return (
    <div className="flex w-full max-w-xs flex-col gap-6">
      <NumberInput
        label="Seats"
        min={1}
        max={50}
        value={seats}
        onValueChange={setSeats}
        hint="Enter a whole number."
      />
      <NumberInput
        label="Budget"
        layout="vertical"
        format={USD}
        locales="en-US"
        step={100}
        defaultValue={1200}
      />
    </div>
  );
}

API

Parts: field (label row, help icon, hint from input), group (border box), decrement button (minus, or the lower stepper half), text input (the number), increment button (plus, or the upper stepper half).

PropTypeDefaultDescription
valuenumber | nullundefinedValue (controlled). null is an empty field.
defaultValuenumber | nullnullValue when uncontrolled.
layout"horizontal" | "vertical""horizontal"horizontal: minus button, number, plus button, with the number centered. vertical: number on the left and a two-part stepper (up, down) at the right edge.
minnumberundefinedSmallest value. The decrement button turns off at min.
maxnumberundefinedLargest value. The increment button turns off at max.
stepnumber1Change per button press or arrow key.
largeStepnumber10Change per Page Up, Page Down, or Shift with an arrow key.
formatIntl.NumberFormatOptionsundefinedNumber format, for example a currency or a percent; the value stays a plain number.
localesstring"en-US"Locale for the format and for parsing typed text.
size"sm" | "md" | "lg""md"Field height 40, 44 or 48px, matching Input. Horizontal buttons are square at the field height.
labelstringundefinedVisible label above the field.
placeholderstringundefinedText in the empty field.
hintstringundefinedHint line under the field; shows the error text when invalid.
helpTextstringundefinedText for the help icon after the label (from input).
requiredbooleanfalseShows the required mark and sets aria-required.
invalidbooleanfalseError border and red hint, as in Input (the Figma counter variants have no error icon).
disabledbooleanfalseTurns the field and its buttons off (50% opacity).
readOnlybooleanfalseThe value can be read and copied but not changed; the buttons are hidden.
namestringundefinedForm field name; the plain number is sent.
EventDescription
valueChangeThe value changed (a number or null), on each step and while typing a valid number.
valueCommitThe value is final: on blur, Enter, or when a held button is released.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
NIN1hover the fieldgroup borderborder-colorborder-control → border-control_hoverduration-sm (200)ease-outSwaps at once
NIN2keyboard focus in the text inputgroupborder-color, box-shadowborder-control, shadow-xs → border-brand, focus-ring-shadow-xsduration-2xs (120)ease-outShows at once
NIN3hover a step buttonstep buttonbackground-color, icon colortransparent, fg-quaternary → bg-primary_hover, fg-tertiary_hoverduration-sm (200)ease-outSwaps at once
NIN4press a step button (pointer down)step button iconscale1 → scale-pressduration-sm (200)ease-outStays still
NIN5release a step buttonstep button iconscalescale-press → 1duration-sm (200)ease-outStays still
NIN6the value reaches min or maxdecrement or increment buttonopacity1 → 0.5duration-sm (200)ease-outSwaps at once
NIN7invalid -> truegroup border, hintborder-color, colorborder-control, text-tertiary → border-error, text-error-primaryduration-sm (200)ease-outSwaps at once

Accessibility

  • Built on a number field primitive: the text input has role textbox with inputmode decimal (or numeric for whole numbers) and aria-roledescription "Number field"; it is not type number, so formats and locales work.
  • Keyboard: Up and Down step by step, Page Up and Page Down (or Shift with an arrow) by largeStep, Home and End go to min and max when they are set.
  • The step buttons are named "Increase" and "Decrease" and are not tab stops (tabindex -1), because the arrow keys in the input do the same; the input is the one tab stop.
  • Holding a step button repeats the step (the primitive's press-and-hold); the value is announced when the user stops, not on every repeat.
  • Step buttons are at least 28 x 22px in the vertical stepper; a transparent hit area grows each to 24px high (WCAG 2.5.8).
  • The label, hint and invalid state are linked as in Input.
  • Reduced motion: colors swap at once and the step buttons do not scale. The value never animates, with or without reduced motion.