← Blog
16 Sept 2026Vue 3Form validationAccessibilityNumeric inputsSchema-driven forms

Numeric Textbox in Vue: Validation, Formatting, and Accessible Number Entry

Build a production-ready numeric textbox in Vue 3 with validation, decimal and currency formatting, accessibility, mobile input, and server checks.

Numeric Textbox in Vue: Validation, Formatting, and Accessible Number Entry

A numeric textbox should accept the value your domain actually needs, preserve what a person is still typing, and only submit a normalized value that passes server validation. In Vue 3, that usually means choosing between a native input[type="number"] for incremental quantities and a text input with inputmode="decimal" for formatted decimals, currency, identifiers, and other cases where a spinbutton is the wrong interaction.

This guide builds both patterns. We will use Vue 3, the Composition API, and standard browser validation. For the DOM Studio example near the end, install and configure its Vue package in your application first.

Table of contents

1. Decide whether you need a number input or a numeric text box

Start with the meaning of the value, not the characters it contains.

Use a native number input when the value is a measurable quantity that users may reasonably increment or decrement: item quantity, seats, percentage, temperature, or a bounded setting. Native number inputs support min, max, and step, and browsers generally provide numeric-oriented mobile keyboards and spin controls. The default step is 1, so configure a decimal step explicitly when fractions are valid.

<script setup lang="ts">
import { computed, ref } from 'vue'

const quantity = ref<number | ''>(1)

const quantityError = computed(() => {
  if (quantity.value === '') return 'Enter a quantity.'
  if (quantity.value < 1) return 'Quantity must be at least 1.'
  if (quantity.value > 99) return 'Quantity cannot exceed 99.'
  return ''
})
</script>

<template>
  <label for="quantity">Quantity</label>
  <input
    id="quantity"
    v-model.number="quantity"
    type="number"
    name="quantity"
    min="1"
    max="99"
    step="1"
    required
    :aria-invalid="Boolean(quantityError)"
    aria-describedby="quantity-hint quantity-error"
  />
  <p id="quantity-hint">Choose between 1 and 99 items.</p>
  <p v-if="quantityError" id="quantity-error" role="alert">
    {{ quantityError }}
  </p>
</template>

Expected result: the field accepts whole quantities, exposes its range in the UI, and has a clear client-side error state.

Use a text input instead when the input is numeric-looking but should not behave as a number spinner. Common examples include postal codes, account numbers, card numbers, one-time codes, currency that needs grouping or a prefix, and decimals whose in-progress states matter. A native number input has an implicit spinbutton role, so it is not the best choice when incrementing and decrementing are not meaningful.

For a digits-only identifier, use type="text" with inputmode="numeric". For a decimal entry field, use inputmode="decimal". Treat the keyboard hint as a convenience, not validation: browser input modes do not guarantee every keyboard or block pasted content.

Conceptual comparison of raw numeric input and formatted display value

Troubleshooting: Do not use type="number" for a postal code just because it contains digits. Leading zeroes and fixed formatting are part of the value, so keep it as text.

2. Keep the editable string separate from the submitted value

A reliable numeric textbox has two states:

  • Display state: the exact string currently being edited, including valid partial entries such as 12..
  • Committed state: the normalized value your form can validate and submit, such as 12.00.

This separation avoids a common frustration: reformatting on every keystroke. If we turn 12. into $12.00 while the user is still typing, we move the caret, make decimal entry awkward, and can lose the intended intermediate state.

Vue’s .number modifier is convenient for simple fields, but it is not a complete parsing policy. When parsing fails, Vue keeps the original string, and clearing a number-bound input produces an empty string. For fields that require decimal rules or a formatted display, make that raw string explicit.

Here is a small USD example that accepts up to two decimal places. It preserves the raw value while focused, validates without rewriting it, and formats only after a successful blur.

<script setup lang="ts">
import { computed, ref } from 'vue'

const rawAmount = ref('')
const committedAmount = ref<string | null>(null)
const amountError = ref('')

function normalizeUsd(value: string): string | null {
  const trimmed = value.trim()

  // Keep partial values in rawAmount, but do not commit them.
  if (!/^\d+(?:\.\d{1,2})?$/.test(trimmed)) return null

  const [whole, fraction = ''] = trimmed.split('.')
  return `${whole}.${fraction.padEnd(2, '0')}`
}

function formatUsd(canonical: string): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(Number(canonical))
}

function onAmountBlur() {
  const normalized = normalizeUsd(rawAmount.value)

  if (!normalized) {
    amountError.value = 'Enter an amount with no more than two decimal places.'
    return
  }

  amountError.value = ''
  committedAmount.value = normalized
  rawAmount.value = formatUsd(normalized)
}

