Number Field
A text input that holds a number, with stepping, bounds and locale formatting.
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.
<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. Readv-model, never the input’s ownvalue. - The steppers sit outside the tab order on purpose, because the input already
reaches the value from the keyboard. Give each one an
aria-labelall the same, since a touch screen reader still lands on them. - Make sure the field has a label, through a
Labelinside aFieldor anaria-labelonNumberField.Input. NumberField.ScrubAreaasks 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.
| Name | Required | Description |
|---|---|---|
| NumberField.Root | true | Renders a <div> plus a visually hidden <input> carrying the numeric value. |
| NumberField.ScrubArea | An area that changes the value by dragging. | |
| NumberField.ScrubAreaCursor | Stands in for the pointer while it is locked. | |
| NumberField.Group | Renders a <div role="group"> around the input and its steppers. | |
| NumberField.Decrement | A <button> that steps down. | |
| NumberField.Input | true | Renders the <input> that holds the formatted value. |
| NumberField.Increment | A <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
Options
Emits
Slot props
The field state set, plus the three below.
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
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
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
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.
Data attributes
CSS variables
Errors
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.
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.