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 keydata-slotElement
basenumber-field<div> root
—labelLabel (iff label / #label)
groupnumber-field-group<div role="group"> holding the buttons and input
decrementButtonnumber-field-decrement-button<button> (iff decrement)
inputnumber-field-input<input role="spinbutton">
incrementButtonnumber-field-increment-button<button> (iff increment)
—descriptionDescription (iff description / #description)
—field-errorFieldError (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 cases
  • secondary - 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.

Enter the width in pixels
Value must be between 0 and 100
<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

Enter the width in pixels
Value must be between 0 and 100
<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

Rate from 1 to 10
<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

Enter the width in pixels
Value must be between 0 and 100
<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.

Current value: 1024
<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.

Increments by 1
Increments by 5
Increments by 10
<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.

Accounting format with EUR currency
Standard USD currency format
Percentage format (0-1, where 0.5 = 50%)
Decimal format with 2 decimal places
Unit format with kilograms
<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.

Only 3 items available
<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.

Enter a value between 0 and 100
<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.

Custom icon children
<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 styling
  • input → [data-slot="number-field-input"] – the numeric input
  • incrementButton → [data-slot="number-field-increment-button"] – button that increments the value
  • decrementButton → [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

PropTypeDefaultDescription
labelstring-Label text rendered above the group.
descriptionstring-Helper text rendered below the group. Hidden while the field is invalid.
errorMessagestring-Error message rendered below the group while invalid. Defaults to the validation errors.
idstringuseId()The id of the input; label, description and error ids derive from it.
namestring-The name of the value, used when submitting a form.
placeholderstring-Temporary text shown when the input is empty.
defaultValuenumber-The initial value when uncontrolled (no v-model).
minnumber-The smallest allowed value.
maxnumber-The largest allowed value.
stepnumber1 (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.
formatOptionsIntl.NumberFormatOptions-Formatting options for the displayed value (Intl.NumberFormat).
localestring-Locale used to format and parse the value. Defaults to the ConfigProvider locale.
variant"primary" | "secondary"'primary'Visual variant of the group.
fullWidthbooleanfalseWhether the field takes the full width of its container.
incrementbooleantrueWhether to render the increment button.
decrementbooleantrueWhether to render the decrement button.
disabledbooleanfalseWhether the field is disabled.
invalidbooleanundefinedWhether the field is invalid. Overrides validation when set.
requiredbooleanfalseWhether the field is required.
readonlybooleanfalseWhether the value can be selected but not changed.
validateValidateFn<number>-Validates the committed value (NaN while empty). Return an error message (or several) when invalid.
validationBehaviorValidationBehavior'native'native blocks form submission and shows errors on commit or submit; aria shows errors in realtime. Defaults to the surrounding Form.
uiComponentSlots<{ 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.
modelValuenumber | null-The numeric value; undefined while the input is empty.

Slots

SlotPropsDescription
labelanyLabel content. Replaces the label prop.
incrementanyIncrement button content. Defaults to a plus icon.
decrementanyDecrement button content. Defaults to a minus icon.
descriptionanyDescription content. Replaces the description prop.
errorValidationResultError content, rendered while invalid. Replaces the errorMessage prop.

Emits

EventPayloadDescription
update:modelValue[value: number | null | undefined]-

Accessibility

  • The input has role="spinbutton" with aria-valuenow, aria-valuemin and aria-valuemax
  • The label's for points to the input id (auto-generated with useId() unless id is set)
  • The input's aria-describedby lists the ids of the rendered description (<id>-description) and error (<id>-error)
  • invalid sets aria-invalid="true" on the input; the error is announced via role="alert"
  • Keyboard: ↑/↓ step by step, PageUp/PageDown by 10 steps, Home/End jump to min/max, Enter commits typed text
  • The buttons are labelled "Increase"/"Decrease", aren't in the tab order, and repeat while held

On this page

No Headings