function onAmountFocus() {
  if (committedAmount.value) rawAmount.value = committedAmount.value
}
</script>

<template>
  <label for="amount">Monthly budget</label>
  <input
    id="amount"
    :value="rawAmount"
    type="text"
    inputmode="decimal"
    autocomplete="off"
    aria-describedby="amount-hint amount-error"
    :aria-invalid="Boolean(amountError)"
    @input="rawAmount = ($event.target as HTMLInputElement).value"
    @focus="onAmountFocus"
    @blur="onAmountBlur"
  />
  <p id="amount-hint">Enter a US dollar amount, for example 19.99.</p>
  <p v-if="amountError" id="amount-error" role="alert">
    {{ amountError }}
  </p>
</template>

Expected result: a user can type 19., correct it to 19.99, and only sees $19.99 after leaving the field. Your submission payload uses committedAmount, not the formatted string.

Troubleshooting: this example deliberately targets an en-US dollar field. Do not strip commas, currency symbols, or localized decimal separators with a broad replacement rule and assume the result is correct worldwide. Define the locales and currencies your product supports, then test parsing, display, and server storage for each one. For high-value or arbitrary-precision amounts, send a canonical decimal string or minor units rather than relying on JavaScript floating-point arithmetic.

3. Validate range, precision, and step without changing what the user typed

Validation has three separate jobs:

  1. Syntax validation: Is the raw entry structurally acceptable?
  2. Domain validation: Is the resulting value in range and at an allowed increment?
  3. Server validation: Does the submitted value satisfy the same business rules after the request reaches your backend?

For native quantity fields, HTML constraints make a useful first layer. A min or max violation makes the input invalid, while step defines which increments are valid relative to the step base. That is why min="0" step="0.25" is a different rule from min="0" step="1".

For a custom decimal textbox, parse once at a predictable boundary, usually blur and submit. Show a useful error while leaving the raw text intact. This helper checks a range and quarter-step rule after the raw string has passed your syntax policy.

function validateQuarterStep(canonical: string) {
  const value = Number(canonical)

  if (!Number.isFinite(value)) return 'Enter a valid amount.'
  if (value < 0 || value > 1_000) return 'Enter an amount from 0 to 1,000.'

  const scaled = value * 4
  if (!Number.isInteger(scaled)) return 'Use increments of 0.25.'

  return ''
}

Call this after normalizeUsd() and before you set committedAmount. In a production currency workflow, prefer decimal or integer-minor-unit arithmetic for the final step check when precision requirements exceed the safe range of the chosen representation.

Workflow from numeric entry through client and server validation to stored value

Expected result: browser constraints give immediate guidance for simple number inputs, while your parser gives formatted text inputs an explicit and testable policy.

Troubleshooting: pattern does not validate a native type="number" field. If you need a precise character-level policy, use a text input and validate the string yourself.

4. Build labels, errors, and keyboard behavior into the field

A production numeric textbox needs more than a red border. Give it a visible label, short instructions before the user needs them, and an error message connected to the input.

Use these checks in every implementation:

  • Associate a visible <label> with the control using matching for and id values.
  • Use aria-describedby to connect help text and an inline error to the field.
  • Set aria-invalid="true" only when the current field has an error.
  • Make every custom increment or decrement button focusable, labeled, and operable with the keyboard.
  • Do not rely on color alone to communicate invalid state.
  • On a failed submit, present a concise error summary and move focus to it or to the first invalid field.

For simple native controls, start with the browser’s interaction rather than recreating a spinbutton. If you create custom stepper buttons, preserve predictable keyboard behavior and keep their accessible names explicit.

<template>
  <div class="field" :class="{ 'field--invalid': quantityError }">
    <label for="seats">Team seats</label>
    <p id="seats-hint">Choose from 1 to 50 seats.</p>

    <input
      id="seats"
      v-model.number="quantity"
      type="number"
      min="1"
      max="50"
      step="1"
      :aria-describedby="quantityError ? 'seats-hint seats-error' : 'seats-hint'"
      :aria-invalid="quantityError ? 'true' : undefined"
    />

    <p v-if="quantityError" id="seats-error" role="alert">
      {{ quantityError }}
    </p>
  </div>
</template>

Expected result: keyboard and assistive-technology users receive the field purpose, range guidance, and a specific correction path.

Troubleshooting: avoid announcing validation errors on every keystroke when the person is still entering a valid partial number. Validate syntax as they type only when the feedback is calm and actionable. Validate a complete value on blur and always recheck on submit.

