← Blog
14 Aug 2026vue js form examplevue v-modelvue form validationDOM Studio VueTailwind form styling

Vue JS Form Example: Build, Validate, and Ship in 2026

A hands-on Vue JS form example with v-model, validation, dynamic fields, accessibility, and DOM Studio wrappers styled with Tailwind.

Vue JS Form Example: Build, Validate, and Ship in 2026

You’ve got a Vue form working locally, and the minute you try to ship it, the simple demo starts asking for more. The contact form needs typed state, validation that doesn’t feel brittle, a backend path, accessible errors, and styling that looks like it belongs on a real product page instead of a tutorial. That’s the gap this Vue JS form example closes, from the first v-model binding to a form you can launch.

Table of Contents

What a Real Vue JS Form Example Looks Like

A tutorial form is easy to admire and hard to deploy. A production form has to survive backend wiring, browser quirks, assistive tech, dynamic fields, and the awkward moment when a server says the email is already taken.

The practical target here is a single Vue 3 form that starts small and ends usable in a dashboard, checkout flow, or marketing site. Vue’s forms guide keeps form handling in the framework’s core essentials, and it treats v-model as the standard binding pattern, including modifiers like .number for automatic numeric typecasting when you need quantities, ages, or prices to stay numeric in state (Vue forms guide).

Practical rule: if the form data matters to business logic, keep the component state as the source of truth from the start.

This implementation works with either the Composition API or the Options API, depending on what keeps the code easiest to read. The point isn’t to show off syntax, it’s to make the data flow obvious enough that the next developer can extend the form without breaking it.

The form you’ll end up with has a typed state object, reactive error handling, conditional fields, field-level backend error mapping, and a wrapper-friendly structure that can scale into larger UI systems. It also uses the same patterns Vue’s own form guidance and test conventions lean on, where you set values on DOM inputs and assert emitted events instead of reaching into component internals.

Building the Basic Form with v-model

The cleanest Vue JS form example starts with a single reactive object and binds every field to it. That gives you one source of truth, which matters because Vue ignores conflicting initial value, checked, or selected attributes once v-model is active, so the component state has to own the initial values.

A hand pointing at a computer monitor displaying Vue.js code for a user contact form component.

If you’re working through the control wrappers later, the basic binding pattern is the same as the one shown in DOM Studio text input docs, only here the state stays local and explicit.

<script setup>
import { reactive } from 'vue'

