Skip to content

Number Field

A text input that holds a number, with stepping, bounds and locale formatting.

View source View as Markdown

Number field holds a single number and renders it through Intl.NumberFormat. The arrow keys step the value, a held stepper repeats, and every change is clamped to the bounds. Useful when the value is a quantity somebody will nudge as often as type; where the bounds matter more than the exact figure, use Slider instead.

Quantity
Budget
App.vue
<template>
  <div class="flex w-[17rem] flex-col gap-6">
    <div class="flex flex-col gap-1.5">
      <span :class="caption">Quantity</span>

      <NumberField.Root
        v-model="quantity"
        class="group"
        :options="{ min: 0, max: 99, name: 'quantity' }"
      >
        <NumberField.Group :class="box">
          <NumberField.Decrement :class="stepper" aria-label="One fewer">
          </NumberField.Decrement>
          <NumberField.Input :class="control" />
          <NumberField.Increment :class="stepper" aria-label="One more">
            +
          </NumberField.Increment>
        </NumberField.Group>
      </NumberField.Root>
    </div>

    <div class="flex flex-col gap-1.5">
      <span :class="caption">Budget</span>

      <NumberField.Root
        v-model="budget"
        class="group"
        :options="{
          min: 0,
          step: 50,
          name: 'budget',
          locale: 'de-DE',
          format: { style: 'currency', currency: 'EUR' },
        }"
      >
        <NumberField.Group :class="box">
          <NumberField.Decrement :class="stepper" aria-label="Fifty less">
          </NumberField.Decrement>
          <NumberField.Input :class="control" />
          <NumberField.Increment :class="stepper" aria-label="Fifty more">
            +
          </NumberField.Increment>
        </NumberField.Group>
      </NumberField.Root>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { NumberField } from '@maas/mirror/vue'

const quantity = ref(1)
const budget = ref(1250)

const caption = 'type-component-2xs text-surface-muted'

const box = [
  'relative isolate flex h-12 w-full items-center justify-between',
  'rounded-component-lg border-surface border-2',
  'transition-all duration-100 ease-linear [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
  'outline-4 outline-transparent focus-within:focus-ring',
  'group-data-[disabled=true]:border-disabled-subtle',
].join(' ')

const stepper = [
  'flex h-full w-11 shrink-0 cursor-pointer items-center justify-center',
  'type-component-lg leading-none text-surface-muted select-none',
  'rounded-component-md bg-transparent outline-none',
  'hover:text-surface active:text-primary-solid',
  'data-[disabled=true]:text-disabled-muted data-[disabled=true]:cursor-not-allowed',
].join(' ')

const control = [
  'type-component-lg leading-[normal]! text-surface tabular-nums',
  'h-full w-full min-w-0 appearance-none bg-transparent text-center outline-none',
  'data-[disabled=true]:text-disabled-solid',
].join(' ')
</script>

Usage guidelines

  • The value is a number or null, and the input shows the formatted text for it. Read v-model, never the input’s own value.
  • The steppers sit outside the tab order on purpose, because the input already reaches the value from the keyboard. Give each one an aria-label all the same, since a touch screen reader still lands on them.
  • Make sure the field has a label, through a Label inside a Field or an aria-label on NumberField.Input.
  • NumberField.ScrubArea asks for a pointer lock and carries on without one, so it is safe to render wherever the API is missing.

Anatomy

Assemble the parts. NumberField.Group is what ties the input and its steppers together for a screen reader, and the scrub area is optional.

NameRequiredDescription
NumberField.Root true Renders a <div> plus a visually hidden <input> carrying the numeric value.
NumberField.ScrubAreaAn area that changes the value by dragging.
NumberField.ScrubAreaCursorStands in for the pointer while it is locked.
NumberField.GroupRenders a <div role="group"> around the input and its steppers.
NumberField.DecrementA <button> that steps down.
NumberField.Input true Renders the <input> that holds the formatted value.
NumberField.IncrementA <button> that steps up.
<script setup lang="ts">
import { Field, Label, NumberField } from '@maas/mirror/vue'
</script>

<template>
  <Field.Root :options="{ name: 'quantity' }">
    <Label>Quantity</Label>
    <NumberField.Root v-model="quantity" :options="{ min: 0, max: 99 }">
      <NumberField.Group>
        <NumberField.Decrement>−</NumberField.Decrement>
        <NumberField.Input />
        <NumberField.Increment>+</NumberField.Increment>
      </NumberField.Group>
    </NumberField.Root>
  </Field.Root>
</template>

Examples

Bounds and steps

options.min and options.max clamp the value, and a stepper disables itself once the value has reached its bound.

<template>
  <NumberField.Root
    v-model="quantity"
    :options="{ min: 1, max: 10, step: 1 }"
  />