If you want a general Vue 3 walkthrough of client-side form validation, this independent video is a useful companion. Adapt its validation concepts to the numeric parsing and accessibility rules in this guide.

5. Revalidate the normalized value on the server

Client-side checks improve completion, but they do not establish trust. A user can edit HTML attributes, send a handcrafted request, or bypass the browser entirely. Send the committed canonical value, then parse and validate it again on the server.

Keep the server contract precise. For a USD field, the client might submit a JSON string such as "19.99", not "$19.99". The backend should reject values that fail syntax, exceed permitted scale, fall outside its range, or do not meet the required increment.

// Server-side TypeScript pseudocode
function parseUsdAmount(value: unknown): { ok: true; value: string } | { ok: false; error: string } {
  if (typeof value !== 'string') {
    return { ok: false, error: 'Amount must be submitted as a decimal string.' }
  }

  if (!/^\d+(?:\.\d{1,2})?$/.test(value)) {
    return { ok: false, error: 'Amount must have up to two decimal places.' }
  }

  const [whole, fraction = ''] = value.split('.')
  const cents = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0'))

  if (cents < 0n || cents > 100_000n) {
    return { ok: false, error: 'Amount is outside the allowed range.' }
  }

  return { ok: true, value: `${whole}.${fraction.padEnd(2, '0')}` }
}

Expected result: the server stores or forwards a predictable canonical value and returns field-level errors when a request fails validation.

Troubleshooting: map a server field error back to the field’s error state instead of showing only a generic toast. The person should be able to find the problem, understand it, fix it, and resubmit without re-entering unrelated values.

6. Apply the pattern to schema-driven DOM Studio forms

When your form is defined from metadata, keep the data rules in the schema and add UI-specific details through adapter options. This lets a numeric field retain its number constraints whether you render it by hand or generate it from a definition.

DOM Studio’s Number Input component documents a Vue v-model numeric value with label, description, min, max, and step props. Its form documentation shows DomForm collecting named field values and errors, plus schema adapters that turn number constraints into input props and validators.

<script setup lang="ts">
import { ref } from 'vue'
import { DomButton, DomForm, forms, zodSchemaToChildren } from '@getdom/studio/vue'

const account = ref({ seats: 5 })

const accountSchema = {
  type: 'object',
  shape: {
    seats: {
      type: 'number',
      min: 1,
      max: 50,
      description: 'Team seats',
    },
  },
}

const children = zodSchemaToChildren(accountSchema, {
  fields: {
    seats: {
      description: 'Choose from 1 to 50 seats.',
      props: { step: 1 },
    },
  },
})

async function submit() {
  const valid = await forms.account?.validate()
  if (!valid) return

  // Send account.value.seats to a server that validates the same range.
}
</script>

<template>
  <DomForm name="account" v-model="account" :children="children">
    <DomButton type="button" @click="submit">Save settings</DomButton>
  </DomForm>
</template>

Expected result: your schema owns the numeric range while your adapter supplies presentation details such as step size and field help.

Troubleshooting: a schema number is a strong fit for quantities such as seats. For currency or locale-sensitive decimal text, use a field component and parser that deliberately model the raw display string, then normalize before writing the canonical value into your form model.

Frequently asked questions

Should I use v-model.number for every numeric field?

No. It is a good fit for straightforward quantities, but it does not replace a parsing policy for formatted decimals. Vue can return an empty string for a cleared field and preserves the original string when parsing fails, so model the value accordingly.

Can I hide the arrows on input[type="number"]?

You can style browser controls, but do not hide them simply to make a number input look like a generic text field. If stepping is not useful, choose a text input with an appropriate inputmode and explicit validation instead.

Is min, max, and step enough validation?

They are a helpful client-side layer, not a security boundary. Repeat syntax, range, precision, and business-rule checks on the server before storing or acting on the value.

How do I support locales that use commas for decimals?

Define the locales you support, use locale-aware formatting for display, and use a parser designed for those locale rules. Keep the server API canonical, such as a decimal string or integer minor units, so display conventions do not leak into storage.

Build the right numeric field for the job

A good numeric textbox is not defined by whether it blocks letters. It is defined by a clear domain rule, a forgiving editing experience, explicit validation, accessible feedback, and a server-side contract that does not trust the browser.

Start with native type="number" for bounded, incrementable quantities. Move to a text-based numeric field when formatting, partial entry, identifiers, or locale-aware parsing demand it. When you want these controls to participate in a generated Vue form, explore DOM Studio’s number input and schema-driven form patterns.