# Number field

A text field for a quantity or a measure, with its unit and minus and plus buttons that step it between its limits.

Block: `smbk-number-field`. Status: experimental. Figma: the Figma file, https://www.figma.com/community/file/1688324308358870630. Desktop and mobile: differ by presentation only.

## Options

| Axis | Values | Default | Meaning |
|---|---|---|---|
| `size` | `md`, `sm` | `md` | The input’s two heights, with its padding and text. The step buttons’ icons are 16 px at both; at sm each button is a 24 px square, so it fits inside the small well. Small text never goes under 16 px on a phone. |
| `error` | on / off | off | The number is not one the form can take. Draws the danger border, kept while focused, and the error icon before the unit. |

Parts: `smbk-number-field__control`, `smbk-number-field__unit`, `smbk-number-field__separator`, `smbk-number-field__decrement`, `smbk-number-field__increment`

## Markup

```html
<span class="smbk-input smbk-input--size-md smbk-number-field smbk-number-field--size-md">
  <input
    aria-label="Parcel weight"
    aria-describedby="react-aria-number-field-1 react-aria-number-field-2 react-aria-number-field-3"
    id="react-aria-number-field-4"
    type="text"
    autoComplete="off"
    inputMode="numeric"
    autoCorrect="off"
    spellCheck="false"
    tabindex="0"
    aria-roledescription="Number field"
    class="smbk-input__control smbk-number-field__control"
    value="2.5"
  >
  <span class="smbk-input__trailing-action">
    <span id="react-aria-number-field-3" class="smbk-number-field__unit">kg</span>
    <span class="smbk-number-field__separator" aria-hidden="true"></span>
    <button
      type="button"
      class="smbk-btn smbk-btn--tone-ghost smbk-btn--size-sm smbk-btn--icon-only smbk-number-field__decrement"
      tabindex="-1"
      data-react-aria-pressable="true"
      aria-label="Decrease Parcel weight"
      aria-controls="react-aria-number-field-4"
    >
      <i class="ph ph-minus smbk-icon smbk-icon--size-md smbk-btn__icon" aria-hidden="true"></i>
    </button>
    <button
      type="button"
      class="smbk-btn smbk-btn--tone-ghost smbk-btn--size-sm smbk-btn--icon-only smbk-number-field__increment"
      tabindex="-1"
      data-react-aria-pressable="true"
      aria-label="Increase Parcel weight"
      aria-controls="react-aria-number-field-4"
    >
      <i class="ph ph-plus smbk-icon smbk-icon--size-md smbk-btn__icon" aria-hidden="true"></i>
    </button>
  </span>
</span>
```

## React

```tsx
import { NumberField } from './design-system/components/number-field/number-field.js';

<NumberField size="md" defaultValue={2.5} min={0.5} max={30} step={0.5} unit="kg" aria-label="Parcel weight" />
```

## Stylesheet

