Most tutorials reduce v-model in Vue to “two-way data binding”. That description is convenient, but it’s incomplete enough to cause production bugs. v-model also defines a contract between a parent and a component, and that contract determines how values, validation state, labels and error messages travel through your form system.
A custom field can update perfectly while remaining inaccessible. A wrapper can emit the right value while leaving its input without a reliable label or description. The binding works, but the user experience fails. Treating v-model as an API contract rather than a shortcut leads to more durable components, especially when a Vue application contains shared form controls, legacy wrappers and headless primitives.
Table of Contents
- Rethinking What v-model Does
- Vue 2 Versus Vue 3 Binding Syntax
- How v-model Behaves with Native Inputs
- Building Custom Components with v-model
- The Accessibility Gap in Two-Way Binding
- Integrating v-model with Headless Component Wrappers
- Common Pitfalls and TypeScript Considerations
Rethinking What v-model Does
The useful mental model is a one-way value flow paired with an upward update event. A component’s v-model contract is implemented through props and emits. Native controls use the property and event appropriate to their type. The Vue forms guide also establishes Vue state as the source of truth, rather than the input’s initial HTML attributes.
That contract becomes more demanding when a component wraps a real input. The parent owns the value. The child renders it, emits user changes and preserves the input’s form semantics. Forwarding only the value leaves the implementation incomplete.

The contract includes more than the value
A field wrapper commonly coordinates:
- The model value, owned by the parent and exposed to the child.
- The update event, emitted when the user changes the control.
- The label relationship, usually an input
idmatched by a label’sfor. - The description relationship, typically an
aria-describedbyvalue containing help and error text. - The validation state, represented with
aria-invalidwhen the field fails validation. - The announcement behaviour, so newly displayed errors are communicated to assistive technology rather than merely shown visually.
A component can therefore pass an emitted-value unit test and still fail an accessibility review. v-model synchronises state. It does not create an accessible name, produce unique IDs or determine how validation messages reach a screen reader. Controlled component guidance helps make ownership and update paths explicit, which is useful when wrappers would otherwise conceal state changes.
A custom two-way bound field should expose or derive the IDs it needs, connect its label and description to the owned input, and update its error relationship when validation changes. If the error appears after submission, the component may also need a live region or a deliberate focus strategy, depending on the surrounding form flow.
Practical rule: If a component owns the input element, it also owns the job of preserving the input’s accessible relationships.
Review the entire contract, not only the model update. Check that the control remains labelled, that help and error text are discoverable, and that composition does not duplicate IDs or discard ARIA attributes. A working v-model is the starting point for a field component, not proof that the field is usable.
Vue 2 Versus Vue 3 Binding Syntax
Vue 2 and Vue 3 express the same basic relationship with different prop and event names. In Vue 2, a component using v-model conventionally received a value prop and listened for an input event:
<!-- Parent.vue -->
<CustomInput v-model="email" />
<!-- CustomInput.vue -->
<script>
export default {
props: {
value: {
type: String,
default: ''
}
},
methods: {
onInput(event) {
this.$emit('input', event.target.value)
}
}
}
</script>
<template>
<input
:value="value"
type="email"
@input="onInput"
>
</template>
Vue 3 uses modelValue and update:modelValue for the default component model:
<!-- CustomInput.vue -->
<script setup>
defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="modelValue"
type="email"
@input="emit('update:modelValue', $event.target.value)"
>
</template>
The modern Vue component API arrived in the 2020 release cycle, and the official guide now recommends defineModel() from Vue 3.4 for component authors. The historical timing and the UK hiring context are documented by IT Jobs Watch’s Vue.js market data, which reported 560 UK Vue-related permanent roles over the six months to 17 August 2026, representing 0.52% of permanent jobs, with 499 salaries quoted in that sample. Those figures aren’t a reason to use Vue, but they do show why migration knowledge remains useful for teams maintaining existing applications.
The migration differences that affect component design
Vue 3 also supports argument-based models. Instead of forcing every field into one unnamed model, a component can expose named bindings:
<UserName
v-model:first-name="firstName"
v-model:last-name="lastName"
/>
Inside the component, the corresponding model names are firstName and lastName. This is clearer than packaging unrelated values into one object when the parent needs to own each value separately.
The .sync modifier from Vue 2 should not be carried into a Vue 3 design by habit. Named v-model arguments provide a clearer contract for multiple independently controlled values, and they make the update event explicit.
| Concern | Vue 2 convention | Vue 3 convention |
|---|---|---|
| Default prop | value |
modelValue |
| Default event | input |
update:modelValue |
| Multiple models | Usually custom wiring | Named v-model arguments |
| Concise component authoring | Manual prop and event setup | defineModel() from Vue 3.4 |
The migration trap is changing names without reviewing semantics. A wrapper that renames value to modelValue but fails to forward id, aria-describedby or invalid state has technically migrated its binding while regressing its form behaviour.

