bpdm/ui
Form

Number Input

Precision-safe number field with stepper buttons — sizes, layouts, range, affixes, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Number Input is a number field with stepper buttons. It is precision-safe: values are strings and all arithmetic runs through bignumber.js, so very large counts and high-decimal measurements never lose precision the way a JavaScript number would. It works controlled or uncontrolled and clamps to min/max. In React it's <NumberInput>; in Angular it's <bpdm-number-input>.

Usage

Drop it in with a starting value. Type freely, or use the steppers.

quantity.tsx
import { NumberInput } from '@bpdm/ui/number-input';

export function Quantity() {
  return <NumberInput defaultValue="9" min="0" />;
}
quantity.ts
import { Component } from '@angular/core';
import { BpdmNumberInput } from '@bpdm/ng';

@Component({
  selector: 'quantity',
  imports: [BpdmNumberInput],
  template: `<bpdm-number-input defaultValue="9" min="0" />`,
})
export class Quantity {}

Range & step

min and max clamp the value (the relevant stepper disables at the bound); step sets how much each press adds or removes.

range.tsx
import { NumberInput } from '@bpdm/ui/number-input';

export function Range() {
  return (
    <div className="flex items-center gap-4">
      <NumberInput min="0" max="10" defaultValue="5" />
      <NumberInput step="5" defaultValue="25" />
    </div>
  );
}
range.ts
import { Component } from '@angular/core';
import { BpdmNumberInput } from '@bpdm/ng';

@Component({
  selector: 'range',
  imports: [BpdmNumberInput],
  template: `
    <div class="flex items-center gap-4">
      <bpdm-number-input min="0" max="10" defaultValue="5" />
      <bpdm-number-input step="5" defaultValue="25" />
    </div>
  `,
})
export class Range {}

Precision

The value is a string and every step/clamp runs through bignumber.js, so high-decimal steps and integers beyond Number.MAX_SAFE_INTEGER stay exact — no float drift, no exponential notation. Read the value as a string via onValueChange (React) or [(value)] (Angular).

precision.tsx
import { useState } from 'react';
import { NumberInput } from '@bpdm/ui/number-input';

export function Precision() {
  const [value, setValue] = useState('1.2500');
  return (
    <div className="flex items-center gap-4">
      <NumberInput value={value} onValueChange={setValue} step="0.0001" suffix="kg" />
      <NumberInput defaultValue="9007199254740993" />
    </div>
  );
}
precision.ts
import { Component, signal } from '@angular/core';
import { BpdmNumberInput } from '@bpdm/ng';

@Component({
  selector: 'precision',
  imports: [BpdmNumberInput],
  template: `
    <div class="flex items-center gap-4">
      <bpdm-number-input [(value)]="value" step="0.0001" suffix="kg" />
      <bpdm-number-input defaultValue="9007199254740993" />
    </div>
  `,
})
export class Precision {
  readonly value = signal('1.2500');
}

Prefix & suffix

Add static text before (prefix) or after (suffix) the value — units like GB, kg, seats, or a × multiplier.

affix.tsx
import { NumberInput } from '@bpdm/ui/number-input';

export function Affix() {
  return (
    <div className="flex items-center gap-4">
      <NumberInput defaultValue="50" suffix="GB" min="0" />
      <NumberInput defaultValue="5" suffix="seats" min="1" />
      <NumberInput defaultValue="2" prefix="×" min="1" buttonLayout="horizontal" />
    </div>
  );
}
affix.ts
import { Component } from '@angular/core';
import { BpdmNumberInput } from '@bpdm/ng';

@Component({
  selector: 'affix',
  imports: [BpdmNumberInput],
  template: `
    <div class="flex items-center gap-4">
      <bpdm-number-input defaultValue="50" suffix="GB" min="0" />
      <bpdm-number-input defaultValue="5" suffix="seats" min="1" />
      <bpdm-number-input defaultValue="2" prefix="×" min="1" buttonLayout="horizontal" />
    </div>
  `,
})
export class Affix {}