const form = reactive({
  name: '',
  email: '',
  message: '',
  topic: 'support',
})
function handleSubmit() {
  console.log('Submitted payload', { ...form })
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <label for="name">Name</label>
    <input id="name" v-model="form.name" type="text" autocomplete="name">

    <label for="email">Email</label>
    <input id="email" v-model="form.email" type="email" autocomplete="email">

    <label for="topic">Topic</label>
    <select id="topic" v-model="form.topic">
      <option value="support">Support</option>
      <option value="sales">Sales</option>
      <option value="billing">Billing</option>
    </select>

    <label for="message">Message</label>
    <textarea id="message" v-model="form.message" rows="5"></textarea>

    <button type="submit">Send</button>
  </form>
</template>

That form does two important things. The UI updates the state on every input event, and the state pushes back into the inputs on each render, which is why v-model feels like a real contract instead of a loose convention.

The rendered result is simple, but it’s already useful: typing into the fields updates form.name, form.email, form.topic, and form.message immediately, and @submit.prevent stops the browser from refreshing the page. In production, that matters because the browser should never get the chance to erase the current state before your handler runs.

Use .trim when whitespace is noise, .number when the field should stay numeric, and .lazy when you want updates to wait until change events instead of firing on every keystroke.

Adding Validation That Actually Catches Mistakes

Validation works best when it has layers, not one giant rule blob. Vue’s form bindings already keep the component state aligned with the input, so the next step is to let the browser catch what it knows, then let reactive logic catch the rest, then stop submission if anything still looks off.

Native constraints first

Required fields, email type checks, minimum lengths, and patterns belong in the markup because the browser can enforce them early. That keeps the form honest before custom logic even runs, and it keeps simple mistakes visible without extra code.

A better pattern is to expose field-specific feedback from a computed errors object. That way the template reads cleanly, and the messages stay tied to the actual rule that failed instead of collapsing into a generic “Something is wrong” notice.

<script setup>
import { computed, reactive } from 'vue'

const form = reactive({
  name: '',
  email: '',
  message: '',
})

const errors = computed(() => {
  const next = {}
  if (!form.name.trim()) next.name = 'Name is required.'
  if (!form.email.trim()) next.email = 'Email is required.'
  else if (!form.email.includes('@')) next.email = 'Enter a valid email address.'
  if (form.message.trim().length < 10) next.message = 'Message needs more detail.'
  return next
})

function handleSubmit() {
  if (Object.keys(errors.value).length) return
  console.log('Submit only after validation passes')
}
</script>

Child inputs need the model contract

Reusable wrappers are where teams usually break validation. If a child component mutates its own local copy of the value, the parent state and the displayed field drift apart, and your validation logic starts reading stale data.

The safer pattern is modelValue plus update:modelValue, which keeps the parent canonical and makes the wrapper behave like a native input. That lines up with the component-form pattern described in Vue-focused guidance, where form data flows upward and the parent owns the source of truth (component composition guidance).

Keep the error message specific to the rule. Users fix “Enter a valid email address” faster than a generic error banner.

The accessibility layer follows naturally from there. Bind aria-invalid when a field has an error, point aria-describedby to the matching error message, and render that message close to the field so screen readers can announce it in context. That’s not decoration, it’s part of how the form stays usable under pressure.

Dynamic Fields and the Form Component Pattern

Forms get messy the moment users can add more than one thing. A shipping flow might need multiple addresses, a sales request might need several contacts, and an order form often needs repeatable line items.

Model those repeatable values as an array of objects, not as a pile of unrelated fields. That makes additions and removals predictable, and it keeps Vue’s reactivity intact when rows move around.

Array-backed fields stay manageable

Use v-for with a stable key, then add or remove rows with splice or filter. The key part is stability, because if the key changes every time the array order changes, the DOM starts reusing the wrong input state and users end up editing the wrong row.

<script setup>
import { reactive } from 'vue'

const form = reactive({
  contacts: [{ name: '', phone: '' }],
})

function addContact() {
  form.contacts.push({ name: '', phone: '' })
}

function removeContact(index) {
  form.contacts.splice(index, 1)
}
</script>

<template>
  <section>
    <div v-for="(contact, index) in form.contacts" :key="index">
      <input v-model="contact.name" placeholder="Name">
      <input v-model="contact.phone" placeholder="Phone">
      <button type="button" @click="removeContact(index)">Remove</button>
    </div>
    <button type="button" @click="addContact">Add contact</button>
  </section>
</template>

Keep the form in one component boundary

The more fields a form has, the more value there is in isolating it as its own component. That keeps validation, rendering, and submission logic together, instead of scattering field rules across a page shell and a half-dozen children.

The same pattern improves testability because Vue Test Utils can assert emitted events and payloads without poking at private internals. That’s a much better fit for forms than reaching into nested component state and hoping the implementation stays frozen.

When the child component emits update:modelValue, the parent updates immediately and nothing gets out of sync. That also makes the component easier to reuse in other flows, because the contract is explicit rather than implied.

Styling the Form with DOM Studio Wrappers and Tailwind

Once the data flow is stable, the visual layer should support it instead of getting in the way. Tailwind CSS 4 keeps spacing, focus rings, and state styling close to the markup, which makes form work easier to read and harder to break. For a quick refresher on the utility setup, see our Tailwind CSS 4 primer.

A stronger production approach is to wrap form controls with DOM Studio primitives such as <dom-dropdown> and <dom-dialog>, then style the surrounding layout with utility classes. The practical advantage is that the primitives bring framework-agnostic behavior, while the Vue integration layer adds reactive props, v-model support, and slots, so you do not have to rebuild ARIA logic or keyboard handling by hand.

What that looks like in practice

The wrapper can feel like a normal Vue component while still using standards-based behavior underneath. That keeps Vue ergonomics intact and preserves the accessibility work that should already be present in the control.

Screenshot from https://getdom.studio

The payoff is consistency. Focus rings, spacing, and dialog behavior stay aligned with the rest of the UI, and the form does not need one-off code for a dropdown or modal just because the core controls are custom.

<template>
  <form class="space-y-4 rounded-xl bg-white p-6 text-slate-900 shadow-sm dark:bg-slate-900 dark:text-slate-100">
    <label class="block">
      <span class="mb-1 block text-sm font-medium">Department</span>
      <dom-dropdown class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black dark:border-slate-700"></dom-dropdown>
    </label>

    <button class="rounded-lg bg-black px-4 py-2 text-white hover:opacity-90">Save</button>
  </form>
</template>

That template shows Vue markup, a custom element, and Tailwind utilities living together cleanly. The setup also stays easy to trim because the modules are tiny and tree-shakeable, which matters when a form sits on a high-traffic page and should not drag in extra UI code.

Wiring Up Submission and the Backend Gap

Most tutorials stop here, and real products start failing. A form that looks fine in Vue still needs somewhere to send the data, and static or jamstack sites are where that gap becomes obvious because the frontend has state, but not necessarily a backend route ready to receive it.

Submit, then map server errors back to fields

Intercept submission with @submit.prevent, build the payload from the reactive object, and send it with fetch to a serverless endpoint. Don’t collapse every backend problem into one toast, because field-level failures like “email already in use” belong beside the relevant input.

<script setup>
import { reactive, ref } from 'vue'

const form = reactive({
  name: '',
  email: '',
  message: '',
})
const errors = reactive({})
const submitting = ref(false)

async function handleSubmit() {
  submitting.value = true
  errors.email = ''
  try {
    const response = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    })

    if (!response.ok) {
      const payload = await response.json()
      if (payload.field === 'email') errors.email = payload.message
      return
    }
  } finally {
    submitting.value = false
  }
}
</script>

