Skip to content

Number Fields

Number fields are a common field in many forms. They include built-in validation to reject non-numerical input. Additionally, the browser may provide stepper arrows to let the user increase and decrease the value with a configurable step amount.

The Number field is usually used for number values rather than numeric values. For example while a credit card number is numeric, you should not use a number field for it. Instead you should use it for values that are meant to be consumed as a number like units, prices and percentages.

The native HTML number input and most other implementations do not offer a good experience. Here are a couple of common issues:

  • Lack of proper internationalization: Mainly, the lack of support for other numeral systems like the Arabic numerals (٠١٢٣٤٥٦٧٨٩) are a pain to work with. Users often have to switch keyboard languages/layout to enter numbers and even then it is not perfect. Keyboards don’t always have all the necessary characters for decimals and thousands separators. This means the number input is not accessible for the global audience.
  • No formatting support: this includes grouping , and displaying units and currencies and other simple masking features.

Formwerk tries to address all these issues and more by utilizing the Intl.NumberFormat API. It provides a solid foundation for building number fields that are accessible and easy to use for users all over the world.

  • Supports using the input element as a base with type="text" (don’t worry, we add the inputmode automatically for accessability and mobile keyboards).
  • Labeling, descriptions, error message displays are automatically linked to input and label elements with aria-* attributes.
  • Formatting and parsing numbers with the Intl.NumberFormat API depending on the site locale or the user’s preferred language.
  • Support for multiple numeral systems (Latin, Arabic, and Chinese).
  • Support for Intl.NumberFormat units and currencies.
  • Support for the Spinbutton ARIA pattern for increment/decrement buttons.
  • Support for min, max and step attributes.
  • Validation support with native HTML constraint validation or Standard Schema validation.
  • Rejects non-numerical input characters and any incoming key presses that would make the number invalid.
  • Support for v-model binding.
  • Supported Keyboard features:
KeyDescription
Arrow Up
increment the value by the step amount.
Arrow Down
decrement the value by the step amount.
Page Up
increment the value by larger multiple of the step amount.
Page Down
decrement the value by larger multiple of the step amount.
Home
set the value to the min value if provided, otherwise has no effect.
End
set the value to the max value if provided, otherwise has no effect.
Wheel up
increment the value by the step amount.
Wheel down
decrement the value by the step amount.
Label
Label
47
Increment button
Decrement button
Input
Help text
Description or Error Message

You can start by importing the useNumberField composable and using it in your number field component.

The useNumberField composable returns binding objects for the elements shown in the anatomy, you will use v-bind to bind them to the corresponding DOM elements.

<script setup lang="ts">
import { ref } from 'vue';
import NumberField from './NumberField.vue';
const count = ref(5);
</script>
<template>
<NumberField label="Count" v-model="count" />
</template>
<script setup lang="ts">
import { type NumberFieldProps, useNumberField } from '@formwerk/core';
const props = defineProps<NumberFieldProps>();
const {
inputProps,
labelProps,
errorMessage,
descriptionProps,
errorMessageProps,
incrementButtonProps,
decrementButtonProps,
isTouched,
} = useNumberField(props);
</script>
<template>
<div class="field" :class="{ 'is-touched': isTouched }">
<label v-bind="labelProps">{{ label }}</label>
<div class="input-wrapper">
<input v-bind="inputProps" />
<div class="spinbutton">
<button type="button" v-bind="incrementButtonProps">+</button>
<button type="button" v-bind="decrementButtonProps">-</button>
</div>
</div>
<div v-if="description" v-bind="descriptionProps" class="hint">
{{ description }}
</div>
<div v-if="errorMessage" v-bind="errorMessageProps" class="error">
{{ errorMessage }}
</div>
</div>
</template>
<style scoped>
.field {
--color-primary: #10b981;
--color-text: #333;
--color-hint: #666;
--color-border: #d4d4d8;
--color-error: #f00;
--color-hover: #eee;
--color-focus: var(--color-primary);
label {
color: var(--color-text);
display: block;
margin-bottom: 0.25rem;
font-size: 14px;
font-weight: 500;
}
.hint {
color: var(--color-hint);
font-size: 13px;
opacity: 0;
transition: opacity 0.3s ease;
}
.hint,
.error {
margin-top: 0.25rem;
}
.input-wrapper {
color: var(--color-text);
display: flex;
align-items: stretch;
font-size: 13px;
width: max-content;
border: 1px solid var(--color-border);
border-radius: 6px;
transition: border-color 0.3s ease;
overflow: hidden;
background-color: #fff;
&:focus-within {
outline: none;
border-color: var(--color-focus);
box-shadow: 0 0 0 1px var(--color-focus);
}
input {
appearance: none;
border: none;
outline: none;
padding: 0.5rem 0.6rem;
background-color: transparent;
font-size: 14px;
}
}
.spinbutton {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: starlight;
justify-content: center;
border-inline-start: 1px solid var(--color-border);
width: 2rem;
button {
background-color: transparent;
border: none;
outline: none;
padding: 2px 0.25rem;
margin: 0;
color: var(--color-text);
font-size: 15px;
cursor: pointer;
&:hover {
background-color: var(--color-hover);
}
}
button + button {
border-top: 1px solid var(--color-border);
}
}
.error {
color: var(--color-error);
display: none;
font-size: 13px;
}
&:has(:focus) {
.hint {
opacity: 1;
}
}
&.is-touched {
&:has(:invalid) {
--color-border: var(--color-error);
--color-focus: var(--color-error);
.error {
display: block;
}
.hint {
display: none;
}
}
}
}
</style>