How v-model Behaves with Native Inputs
Native inputs are where v-model feels effortless because Vue already understands the browser control types. For text inputs and textareas, it synchronises the control’s value. For checkboxes, it manages checked state, and for radio groups and selects it uses the relevant selection mechanism. The framework’s forms documentation describes this automatic choice and explains why initial value, checked or selected attributes shouldn’t be treated as the application’s source of truth.
<script setup>
import { ref } from 'vue'
const message = ref('')
const accepted = ref(false)
const plan = ref('')
const colour = ref('')
</script>
<template>
<input v-model="message" type="text">
<textarea v-model="message"></textarea>
<label>
<input v-model="accepted" type="checkbox">
Accept the terms
</label>
<label>
<input v-model="plan" type="radio" value="standard">
Standard
</label>
<select v-model="colour">
<option disabled value="">Choose a colour</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
</select>
</template>
The important operational detail is that v-model writes the current reactive value back to the control. Adding value="..." to the HTML doesn’t establish the application’s initial state once Vue is managing the element. Set the ref or reactive property instead.
Modifiers change when and how values arrive
Modifiers are useful when they match a deliberate data boundary:
.lazyupdates after the control’s change event rather than on every input event. It can suit fields where validation or processing shouldn’t run for every keystroke..trimremoves surrounding whitespace from text input values. Use it when whitespace has no meaning, but don’t apply it blindly to content where spaces are significant..numberattempts to produce a numeric value from input text. It’s convenient for numeric controls, but validation should still handle empty strings and invalid input states explicitly.
For larger forms, keep native field semantics close to the element. A shared validation layer should consume the model and expose errors, but it shouldn’t encourage developers to replace useful native controls with generic wrappers unnecessarily. The practical examples in this Vue.js form example are a useful reference when deciding where field wiring belongs.
When a form must submit to a service rather than a Vue-managed endpoint, a hosted form API for Vue developers can handle the submission boundary while Vue continues to manage field state and user feedback. That separation keeps v-model focused on interaction instead of turning it into an entire transport layer.
Building Custom Components with v-model
A custom component should expose a predictable model and keep the parent as the owner of that model. Before Vue 3.4, the explicit pattern used a modelValue prop and an update:modelValue event:
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: ''
},
id: {
type: String,
required: true
}
})
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:id="props.id"
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
>
</template>
That code is verbose, but the verbosity is informative. It shows exactly which value enters the child and exactly which event leaves it. The child must not mutate props.modelValue directly, because the parent owns the state.

