NumberField
Number input fields with increment/decrement buttons, validation, and internationalized formatting
Usage
<script setup lang="ts">
import { NumberField } from '@hareui/vue'
</script><script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <NumberField class="w-full max-w-64" :default-value="1024" :min="0" name="width" label="Width" :ui="{ input: 'w-[120px]' }" /></template>Anatomy
NumberField renders a Label, a group holding the decrement button, the input and the increment button, then a Description and a FieldError. The text parts come from props; each can be replaced with a slot.
<template>
<NumberField label="…" description="…" error-message="…" :invalid="…">
<template #label /> <!-- Label content -->
<template #decrement /> <!-- decrement button content (default: minus icon) -->
<template #increment /> <!-- increment button content (default: plus icon) -->
<template #description /> <!-- Description content -->
<template #error /> <!-- FieldError content, rendered when invalid -->
</NumberField>
</template>ui key | data-slot | Element |
|---|---|---|
base | number-field | <div> root |
| — | label | Label (iff label / #label) |
group | number-field-group | <div role="group"> holding the buttons and input |
decrementButton | number-field-decrement-button | <button> (iff decrement) |
input | number-field-input | <input role="spinbutton"> |
incrementButton | number-field-increment-button | <button> (iff increment) |
| — | description | Description (iff description / #description) |
| — | field-error | FieldError (while invalid, iff error-message / #error / validation errors) |
NumberField allows users to enter numeric values with optional increment/decrement buttons. It supports internationalized formatting, validation, and keyboard navigation.
Examples
Variants
The NumberField component supports two visual variants:
primary(default) - Standard styling with shadow, suitable for most use casessecondary- Lower emphasis variant without shadow, suitable for use in surfaces
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="flex flex-col gap-4"> <NumberField :default-value="100" :min="0" name="primary-width" variant="primary" label="Primary variant" :ui="{ input: 'w-[120px]' }" /> <NumberField :default-value="100" :min="0" name="secondary-width" variant="secondary" label="Secondary variant" :ui="{ input: 'w-[120px]' }" /> </div></template>In Surface
When used inside a surface (HeroUI's Surface, here a bg-surface container), use variant="secondary" to apply the lower emphasis variant suitable for surface backgrounds.
<script setup lang="ts">import { NumberField, Surface } from '@hareui/vue'</script><template> <Surface class="flex w-full max-w-[280px] flex-col gap-4 rounded-3xl p-6"> <NumberField :default-value="1024" :min="0" name="width" variant="secondary" label="Width" description="Enter the width in pixels" :ui="{ input: 'w-full' }" /> <NumberField :default-value="0.5" :format-options="{ style: 'percent' }" :max="1" :min="0" name="percentage" :step="0.1" variant="secondary" label="Percentage" description="Value must be between 0 and 100" :ui="{ input: 'w-full' }" /> </Surface></template>With Description
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField :default-value="1024" :min="0" name="width" label="Width" description="Enter the width in pixels" :ui="{ input: 'w-[120px]' }" /> <NumberField :default-value="0.5" :format-options="{ style: 'percent' }" :max="1" :min="0" name="percentage" :step="0.1" label="Percentage" description="Value must be between 0 and 100" :ui="{ input: 'w-[120px]' }" /> </div></template>Required Field
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField required :min="0" name="quantity" label="Quantity" :ui="{ input: 'w-[120px]' }" /> <NumberField required :default-value="1" :max="10" :min="1" name="rating" label="Rating" description="Rate from 1 to 10" :ui="{ input: 'w-[120px]' }" /> </div></template>Disabled State
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField disabled :default-value="1024" :min="0" name="width" label="Width" description="Enter the width in pixels" :ui="{ input: 'w-[120px]' }" /> <NumberField disabled :default-value="0.5" :format-options="{ style: 'percent' }" :max="1" :min="0" name="percentage" :step="0.1" label="Percentage" description="Value must be between 0 and 100" :ui="{ input: 'w-[120px]' }" /> </div></template>Full Width
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="w-[400px] space-y-4"> <NumberField full-width :default-value="1024" :min="0" name="width" label="Width" /> </div></template>Validation
Set invalid with error-message to control the state yourself, or validate with required, a validate function, or a surrounding Form. validate receives the committed number (NaN while empty). min / max clamp the value rather than reporting an error.
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField invalid required :min="0" name="quantity" :model-value="-5" label="Quantity" error-message="Quantity must be greater than or equal to 0" :ui="{ input: 'w-[120px]' }" /> <NumberField invalid :format-options="{ style: 'percent' }" :max="1" :min="0" name="percentage" :step="0.1" :model-value="1.5" label="Percentage" error-message="Percentage must be between 0 and 100" :ui="{ input: 'w-[120px]' }" /> </div></template>Controlled
Bind the value with v-model to synchronize with other components or perform custom formatting. The value is undefined while the input is empty.
<script setup lang="ts">import { Button, NumberField } from '@hareui/vue'import { ref } from 'vue'const value = ref<number | null>(1024)</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField v-model="value" :min="0" name="width" label="Width" :ui="{ input: 'w-[120px]' }"> <template #description> Current value: {{ value }} </template> </NumberField> <div class="flex gap-2"> <Button variant="tertiary" @click="value = 0"> Reset to 0 </Button> <Button variant="tertiary" @click="value = 2048"> Set to 2048 </Button> </div> </div></template>Step Values
Configure increment/decrement step values for precise control.
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField :default-value="0" :max="100" :min="0" name="step1" :step="1" label="Step: 1" description="Increments by 1" :ui="{ input: 'w-[120px]' }" /> <NumberField :default-value="0" :max="100" :min="0" name="step5" :step="5" label="Step: 5" description="Increments by 5" :ui="{ input: 'w-[120px]' }" /> <NumberField :default-value="0" :max="100" :min="0" name="step10" :step="10" label="Step: 10" description="Increments by 10" :ui="{ input: 'w-[120px]' }" /> </div></template>Format Options
Format numbers as currency, percentages, decimals, or units with internationalization support. Pass locale to override the ConfigProvider locale.
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField :default-value="99" :min="0" name="currency-eur" :format-options="{ currency: 'EUR', currencySign: 'accounting', style: 'currency' }" label="Currency (EUR - Accounting)" description="Accounting format with EUR currency" :ui="{ input: 'w-[120px]' }" /> <NumberField :default-value="99.99" :min="0" name="currency-usd" :format-options="{ currency: 'USD', style: 'currency' }" label="Currency (USD)" description="Standard USD currency format" :ui="{ input: 'w-[120px]' }" /> <NumberField :default-value="0.5" :min="0" :max="1" :step="0.01" name="percentage" :format-options="{ style: 'percent' }" label="Percentage" description="Percentage format (0-1, where 0.5 = 50%)" :ui="{ input: 'w-[120px]' }" /> <NumberField :default-value="1234.56" :min="0" name="decimal" :format-options="{ maximumFractionDigits: 2, minimumFractionDigits: 2, style: 'decimal' }" label="Decimal (2 decimal places)" description="Decimal format with 2 decimal places" :ui="{ input: 'w-[120px]' }" /> <NumberField :default-value="1000" :min="0" name="unit" :format-options="{ style: 'unit', unit: 'kilogram', unitDisplay: 'short' }" label="Unit (Kilograms)" description="Unit format with kilograms" :ui="{ input: 'w-[120px]' }" /> </div></template>Form Example
Complete form integration with validation and submission handling.
<script setup lang="ts">import { Button, NumberField } from '@hareui/vue'import { computed, ref } from 'vue'const STOCK_AVAILABLE = 3const value = ref<number | null>()const isSubmitting = ref(false)const isOutOfStock = computed(() => value.value != null && value.value > STOCK_AVAILABLE)function handleSubmit(e: Event) { e.preventDefault() if (value.value == null || value.value < 1 || value.value > STOCK_AVAILABLE) return isSubmitting.value = true // Simulate API call setTimeout(() => { console.log('Order submitted:', { quantity: value.value }) value.value = undefined isSubmitting.value = false }, 1500)}</script><template> <form class="flex w-[280px] flex-col gap-4" @submit="handleSubmit"> <NumberField v-model="value" required :invalid="isOutOfStock" :max="5" :min="1" name="quantity" label="Order quantity" :description="`Only ${STOCK_AVAILABLE} items available`" :error-message="`Only ${STOCK_AVAILABLE} items left in stock`" :ui="{ input: 'w-[120px]' }" /> <Button class="w-full" :disabled="value == null || value < 1 || value > STOCK_AVAILABLE" :pending="isSubmitting" type="submit" variant="primary" > {{ isSubmitting ? 'Processing...' : 'Place Order' }} </Button> </form></template>With Validation
Implement custom validation logic with controlled values.
<script setup lang="ts">import { NumberField } from '@hareui/vue'import { computed, ref } from 'vue'const value = ref<number | null>()const isInvalid = computed(() => value.value != null && (value.value < 0 || value.value > 100))</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField v-model="value" required :format-options="{ style: 'percent' }" :invalid="isInvalid" :max="1" :min="0" name="percentage" :step="0.1" label="Percentage" description="Enter a value between 0 and 100" error-message="Percentage must be between 0 and 100" :ui="{ input: 'w-[120px]' }" /> </div></template>Custom Icons
Customize the increment and decrement button icons with the #increment and #decrement slots.
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <div class="flex w-full max-w-64 flex-col gap-4"> <NumberField :default-value="1024" :min="0" name="width" label="Width (Custom Icons)" description="Custom icon children" :ui="{ input: 'w-[120px]' }"> <template #decrement> <svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M6.75 11a4.25 4.25 0 1 0 0-8.5a4.25 4.25 0 0 0 0 8.5m0 1.5a5.73 5.73 0 0 0 3.501-1.188l2.719 2.718a.75.75 0 1 0 1.06-1.06l-2.718-2.719A5.75 5.75 0 1 0 6.75 12.5m-2-6.5a.75.75 0 0 0 0 1.5h4a.75.75 0 0 0 0-1.5z" fill="currentColor" fill-rule="evenodd" /> </svg> </template> <template #increment> <svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M6.75 11a4.25 4.25 0 1 0 0-8.5a4.25 4.25 0 0 0 0 8.5m0 1.5a5.73 5.73 0 0 0 3.501-1.188l2.719 2.718a.75.75 0 1 0 1.06-1.06l-2.718-2.719A5.75 5.75 0 1 0 6.75 12.5m.75-7.75a.75.75 0 0 0-1.5 0V6H4.75a.75.75 0 0 0 0 1.5H6v1.25a.75.75 0 0 0 1.5 0V7.5h1.25a.75.75 0 0 0 0-1.5H7.5z" fill="currentColor" fill-rule="evenodd" /> </svg> </template> </NumberField> </div></template>With Chevrons
Use chevron icons in a vertical layout for a different visual style. HeroUI wraps the buttons in an extra <div>; here the ui classes place them in the group's grid.
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <!-- HeroUI stacks the buttons in a wrapper <div>; HareUI renders the group itself, so the grid places them instead --> <NumberField class="w-full max-w-64" :default-value="99" :min="0" name="amount" :format-options="{ currency: 'EUR', currencySign: 'accounting', style: 'currency' }" label="Number field with chevrons" :ui="{ group: 'grid-rows-2 has-[[slot=decrement]]:has-[[slot=increment]]:grid-cols-[1fr_24px]', input: 'col-start-1 row-span-2 row-start-1', incrementButton: 'col-start-2 row-start-1 flex h-full w-6 items-center justify-center rounded-none border-0 border-s border-field-placeholder/15 pt-0.5 text-sm', decrementButton: 'col-start-2 row-start-2 flex h-full w-6 items-center justify-center rounded-none border-0 border-s border-field-placeholder/15 pb-0.5 text-sm', }" > <template #increment> <svg aria-hidden="true" height="11" viewBox="0 0 16 16" width="11" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M13.03 10.53a.75.75 0 0 1-1.06 0L8 6.56l-3.97 3.97a.75.75 0 1 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1 0 1.06" fill="currentColor" fill-rule="evenodd" /> </svg> </template> <template #decrement> <svg aria-hidden="true" height="11" viewBox="0 0 16 16" width="11" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M2.97 5.47a.75.75 0 0 1 1.06 0L8 9.44l3.97-3.97a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 0-1.06" fill="currentColor" fill-rule="evenodd" /> </svg> </template> </NumberField></template>Render Function
HeroUI's render prop replaces the root element. In Vue, attributes you pass to NumberField (other than aria-label / aria-labelledby, which go to the input) fall through to the root div.
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <!-- Attributes other than aria-label/aria-labelledby fall through to the root element (HeroUI's `render` prop) --> <NumberField class="w-full max-w-64" data-custom="foo" :default-value="1024" :min="0" name="width" label="Width" :ui="{ input: 'w-[120px]' }" /></template>Customization
Tailwind CSS
<script setup lang="ts">import { NumberField } from '@hareui/vue'</script><template> <NumberField class="w-full max-w-48" :default-value="2" :min="1" name="guests" variant="secondary" label="Guests" :ui="{ group: 'rounded-xl bg-default', decrementButton: 'text-muted hover:text-foreground', input: 'text-center tabular-nums', incrementButton: 'text-muted hover:text-foreground', }" /></template>Global Configuration
app.use(createHareUI({
ui: { numberField: { slots: { group: 'rounded-xl', input: 'text-center' } } },
}))Styling Reference
Slots
base→[data-slot="number-field"]– root container (flex flex-col gap-1)group→[data-slot="number-field-group"]– container for the input and buttons, with border and background stylinginput→[data-slot="number-field-input"]– the numeric inputincrementButton→[data-slot="number-field-increment-button"]– button that increments the valuedecrementButton→[data-slot="number-field-decrement-button"]– button that decrements the value
The group's background follows --number-field-group-bg-current, so a plain bg-* class in ui.group wins in every state. The secondary variant reads --number-field-group-bg, --number-field-group-bg-hover and --number-field-group-bg-focus.
Note: The child components (Label, Description, FieldError) have their own themes. See their respective pages for customization options.
Interactive States
NumberField sets these data attributes:
- Invalid:
[data-invalid="true"]on the root and the group - Automatically hides the description when invalid - Disabled:
[data-disabled="true"]on the root,[data-disabled]on the group, input and buttons - Required:
[data-required="true"]on the root - Shows the label's required asterisk - Read Only:
[data-readonly="true"]on the root,[data-readonly]on the group and input - Focus Within:
[data-focus-within="true"]on the group - Applied when the input or buttons are focused - Focus Visible:
[data-focus-visible="true"]on the group - Applied when focus is visible (keyboard navigation) - Pressed:
[data-pressed="true"]on a button while it's held
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | - | Label text rendered above the group. |
description | string | - | Helper text rendered below the group. Hidden while the field is invalid. |
errorMessage | string | - | Error message rendered below the group while invalid. Defaults to the validation errors. |
id | string | useId() | The id of the input; label, description and error ids derive from it. |
name | string | - | The name of the value, used when submitting a form. |
placeholder | string | - | Temporary text shown when the input is empty. |
defaultValue | number | - | The initial value when uncontrolled (no v-model). |
min | number | - | The smallest allowed value. |
max | number | - | The largest allowed value. |
step | number | 1 (0.01 with `formatOptions.style: 'percent'`) | The amount the value changes per increment/decrement. When set, values are also snapped to it
(relative to min); when unset, they're only clamped to min/max. |
formatOptions | Intl.NumberFormatOptions | - | Formatting options for the displayed value (Intl.NumberFormat). |
locale | string | - | Locale used to format and parse the value. Defaults to the ConfigProvider locale. |
variant | "primary" | "secondary" | 'primary' | Visual variant of the group. |
fullWidth | boolean | false | Whether the field takes the full width of its container. |
increment | boolean | true | Whether to render the increment button. |
decrement | boolean | true | Whether to render the decrement button. |
disabled | boolean | false | Whether the field is disabled. |
invalid | boolean | undefined | Whether the field is invalid. Overrides validation when set. |
required | boolean | false | Whether the field is required. |
readonly | boolean | false | Whether the value can be selected but not changed. |
validate | ValidateFn<number> | - | Validates the committed value (NaN while empty). Return an error message (or several) when invalid. |
validationBehavior | ValidationBehavior | 'native' | native blocks form submission and shows errors on commit or submit; aria shows errors in realtime.
Defaults to the surrounding Form. |
ui | ComponentSlots<{ slots: { base: string; group: string[]; input: string[]; incrementButton: string[]; decrementButton: string[]; }; variants: { variant: { primary: { base: string; }; secondary: { group: string[]; }; }; fullWidth: { false: { base: string; }; true: { base: string; group: string; }; }; }; defaultVariants: { fullWidth: boolean; variant: string; }; }> | - | Per-slot class overrides. |
modelValue | number | null | - | The numeric value; undefined while the input is empty. |
Slots
| Slot | Props | Description |
|---|---|---|
label | any | Label content. Replaces the label prop. |
increment | any | Increment button content. Defaults to a plus icon. |
decrement | any | Decrement button content. Defaults to a minus icon. |
description | any | Description content. Replaces the description prop. |
error | ValidationResult | Error content, rendered while invalid. Replaces the errorMessage prop. |
Emits
| Event | Payload | Description |
|---|---|---|
update:modelValue | [value: number | null | undefined] | - |
Accessibility
- The input has
role="spinbutton"witharia-valuenow,aria-valueminandaria-valuemax - The label's
forpoints to the input id (auto-generated withuseId()unlessidis set) - The input's
aria-describedbylists the ids of the rendered description (<id>-description) and error (<id>-error) invalidsetsaria-invalid="true"on the input; the error is announced viarole="alert"- Keyboard: ↑/↓ step by
step, PageUp/PageDown by 10 steps, Home/End jump tomin/max, Enter commits typed text - The buttons are labelled "Increase"/"Decrease", aren't in the tab order, and repeat while held