Notice that we imported the NumberFieldProps in the previous example. This is recommended to use as your component prop types. Not only you get type safety for your component out of it but also it handles the reactivity aspects of the props so you don’t have to. You are free to extend it with your own props or omit the ones you don’t need.

You can use the following native HTML validation properties to validate the number field:

NameTypeDescription
maxnumberThe maximum value for the number field.
minnumberThe minimum value for the number field.
requiredbooleanWhether the number field is required.
stepnumberThe step amount for the number field.

Here is an example of how to use the max and min properties to limit the number field value between 0 and 100.

Assuming you have a NumberField component like the one shown above, you can use it like this:

<script setup lang="ts">
import { ref } from 'vue';
import NumberField from './NumberField.vue';
const value = ref(26);
</script>
<template>
<NumberField
v-model="value"
label="Amount"
max="50"
min="0"
step="2"
required
/>
</template>

useNumberField also supports Standard Schema validation through the schema prop. This includes multiple providers like Zod, Valibot, Arktype, and more.

<script setup lang="ts">
import { z } from 'zod';
import NumberField from './NumberField.vue';
const schema = z
.number('Required')
.min(1, 'Must be greater than 0')
.max(14, 'Must be less than 14');
</script>
<template>
<NumberField label="Number" :schema="schema" />
</template>

While it is unlikely that you need both HTML constraints and Standard Schemas to validate a number field, Formwerk supports mixed validation, which means you can use both HTML constraints and Standard Schemas to validate the field and define step amount and min/max values and they work seamlessly together.

Note that HTML constraints are validated first, so any errors from the HTML constraints will be displayed first. Then once all HTML constraints are satisfied, the Standard Schema is validated.

<script setup lang="ts">
import { z } from 'zod';
import NumberField from './NumberField.vue';
const schema = z.number().min(1).max(10);
</script>
<template>
<NumberField label="Number" :schema="schema" max="50" min="0" step="2" />
</template>

Use disabled to mark fields as non-interactive, disabled fields are not validated and are not submitted.

If you need to prevent the user from interacting with the field while still allowing it to submit, consider using readonly instead.

<script setup lang="ts">
import NumberField from './NumberField.vue';
</script>
<template>
<NumberField label="Disabled" disabled />
</template>

Readonly fields are validated and submitted, but they do not accept user input. The field is still focusable and the value is copyable. For more info, check the MDN.

<script setup lang="ts">
import NumberField from './NumberField.vue';
</script>
<template>
<NumberField label="Readonly" value="47" readonly />
</template>

You can use the formatOptions prop to format the number field value. It accepts all options that are supported by the Intl.NumberFormat API.

<script setup lang="ts">
import { ref } from 'vue';
import NumberField from './NumberField.vue';
const value = ref(1000);
</script>
<template>
<NumberField
label="Amount"
v-model="value"
step="100"
:formatOptions="{ style: 'currency', currency: 'USD' }"
/>
</template>

Aside from formatting you can also use any numeral system supported by the Intl.NumberFormat API. Like Arabic, and Chinese.

The number field also accepts a locale prop to change the locale of the number field. Usually you should not want to pass it manually but for demonstration purposes it is shown below.

Actually, here are 3 fields each with a different numeral system bound to the same value, and you get the parsed value with either of them.

<script setup lang="ts">
import { ref } from 'vue';
import NumberField from './NumberField.vue';
const value = ref(10);
</script>
<template>
<NumberField label="Latin" v-model="value" />
<NumberField label="Arabic" v-model="value" locale="ar-EG" />
<NumberField label="Chinese" v-model="value" locale="zh-cn-u-nu-hanidec" />
value is: {{ value }}
</template>

The number field doesn’t really need much for RTL support, however the dir prop can be used to set the direction of the field for convenience.

<script setup lang="ts">
import NumberField from './NumberField.vue';
</script>
<template>
<NumberField label="Amount" value="10" dir="rtl" />
</template>

These are some examples of number fields built with Formwerk.

These are the properties that can be passed to the useNumberField composable.

NameTypeDescription

These are the properties in the object returned by the useNumberField composable.

NameTypeDescription
controlId
decrement
decrementButtonProps
Ref<{ 'aria-label': string; tabindex: string; role: string; "aria-disabled": true; type?: undefined; disabled?: undefined; onMousedown: (e: MouseEvent) =>
descriptionPropsProps for the description element.
errorMessageThe error message for the field.
errorMessagePropsProps for the error message element.
errorsThe errors for the field.
field
fieldValueThe value of the field.
formattedText
increment
incrementButtonProps
Ref<{ 'aria-label': string; tabindex: string; role: string; "aria-disabled": true; type?: undefined; disabled?: undefined; onMousedown: (e: MouseEvent) =>
inputEl
inputProps
isBlurredWhether the field is blurred.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isTouchedWhether the field is touched.
isValidWhether the field is valid.
labelPropsProps for the label element.
setBlurredSets the blurred state for the field.
setErrors
(messages: Arrayable<string>) => void
Sets the errors for the field.
setTouchedSets the touched state for the field.
setValueSets the value for the field.
submitErrorMessageThe error message for the field from the last submit attempt.
submitErrorsThe errors for the field from the last submit attempt.
validate
(mutate?: boolean) => Promise<ValidationResult<unknown>>
Validates the field.