Button layout

buttonLayout chooses the steppers — stacked (up/down chevrons on the right, default) or horizontal (−/+ on each side).

layout.tsx
import { NumberInput } from '@bpdm/ui/number-input';

export function Layout() {
  return (
    <div className="flex items-center gap-4">
      <NumberInput buttonLayout="stacked" defaultValue="9" />
      <NumberInput buttonLayout="horizontal" defaultValue="9" />
    </div>
  );
}
layout.ts
import { Component } from '@angular/core';
import { BpdmNumberInput } from '@bpdm/ng';

@Component({
  selector: 'layout',
  imports: [BpdmNumberInput],
  template: `
    <div class="flex items-center gap-4">
      <bpdm-number-input buttonLayout="stacked" defaultValue="9" />
      <bpdm-number-input buttonLayout="horizontal" defaultValue="9" />
    </div>
  `,
})
export class Layout {}

Sizes

size sets the height and text scale — sm, md (default), or lg.

sizes.tsx
import { NumberInput } from '@bpdm/ui/number-input';

export function Sizes() {
  return (
    <div className="flex items-center gap-4">
      <NumberInput size="sm" defaultValue="9" />
      <NumberInput size="md" defaultValue="9" />
      <NumberInput size="lg" defaultValue="9" />
    </div>
  );
}
sizes.ts
import { Component } from '@angular/core';
import { BpdmNumberInput } from '@bpdm/ng';

@Component({
  selector: 'sizes',
  imports: [BpdmNumberInput],
  template: `
    <div class="flex items-center gap-4">
      <bpdm-number-input size="sm" defaultValue="9" />
      <bpdm-number-input size="md" defaultValue="9" />
      <bpdm-number-input size="lg" defaultValue="9" />
    </div>
  `,
})
export class Sizes {}

States

Disabled dims the field and blocks both typing and the steppers.

states.tsx
import { NumberInput } from '@bpdm/ui/number-input';

export function States() {
  return <NumberInput disabled defaultValue="9" />;
}
states.ts
import { Component } from '@angular/core';
import { BpdmNumberInput } from '@bpdm/ng';

@Component({
  selector: 'states',
  imports: [BpdmNumberInput],
  template: `<bpdm-number-input disabled defaultValue="9" />`,
})
export class States {}

API

NumberInput / bpdm-number-input

Values accept a string or number; strings are recommended to preserve precision.

PropTypeDefaultDescription
valuestring | numberControlled value. React: pair with onValueChange. Angular: two-way [(value)].
defaultValuestring | number"0"Uncontrolled initial value.
minstring | numberLower bound; the decrease stepper disables at it.
maxstring | numberUpper bound; the increase stepper disables at it.
stepstring | number1Amount each stepper press adds or removes.
sizesm | md | lgmdHeight + text scale.
buttonLayoutstacked | horizontalstackedStepper placement.
prefixstringStatic text before the value.
suffixstringStatic text after the value.
onValueChange (React)(value: string) => voidCalled with the string value on every change.
messages{ increase, decrease }EnglishOverride the stepper button labels (screen-reader text) — see Internationalization.
disabledbooleanfalseDisable the field and steppers.
className (React) / class (Angular)stringExtra classes on the wrapper.

Accessibility

  • The wrapper is a role="group", and each stepper is a real <button> with an aria-label (Increase / Decrease) — usable and announced without sight of the glyph.
  • The field uses inputmode="decimal", so mobile shows a numeric keypad.
  • At min/max the relevant stepper is disabled (removed from the tab order), so the value can't leave its range.
  • Give the field a name — associate a <label> (via htmlFor/id) or add aria-label.

Internationalization

  • The stepper button labels (Increase / Decrease) are translatable via the messages prop — see Translating labels. Give the field itself a translated accessible name.
  • RTL-safe: field alignment uses a logical property (text-start) and the steppers sit via flex layout, so the control mirrors under dir="rtl".

On this page