```css
/* smbk-number-field — Number field. Figma "Number field" 7106:947.
 * Built on the input: the block always sits on an smbk-input well, the text
 * control is the input's control part, and the unit, a hairline and the minus
 * and plus buttons (ghost icon buttons) sit in the input's trailing action.
 * The padding, the value's text, hover, focus, error, read-only, disabled and
 * the two sizes are the input's own, so a change to the input reaches the
 * number field. This stylesheet adds the unit, the line, the buttons' place
 * and figures of one width. */

/* Figures of one width, so a value that steps does not shift sideways. */
.smbk-number-field__control,
.smbk-number-field__unit {
  font-variant-numeric: tabular-nums;
}

/* The unit, the line and the two buttons, 4 apart. */
.smbk-number-field .smbk-input__trailing-action {
  gap: var(--smbk-space-inline-xs);
}

/* The unit is quieter than the value and a step smaller beside the md value: Body SM. Figma keeps its color in
   every state, disabled too. */
.smbk-number-field__unit {
  color: var(--smbk-color-text-subtle);
  font-family: var(--smbk-typography-body-sm-font-family);
  font-size: var(--smbk-typography-body-sm-font-size);
  font-weight: var(--smbk-typography-body-sm-font-weight);
  line-height: var(--smbk-typography-body-sm-line-height);
  letter-spacing: var(--smbk-typography-body-sm-letter-spacing);
  white-space: nowrap;
}

.smbk-input--size-sm .smbk-number-field__unit {
  font-family: var(--smbk-typography-field-sm-font-family);
  font-size: var(--smbk-typography-field-sm-font-size);
  font-weight: var(--smbk-typography-field-sm-font-weight);
  line-height: var(--smbk-typography-field-sm-line-height);
  letter-spacing: var(--smbk-typography-field-sm-letter-spacing);
}

/* A hairline between what is typed and the buttons, as tall as a medium icon at both sizes, as Figma draws it. */
.smbk-number-field__separator {
  flex-shrink: 0;
  width: var(--smbk-border-width-hairline);
  height: var(--smbk-size-icon-md);
  background: var(--smbk-color-border-subtle);
}

/* The rule React Aria would write into the page itself (the wrapper keeps it from doing so, since a Site's
   security policy refuses an inline style). A touch on a button starts the press at once, with no wait for a
   double tap, while the page still scrolls and zooms under it; unlike the button's own touch-action: manipulation,
   a scroll that starts on a button also cancels its press in Safari, so a held button never starts stepping while
   the page scrolls. Two classes, so it wins over the button's own rule whichever stylesheet loads first. */
.smbk-number-field__decrement.smbk-btn,
.smbk-number-field__increment.smbk-btn {
  touch-action: pan-x pan-y pinch-zoom;
}

/* Small: Figma puts 32 px buttons in the 32 px well, which would cover the well's edge. Each button here is
   the 24 px square, with the 16 px icon Figma draws, and the margin gives back the rest of Figma's 32 px place,
   so the icons sit where Figma draws them. Two classes on the icon, so it wins over the button's own small icon
   whichever stylesheet loads first. */
.smbk-number-field__decrement.smbk-btn--size-xs,
.smbk-number-field__increment.smbk-btn--size-xs {
  margin-inline: calc((var(--smbk-size-control-sm) - var(--smbk-size-control-xs)) / 2);
}

.smbk-number-field__decrement.smbk-btn--size-xs .smbk-btn__icon,
.smbk-number-field__increment.smbk-btn--size-xs .smbk-btn__icon {
  font-size: var(--smbk-size-icon-sm);
}

/* ---- touch: the two steps stop their press areas halfway to each other ----
   Every button takes an invisible press area on a touch screen; the field's two steps stand closer than that, so
   between them each area reaches only halfway into their gap, while both can be pressed. Nothing drawn moves. */

@media (pointer: coarse) {
  .smbk-input__trailing-action:has(> .smbk-number-field__increment:not(:disabled)) > .smbk-btn.smbk-number-field__decrement::after {
    inset-inline-end: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-inline-xs) / -2);
  }

  .smbk-input__trailing-action:has(> .smbk-number-field__decrement:not(:disabled)) > .smbk-btn.smbk-number-field__increment::after {
    inset-inline-start: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), var(--smbk-space-inline-xs) / -2);
  }

  .smbk-input__trailing-action:has(> .smbk-number-field__increment:not(:disabled)) > .smbk-number-field__decrement.smbk-btn--size-xs::after {
    inset-inline-end: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), (var(--smbk-space-inline-xs) + var(--smbk-size-control-sm) - var(--smbk-size-control-xs)) / -2);
  }

  .smbk-input__trailing-action:has(> .smbk-number-field__decrement:not(:disabled)) > .smbk-number-field__increment.smbk-btn--size-xs::after {
    inset-inline-start: max(min(0%, (100% - var(--smbk-size-target-comfortable)) / 2), (var(--smbk-space-inline-xs) + var(--smbk-size-control-sm) - var(--smbk-size-control-xs)) / -2);
  }
}

/* ---- high contrast (forced colors) ---- */
/* The hairline is a fill, which forced colors drop; draw it in the text color the reader chose. */
@media (forced-colors: active) {
  .smbk-number-field__separator {
    background: CanvasText;
  }
}
```

## Tokens it reads

```json
{
  "space": {
    "inline": {
      "xs": {
        "$type": "dimension",
        "$value": "{{space.4}}",
        "$description": "Gap between things side by side, extra small."
      }
    }
  },
  "color": {
    "text": {
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.700}}",
        "$description": "Secondary text: descriptions, metadata."
      }
    },
    "border": {
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.300}}",
        "$description": "Dividers and quiet edges."
      }
    }
  },
  "typography": {
    "body-sm": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "14px",
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      }
    },
    "field-sm": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": {
          "min": "16px",
          "max": "14px"
        },
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      },
      "$description": "Field SM: the text inside a small field. 14 on a desktop, 16 on a phone, so iPhones do not zoom in (answer 11)."
    }
  },
  "border-width": {
    "hairline": {
      "$type": "dimension",
      "$value": "{{border-width.1}}",
      "$description": "Thin lines: dividers and almost every edge."
    }
  },
  "size": {
    "icon": {
      "md": {
        "$type": "dimension",
        "$value": "{{size.20}}",
        "$description": "Icon box, medium."
      },
      "sm": {
        "$type": "dimension",
        "$value": "{{size.16}}",
        "$description": "Icon box, small."
      }
    },
    "control": {
      "sm": {
        "$type": "dimension",
        "$value": "{{size.32}}",
        "$description": "Height of a small control: 32 comfortable, 28 compact. Dashboard recipe: use compact density; 40 for the page’s one main action and for forms; 32 for toolbars and filters, the size used most; 28 for actions inside table rows and cards. On phones switch to comfortable density, or give small controls an invisible 44 px tap area (size/target/comfortable)."
      },
      "xs": {
        "$type": "dimension",
        "$value": "{{size.24}}",
        "$description": "Height of the extra-small control: 24 in both densities, the smallest target allowed. Dashboard recipe: use compact density; 40 for the page’s one main action and for forms; 32 for toolbars and filters, the size used most; 28 for actions inside table rows and cards. On phones switch to comfortable density, or give small controls an invisible 44 px tap area (size/target/comfortable)."
      }
    },
    "target": {
      "comfortable": {
        "$type": "dimension",
        "$value": "{{size.44}}",
        "$description": "Comfortable touch target (44)."
      }
    }
  }
}
```

