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.

Features

  • 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.

Anatomy

Label
Label
47
Increment button
Decrement button
Input
Help text
Description or Error Message

Building a Number Field Component

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>
114 collapsed lines
<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.

Validation

HTML Constraints

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>

Standard Schema

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>

Mixed Validation

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>

Usage

Disabled

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

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>

Formatting and Units

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>

i18n

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>

RTL

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>

Examples

These are some examples of number fields built with Formwerk.

NumberFlow

Showcase of a number field using the excellent `@number-flow/vue` to animate value transitions.

API

Props

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

NameTypeDescription
decrementLabelThe label text for the decrement button.
descriptionThe description text for the number field.
disabledWhether the number field is disabled.
disableHtmlValidationWhether to disable HTML5 form validation.
disableWheelWhether to disable mouse wheel input.
formatOptionsOptions for number formatting.
incrementLabelThe label text for the increment button.
labelThe label text for the number field.
localeThe locale to use for number formatting.
maxThe maximum allowed value.
minThe minimum allowed value.
modelValueThe v-model value of the number field.
nameThe name attribute for the number field input.
placeholderPlaceholder text shown when the number field is empty.
readonlyWhether the number field is readonly.
requiredWhether the number field is required.
schemaSchema for number field validation.
stepThe amount to increment/decrement by.
valueThe value attribute of the number field input.

Returns

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

NameTypeDescription
decrementDecrements the number field value.
decrementButtonProps
Ref<{ 'aria-label': string; tabindex: string; role: string; "aria-disabled": true; type?: undefined; disabled?: undefined; onMousedown: () =>
Props for the decrement button.
descriptionPropsProps for the description element.
displayErrorDisplay the error message for the field.
errorMessageThe error message for the field.
errorMessageProps
Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }>
Props for the error message element.
errorsThe errors for the field.
fieldValueThe value of the field.
formattedTextThe formatted text of the number field.
incrementIncrements the number field value.
incrementButtonProps
Ref<{ 'aria-label': string; tabindex: string; role: string; "aria-disabled": true; type?: undefined; disabled?: undefined; onMousedown: () =>
Props for the increment button.
inputElReference to the input element.
inputPropsProps for the input element.
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.
setErrors
(messages: Arrayable<string>) => void
Sets the errors for the field.
setTouchedSets the touched state for the field.
setValueSets the value for the field.
validityDetailsValidity details for the number field.