</template>

options.step is the granularity of one arrow press. options.smallStep and options.largeStep are the same movement with a modifier held, so the user can step finer or coarser without any change to the field.

<template>
  <NumberField.Root
    v-model="rate"
    :options="{ step: 0.5, smallStep: 0.1, largeStep: 5 }"
  />
</template>

Snapping

By default the value goes wherever the arithmetic puts it, so a field starting at 7 with a step of 5 lands on 12. options.snapOnStep pulls every change onto min + n * step instead, and does the same to a typed value on commit.

<template>
  <NumberField.Root
    v-model="quantity"
    :options="{ step: 5, snapOnStep: true }"
  />
</template>

Formatting and locale

options.format is passed straight to Intl.NumberFormat, and options.locale decides the separators. The same pair reads the value back, so a German field accepts 1.234,50 € and returns 1234.5.

<template>
  <NumberField.Root
    v-model="budget"
    :options="{
      locale: 'de-DE',
      format: { style: 'currency', currency: 'EUR' },
    }"
  />
</template>

A percentage format holds the fraction and shows the percentage, the way Intl.NumberFormat does everywhere else: 0.25 renders as 25% and typing 50% gives you 0.5.

<template>
  <NumberField.Root
    v-model="share"
    :options="{ format: { style: 'percent' } }"
  />
</template>

While somebody is typing, the text stays as they left it and the value follows along unformatted. A blur, an Enter, a stepper press or a scrub commits the change, and the display is written back from the value at that point.

Scrubbing

NumberField.ScrubArea turns a drag into a value change. It locks the pointer where the browser allows it, so the drag never runs out of screen, and NumberField.ScrubAreaCursor stands in for the pointer that the lock hid.

The drag is configured on the root, under options.scrub, so it reads the same wherever the area is rendered. options.scrub.pixelSensitivity is how many pixels of movement one step is worth.

<template>
  <NumberField.Root
    v-model="opacity"
    :options="{ min: 0, max: 100, scrub: { pixelSensitivity: 3 } }"
  >
    <NumberField.ScrubArea>
      <span>Opacity</span>
      <NumberField.ScrubAreaCursor>↔</NumberField.ScrubAreaCursor>
    </NumberField.ScrubArea>
    <NumberField.Group>
      <NumberField.Input />
    </NumberField.Group>
  </NumberField.Root>
</template>

options.scrub.direction moves the reading to the vertical axis, where up is an increase. options.scrub.teleportDistance bounds the box the cursor wraps around, centred on the scrub area, which keeps a long drag from walking off to a corner of the screen.

<template>
  <NumberField.Root
    v-model="opacity"
    :options="{ scrub: { direction: 'vertical', teleportDistance: 200 } }"
  />
</template>

Scrubbing with the wheel

options.allowWheelScrub lets the wheel move the value, but only while the input holds focus, so a page scroll never changes a number on the way past.

<template>
  <NumberField.Root v-model="quantity" :options="{ allowWheelScrub: true }" />
</template>

Reacting to changes

update:modelValue fires on every change, a keystroke included, while valueCommit fires once an interaction ends: a blur, an Enter, the release of a stepper, the end of a scrub. Render from the first and persist from the second.

<template>
  <NumberField.Root
    v-model="quantity"
    @value-commit="save"
    @scrubbing="dragging = $event"
  />
</template>

Inside a form

NumberField.Root renders a visually hidden input carrying options.name and the numeric value, so the form sees it without the formatted text getting in the way. An empty field submits an empty string.

<template>
  <form @submit.prevent="submit">
    <NumberField.Root
      v-model="quantity"
      :options="{ name: 'quantity', required: true }"
    />
  </form>
</template>

The input is the only tab stop, so it reports focus to a Field and blurring it marks the field touched. Validation runs against the number, not the text.

Reaching the field from anywhere

Give the root an id and useMirrorNumberField(id) reads and writes that field from anywhere in the app.

<template>
  <Button @click="setValue(1)">Reset</Button>
</template>

<script setup lang="ts">
const { value, formatted, increment, decrement, setValue } =
  useMirrorNumberField(NumberFieldId.Quantity)
</script>

API reference

Module. A bundled options object, and useMirrorNumberField(id) as the programmatic API. Every part takes id to resolve a field it is not nested inside.

NumberField.Root

Renders a <div> plus a visually hidden <input> carrying the numeric value.

Props

PropTypeDefault
id
stringgenerated
modelValue
number | null | undefinedundefined
defaultValue
number | nullnull
invalid
booleaninherited from Field
options
NumberFieldOptionssee below

Options