That pattern is the missing half of many Vue form tutorials. The frontend binding works, the validation works, and then the submission path gets vague, especially on Nuxt, VitePress, Hugo, or Gatsby sites that need a real endpoint instead of a fake success screen (backend gap discussion).

A loading state on the button matters too, because users need a visible signal that the request is in flight. If you already use a dialog for success and a toast for failure, keep those messages tied to actual outcomes, not just optimistic UI guesses.

Spam protection still belongs in the plan. Native constraints and custom rules improve input quality, but they don’t stop automated abuse, so a honeypot or rate limiting should sit alongside the backend handler before launch.

Accessibility, Testing, and a Complete Working Example

A production form is the one that still works when the keyboard never leaves the page and the screen reader is doing the talking. Labels need for and id, errored fields need aria-invalid, descriptive error text needs aria-describedby, and submission status should be visible to assistive tech with a live region.

The habits worth keeping

  • Label every control: the label and id pairing should be a must, because placeholder text is not a label.
  • Expose field errors directly: put the error text near the field and point aria-describedby at it.
  • Signal pending and success states: the submit button and the status region should both reflect the current state.
  • Keep focus visible: Tailwind ring utilities make keyboard navigation obvious instead of relying on guesswork.

For a broader accessibility checklist, Growform’s accessibility guide is a useful companion reference when you’re hardening a form for production.

Testing should follow the same principle. Assert that the submit event fires with the expected payload, assert that error text appears when a field is invalid, and assert that the loading state blocks duplicate submits. Vue Test Utils fits that shape because it rewards event-driven checks instead of brittle DOM spelunking.

Here’s a full working example that pulls the pieces together without pretending the backend is optional.

<script setup>
import { computed, reactive, ref } from 'vue'

const form = reactive({
  name: '',
  email: '',
  message: '',
  contacts: [{ name: '', phone: '' }],
})

const serverErrors = reactive({
  name: '',
  email: '',
  message: '',
})

const submitting = ref(false)
const success = ref(false)

const errors = computed(() => {
  const next = {}
  if (!form.name.trim()) next.name = 'Name is required.'
  if (!form.email.trim()) next.email = 'Email is required.'
  else if (!form.email.includes('@')) next.email = 'Enter a valid email address.'
  if (form.message.trim().length < 10) next.message = 'Message needs at least 10 characters.'
  return next
})