Using defineModel for the common case
From Vue 3.4, defineModel() packages the prop and update event into a synchronised ref:
<script setup>
const model = defineModel({ type: String, default: '' })
</script>
<template>
<input v-model="model" type="text">
</template>
The official defineModel() documentation explains why this reduces boilerplate. The returned ref stays synchronised with the parent-bound value, and changing the child-side ref updates the parent automatically.
Named models use an argument:
<script setup>
const start = defineModel('start', { type: String, default: '' })
const end = defineModel('end', { type: String, default: '' })
</script>
<template>
<input v-model="start" type="date">
<input v-model="end" type="date">
</template>
The parent can then write:
<DateRange
v-model:start="rangeStart"
v-model:end="rangeEnd"
/>
This pattern works well for controls whose values are separate. It’s less suitable when the values must always change together, in which case one structured model can make the invariant more visible.
The wrapper still has responsibilities that defineModel() can’t infer. It must forward the input’s ID, label relationship, description IDs and invalid state. If the wrapper contains more than one interactive element, it should also define which element receives the model and how keyboard interaction changes it.
The Accessibility Gap in Two-Way Binding
v-model can keep a value synchronised while doing nothing for accessibility. That’s the gap many tutorials leave unexplained: once a custom component wraps an input, the label, error text and screen-reader announcement can become disconnected from the control. Vue’s accessibility guidance specifically addresses labels, aria-describedby and the problems with using a placeholder as the only instruction.
Start with a stable ID contract. The field wrapper should accept an ID from the parent or generate one through a deterministic component mechanism. It should render the same ID on the actual input, use that value in the label’s for attribute and derive related IDs for help and error text.
<script setup>
import { computed } from 'vue'
const props = defineProps({
id: {
type: String,
required: true
},
label: {
type: String,
required: true
},
error: {
type: String,
default: ''
},
hint: {
type: String,
default: ''
}
})
const hintId = computed(() => `${props.id}-hint`)
const errorId = computed(() => `${props.id}-error`)
const describedBy = computed(() =>
[props.hint && hintId.value, props.error && errorId.value]
.filter(Boolean)
.join(' ') || undefined
)
</script>
<template>
<label :for="id">{{ label }}</label>
<input
:id="id"
:aria-describedby="describedBy"
:aria-invalid="error ? 'true' : undefined"
>
<p v-if="hint" :id="hintId">{{ hint }}</p>
<p v-if="error" :id="errorId" role="alert">{{ error }}</p>
</template>
Validation needs a communication strategy
aria-invalid tells assistive technology that the current value fails validation. It doesn’t explain why. The error element needs a stable ID and a relationship to the input, while the announcement mechanism should match the way the form updates. role="alert" can announce an inserted message, but teams should test timing and repetition with the screen readers and browsers they support.
The wrapper also needs to preserve the accessible name. A visible label is usually preferable to placeholder-only guidance, and a slot-based label must still render in a way the input can reference. Keyboard focus should land on the actual interactive element, not on an inert wrapper.
For broader implementation checks, these Orbit AI form accessibility tips provide useful reminders about keyboard behaviour and form structure. The core principle remains unchanged: binding state and communicating state are separate jobs.
Integrating v-model with Headless Component Wrappers
Headless components remove visual assumptions, not behavioural responsibilities. A Vue wrapper around a listbox, combobox, toggle or dialog should expose a model for the controlled value while preserving the primitive’s roles, focus rules and keyboard interactions.
The wrapper boundary should be deliberately thin:
<script setup>
const selected = defineModel({ type: String, default: '' })
</script>
<template>
<HeadlessListbox v-model="selected">
<slot />
</HeadlessListbox>
</template>
If the underlying primitive emits a different event or uses a richer value shape, adapt it at the wrapper boundary rather than leaking implementation details into every parent. For a combobox, that might mean the model contains the selected option while a separate internal ref contains the current query. Those are different concepts and shouldn’t be forced into one model.
Keep ARIA and focus inside the primitive
A headless listbox needs to manage the relationship between its trigger, popup and options. A combobox needs keyboard navigation, active option state and an appropriate expanded relationship. A dialog needs focus entry, focus return and dismissal behaviour. Recreating those rules in every v-model wrapper creates inconsistent controls and makes accessibility regressions likely.
Slots are useful for custom content, but they shouldn’t remove semantic attributes from the primitive. Forward the slot content into the correct structural position and keep the state that drives aria-expanded, aria-selected, aria-activedescendant or equivalent attributes inside the interaction layer.
For a control with more than one independently controlled concern, named models can clarify ownership:
<script setup>
const open = defineModel('open', { type: Boolean, default: false })
const value = defineModel('value', { type: String, default: '' })
</script>
That distinction is valuable for components such as date pickers, split panels and searchable selects. A practical example of separating interaction concerns from display concerns appears in this Vue time picker implementation.
DOM Studio is one option for this architecture. Its framework-agnostic web component primitives provide behaviour, while its Vue integration layer exposes reactive props, v-model support and slots. Components such as DomTextInput, DomToggle, DomCombobox and DomTabs can therefore sit inside a Vue form without requiring each team to rebuild the underlying ARIA and keyboard patterns.
Common Pitfalls and TypeScript Considerations
Most v-model bugs are contract bugs. The input changes, but the event name is wrong. The prop is typed as optional while the component assumes it’s always present. A wrapper forwards the value but drops the ID needed by the label. These failures often appear only after composition, not in the isolated component demo.

The mistakes that survive code review
- Mismatched names:
modelValuemust pair withupdate:modelValue. A named model such asv-model:querymust pair with the correspondingqueryprop andupdate:queryevent when you’re using the explicit API. - Direct prop mutation: Never assign to a prop received from the parent. Use the model ref or emit an update event.
- Undeclared events: In explicit Vue 3 code, declare the update event with
defineEmits. This makes the component contract visible and helps catch spelling mistakes. - Broken wrapper forwarding: Pass the model to the actual interactive element, then forward
id,aria-describedby,aria-invalidand relevant event handlers deliberately.
For TypeScript, start with the value shape rather than relying on broad inference:
<script setup lang="ts">
const model = defineModel<string>({ default: '' })
const invalid = defineModel<boolean>('invalid', { default: false })
</script>
The model type should reflect real states. A select may need string | null, while a checkbox may use boolean or a collection type. Don’t hide uncertainty with any, because parent and child components then disagree.
Explicit contracts remain useful for older code or complicated wrappers:
const props = defineProps<{
modelValue: string
id: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
Multiple models deserve their own types, especially when a wrapper forwards values to another component. Keep the type at each boundary aligned so a date range, option object or nullable selection doesn’t get flattened accidentally.
The migration problem is broader than syntax. Production teams also need consistent id and for relationships, aria-invalid handling and unique IDs across composed wrappers. That practical gap is reflected in UK frontend framework usage data from WMTips, which reports Vue with 15.6% framework share in the UK. Mixed and evolving codebases need migration patterns that preserve accessibility, not merely renamed props.
Final engineering check: Test the component as a user experiences it. Change the value, trigger validation, move through it with the keyboard and inspect the accessible name and description.
DOM Studio provides headless web component primitives with a Vue integration layer, including reactive props, v-model support, slots and built-in accessibility behaviour for controls such as inputs, toggles and comboboxes. If you’re standardising form contracts and want to avoid rebuilding ARIA, focus management and keyboard handling in every wrapper, visit DOM Studio and evaluate how its components fit your Vue form system.