OptionTypeDefault
min
numberundefined
max
numberundefined
step
number1
smallStep
number0.1
largeStep
number10
snapOnStep
booleanfalse
allowWheelScrub
booleanfalse
format
Intl.NumberFormatOptionsundefined
locale
stringundefined
name
stringinherited from Field
disabled
booleaninherited from Field
readOnly
booleaninherited from Field
required
booleaninherited from Field
scrub.direction
'horizontal' | 'vertical''horizontal'
scrub.pixelSensitivity
number2
scrub.teleportDistance
numberundefined

Emits

EmitPayload
update:modelValue
number | null
valueCommit
number | null
scrubbing
boolean

Slot props

The field state set, plus the three below.

PropType
value
number | null
inputValue
string
scrubbing
boolean

NumberField.Group

Renders a <div role="group"> around the input and its steppers, so a screen reader reads the three as one control.

Slot props

The field state set, plus value, inputValue and scrubbing.

NumberField.Input

Renders an <input type="text"> carrying the formatted value, with inputmode set to decimal wherever the step or the format can produce a fraction and numeric otherwise. It takes its DOM ID from the root, so a Label reaches it.

Props

PropTypeDefault
id
stringinjected

NumberField.Increment

Renders a <button> that steps the value up, out of the tab order and disabled once the value has reached max. Holding it repeats after 400ms, then every 60ms until it is released.

Props

PropTypeDefault
id
stringinjected
disabled
booleanundefined

Slot props

The field state set, where disabled also covers the bound this stepper has reached.

NumberField.Decrement

Renders a <button> that steps the value down, disabled once the value has reached min. Same props and slot props as NumberField.Increment.

NumberField.ScrubArea

Renders a <span> that changes the value by dragging. It requests a pointer lock on pointerdown and carries on without one where the API is missing or the browser refuses. How the drag reads comes from options.scrub on the root.

Props

PropTypeDefault
id
stringinjected

Slot props

The field state set, plus direction, scrubbing and value.

NumberField.ScrubAreaCursor

Renders a <span> while a scrub is running and nothing the rest of the time. It stands in for the pointer the lock hid, follows it through a component-local variable, and is hidden from assistive technology.

Slot props

The field state set, plus direction and scrubbing.

Composable

useMirrorNumberField(id) reaches a number field from anywhere in the app.

KeyType
value
ComputedRef<number | null>
inputValue
ComputedRef<string>
formatted
ComputedRef<string>
scrubbing
ComputedRef<boolean>
disabled
ComputedRef<boolean>
readOnly
ComputedRef<boolean>
atMin
ComputedRef<boolean>
atMax
ComputedRef<boolean>
setValue
(next: number | null) => void
increment
(amount?: number) => void
decrement
(amount?: number) => void
commit
() => void
focusInput
() => void

Data attributes

PartAttributeValue
all
data-disabled
true
all
data-readonly
true
all
data-required
true
all
data-valid
true
all
data-invalid
true
all
data-dirty
true
all
data-touched
true
all
data-filled
true
all
data-focused
true
all
data-scrubbing
true
NumberField.ScrubArea, NumberField.ScrubAreaCursor
data-direction
horizontal | vertical

CSS variables

PartVariableDefault
NumberField.Input
--mirror-number-field-input-disabled-cursor
not-allowed
NumberField.Increment, NumberField.Decrement
--mirror-number-field-stepper-cursor
pointer
NumberField.Increment, NumberField.Decrement
--mirror-number-field-stepper-disabled-cursor
not-allowed
NumberField.ScrubArea
--mirror-number-field-scrub-area-cursor
ew-resize
NumberField.ScrubArea
--mirror-number-field-scrub-area-vertical-cursor
ns-resize
NumberField.ScrubArea
--mirror-number-field-scrub-area-disabled-cursor
not-allowed
NumberField.ScrubAreaCursor
--mirror-number-field-scrub-area-cursor-z-index
1000

Errors

Code
missing_number_field_context
missing_number_field_scrub_area
invalid_number_field_range
invalid_number_field_step

Accessibility

The input is a text input rather than a spinbutton, because the value it shows is formatted text that a screen reader should read as written. aria-roledescription="Number field" names it, and the steppers point at it with aria-controls. The steppers carry tabindex="-1": the keyboard reaches the value through the input, and a touch screen reader still reaches the buttons.

KeyBehaviour
ArrowUp / ArrowDownIncrease or decrease by step.
Shift and an arrowIncrease or decrease by largeStep.
Alt and an arrowIncrease or decrease by smallStep.
PageUp / PageDownIncrease or decrease by largeStep.
Home / EndJump to min or max. Inert while that bound is unset.
EnterCommit the typed value. A field inside a form still submits it.
WheelIncrease or decrease by step, while the input holds focus and allowWheelScrub is set.

Every key above works on NumberField.Input, and a read-only or disabled field ignores all of them. A disabled field leaves the tab order altogether, where a read-only one keeps its place in it.