## Purpose

A text field for a quantity or a measure, where a unit and limits matter: a weight, a count of seats, a width in pixels. The reader types the number or steps it with the minus and plus buttons or the arrow keys, and the field keeps it between its lowest and highest values. It reads and writes numbers the way the reader's language does, so "1,5" and "1.5" both work where each is how one and a half is written.

## Anatomy

- The block, always on the input's well: its markup carries the input's classes as well as its own.
- The control part, a text control holding the number, which is also the input's control part. Its figures are all one width, so a number that steps does not shift sideways.
- In error, the input's error icon, a warning sign before the unit.
- The unit part, optional: the unit after the number, in quieter text, a step smaller than the number at the medium size.
- The separator part, a hairline between what is typed and the buttons.
- The decrement and increment parts, ghost icon buttons with a minus and a plus, in the input's trailing action. At the medium size they are small icon buttons; at the small size they are the extra-small square, with the same 16 px icons, so they fit inside the small well.

## When to use

- A quantity or a measure: a weight, a price, a count, a size in pixels, a percentage.
- A value with limits and a natural step, such as 1 to 50 seats in steps of 1.

## When not to use

- A number that is really a name: a phone number, a postcode, a card number, an order number. Use the [input](input.md) with a numeric keyboard hint; steps and limits mean nothing there.
- A value picked roughly from a wide range, where the exact number matters less. Use the [slider](slider.md).
- A choice of a few fixed values. Use the [segmented control](segmented-control.md) or the [select](select.md).

## Behavior

The minus and plus buttons move the number by its step; holding one down keeps stepping. The up and down arrow keys do the same while the field has focus, as do Page Up and Page Down, and Home and End jump to the lowest and highest values when the field has them. At a limit the button that would pass it is disabled. Scrolling the page never changes the number.

The field takes only what can make a number in the reader's language: figures, the decimal mark, the minus sign. A typed number is settled when the reader leaves the field or presses Enter: kept inside the limits and rounded to the step, then written the reader's way. The page hears the new number then, and after every step, not on each key. An emptied field reports `null`, and the page empties the field by giving it `null`.

The wrapper holds its own number, or follows the number the page gives it. Given a name, it sends the number with the form as plain digits, whatever the language shows, and nothing while disabled. A required number field stops its form being sent while it is empty: the field takes focus, the browser says why, and it takes its error look until a number is typed. The form's reset puts the starting number back. Read-only, the number can be read and copied and the buttons are disabled; disabled, the field and its buttons are out of reach.

With `hideButtons` the field is a bare number box: the same well, text and keys without the line and the two buttons, for a row of boxes too narrow to hold them.

## Accessibility

### What it does for you

- A screen reader hears the field's name, "number field", the number, and then the unit.
- The field is one stop in the Tab order. The buttons are left out of it, because the arrow keys do the same from the field; they are named "Decrease" and "Increase" followed by the field's name, such as "Increase seats", so a screen reader on its own can still press them.
- Each button is a 24 px square or larger, the least a press needs.
- A number brought back inside the limits when the reader leaves the field is read out, so the change is never silent.
- In development, a number field with no name logs a warning in the console.
- In a forced-color mode, the hairline between the unit and the buttons is drawn in the reader's text color.

### What you must do

- Give it a label like any input; inside a field it has one.
- Say the limits in the field's help text, such as "From 1 to 50", so they are known before the first try.

### How to test

- Tab to the field: it is one stop, and the buttons are skipped. Press Up Arrow and Down Arrow: the number moves by its step, and the page hears it.
- Type a number outside the limits and press Enter: it is brought inside them and rounded to the step, and a screen reader says the new number.
- With a screen reader, the field is read with its name, "number field", its number and its unit, and each button as "Increase" or "Decrease" with the field's name.
- With text at 200% and the page 320 pixels wide, the number, the unit and the two buttons stay whole.
- In a forced-color mode, the focus border, the number and the hairline stay visible.

### Keyboard

| Key | What it does |
|---|---|
| Tab | Moves focus to the field, and out of it, which settles a typed number. The two buttons are skipped. |
| Up Arrow or Down Arrow | Moves the number up or down by its step. |
| Page Up or Page Down | Moves the number up or down, as the arrow keys do. |
| Home or End | Jumps to the lowest or highest value, when the field has limits. |
| Enter | Settles a typed number: kept inside the limits, rounded to the step and written the reader's way. |

## Usage rules

### Do

- Give the unit beside the number, and in the label too when it decides what the reader types ("Weight in kg").
- Choose a step the reader expects: 1 for a count, 0.5 or 0.1 for a measure.

### Don't

- Use it for a phone number, a postcode or any number that is a name.
- Leave the limits unsaid: a number changed on leaving the field surprises a reader who did not know them.