function addContact() {
  form.contacts.push({ name: '', phone: '' })
}

function removeContact(index) {
  form.contacts.splice(index, 1)
}

async function handleSubmit() {
  if (Object.keys(errors.value).length) return
  submitting.value = true
  success.value = false
  serverErrors.email = ''

  try {
    const response = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    })

    if (!response.ok) {
      const payload = await response.json()
      if (payload.field && payload.message) serverErrors[payload.field] = payload.message
      return
    }

    success.value = true
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <form class="mx-auto max-w-2xl space-y-5 rounded-2xl bg-white p-6 text-slate-900 shadow-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:ring-slate-800" @submit.prevent="handleSubmit" novalidate>
    <p v-if="success" role="status" class="rounded-lg bg-emerald-50 px-4 py-3 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
      Thanks. Your message was sent.
    </p>

    <div>
      <label for="name" class="mb-1 block text-sm font-medium">Name</label>
      <input id="name" v-model="form.name" class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black dark:border-slate-700 dark:bg-slate-800" :aria-invalid="!!errors.name || !!serverErrors.name" :aria-describedby="errors.name || serverErrors.name ? 'name-error' : undefined">
      <p v-if="errors.name || serverErrors.name" id="name-error" role="alert" class="mt-1 text-sm text-red-600">{{ errors.name || serverErrors.name }}</p>
    </div>

    <div>
      <label for="email" class="mb-1 block text-sm font-medium">Email</label>
      <input id="email" v-model="form.email" type="email" class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black dark:border-slate-700 dark:bg-slate-800" :aria-invalid="!!errors.email || !!serverErrors.email" :aria-describedby="errors.email || serverErrors.email ? 'email-error' : undefined">
      <p v-if="errors.email || serverErrors.email" id="email-error" role="alert" class="mt-1 text-sm text-red-600">{{ errors.email || serverErrors.email }}</p>
    </div>

    <div>
      <label for="message" class="mb-1 block text-sm font-medium">Message</label>
      <textarea id="message" v-model="form.message" rows="5" class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black dark:border-slate-700 dark:bg-slate-800" :aria-invalid="!!errors.message" aria-describedby="message-error"></textarea>
      <p id="message-error" role="alert" class="mt-1 text-sm text-red-600">{{ errors.message }}</p>
    </div>

    <section class="space-y-3 rounded-xl border border-slate-200 p-4 dark:border-slate-800">
      <div v-for="(contact, index) in form.contacts" :key="index" class="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
        <input v-model="contact.name" class="rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black dark:border-slate-700 dark:bg-slate-800" placeholder="Contact name">
        <input v-model="contact.phone" class="rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black dark:border-slate-700 dark:bg-slate-800" placeholder="Phone">
        <button type="button" class="rounded-lg border border-slate-300 px-3 py-2" @click="removeContact(index)">Remove</button>
      </div>
      <button type="button" class="rounded-lg bg-slate-100 px-4 py-2 dark:bg-slate-800" @click="addContact">Add contact</button>
    </section>

    <div class="flex items-center gap-3">
      <button type="submit" class="rounded-lg bg-black px-4 py-2 text-white disabled:opacity-50" :disabled="submitting">
        {{ submitting ? 'Sending…' : 'Send message' }}
      </button>
      <span role="status" class="text-sm text-slate-500 dark:text-slate-400">{{ submitting ? 'Submitting form' : 'Ready' }}</span>
    </div>
  </form>
</template>

Before this ships, check the basics again. Required fields should be covered, error text should be specific, the submit button should disable while pending, and both success and failure states should be visible to assistive tech. If the form triggers email on the backend, the team handling delivery should also think about DKIM and DMARC alignment so the messages don’t get lost on the way out.

If you want to build forms that feel production-ready instead of patched together, take the patterns here and apply them to your next UI. DOM Studio is built for exactly this kind of work, with accessible, production-grade components that fit into Vue and Tailwind workflows without forcing you to rebuild the hard parts yourself. Visit DOM Studio, then use the same form patterns on your next dashboard, checkout, or onboarding flow.