| |

Vue.js 61 🟢 Form Input Directives Customization and v-model Modifiers Deep Dive

⏱️ Estimated read: 14 min | |

v-model is Vue’s two-way binding directive, and on the surface it looks like magic: a single attribute synchronizes a form input with a reactive variable. Under the hood, it is a compile-time transformation that expands into a prop binding and an event listener. On a native <input>, v-model="text" becomes :value="text" plus @input="text = $event.target.value". On a custom component, the transformation changes: v-model="text" becomes :model-value="text" plus @update:model-value="text = $event" . This distinction—native elements use value/input, custom components use modelValue/update:modelValue—is the foundation for everything else in this chapter.

The modifiers—.trim, .number, .lazy—are not part of the directive itself. They are flags that the compiler passes to the generated code, and on a custom component, they arrive as a prop called modelModifiers . The component must read that prop and apply the transformation manually. This is the part that catches most developers: writing v-model.trim="name" on a custom input does not automatically trim the value. The modifier is delivered, but it is the component’s responsibility to honor it. This chapter covers the compile expansion, the built-in modifiers, the custom modifier mechanism, and the defineModel macro that simplifies the whole contract.

Key point: On native inputs, v-model expands to :value and @input (or :checked and @change for checkboxes/radios). On custom components, it expands to :modelValue and @update:modelValue. Built-in modifiers (.trim, .number, .lazy) work automatically on native inputs but must be manually applied in custom components via the modelModifiers prop (or the modifiers object returned by defineModel).


Why the compile expansion matters

The Vue 2 to Vue 3 migration problem. In Vue 2, custom components used value as the prop and input as the event. Vue 3 changed the defaults to modelValue and update:modelValue . A component that still declares props: ['value'] and emits input will silently fail to bind with v-model in Vue 3. The parent’s value will not update, and there is no error—the component simply does not participate in the binding. This is the single most common migration error, and it is resolved by understanding what the compiler generates.

The prop mutation problem. A child component cannot mutate its modelValue prop directly. Vue enforces one-way data flow: props are read-only. If the child writes props.modelValue = newValue, Vue warns at runtime, and the parent’s value does not change . The only correct path is to emit update:modelValue. The defineModel macro (Vue 3.4+) wraps this contract into a writable ref, but under the hood it still uses the same prop and event.

The modifier delivery problem. On a native input, the compiler knows how to apply .trim and .number because it generates the code inline. On a custom component, the compiler cannot know what the component does with its value. So it passes the modifiers as a prop—modelModifiers for the default v-model, and <name>Modifiers for a named model like v-model:title . The component reads the prop and decides how to apply the transformation. If the component ignores the prop, the modifier is silently ineffective.

The multiple binding problem. Vue 3 supports multiple v-model bindings on the same component through arguments: v-model:firstName and v-model:lastName . Each argument maps to its own prop (firstName, lastName) and its own event (update:firstName, update:lastName). This replaced Vue 2’s .sync modifier and the model component option. The defineModel macro makes this concise: const firstName = defineModel('firstName') and const lastName = defineModel('lastName') create the two bindings in two lines.


a. Built-in modifiers: .trim, .number, .lazy

Vue provides three built-in modifiers for v-model. On native inputs, they work automatically. On custom components, they are delivered as flags and must be applied manually.

<!-- Native input: .trim is applied automatically -->
<input v-model.trim="email" />

<!-- Native input: .number casts the string to a number -->
<input v-model.number="age" type="number" />

<!-- Native input: .lazy syncs on change instead of input -->
<input v-model.lazy="message" />

The .trim modifier strips leading and trailing whitespace from the value before assigning it to the bound variable. The .number modifier casts the input string to a number. An <input> always returns a string from its value property; without .number, age would be the string "30", not the number 30 . The .lazy modifier changes the event from input (every keystroke) to change (when the field loses focus or the user presses Enter).

Modifiers can be chained: v-model.trim.lazy="username" applies both. The order of application is determined by the compiler, but the practical result is that the value is trimmed and synced on change.

On a custom component, the built-in modifiers do not apply automatically. The component receives modelModifiers as a prop, and it must check the flags:

<script setup>
const props = defineProps({
  modelValue: String,
  // modelModifiers is injected by Vue when the parent uses v-model.trim
  modelModifiers: {
    type: Object,
    default: () => ({}),
  },
})

const emit = defineEmits(['update:modelValue'])

function onInput(event) {
  let value = event.target.value
  // Check the modifier flag and apply the transformation manually
  if (props.modelModifiers.trim) {
    value = value.trim()
  }
  // Emit the transformed value to the parent
  emit('update:modelValue', value)
}
</script>

<template>
  <input :value="modelValue" @input="onInput" />
</template>

The modelModifiers prop is an object with the modifier names as keys and true as values. v-model.trim produces { trim: true }. The component checks the key and applies the transformation before emitting. Without this check, the modifier is delivered but ignored.


b. Custom modifiers and the defineModel macro

Vue 3 allows custom modifiers: v-model.capitalize="name". The component receives modelModifiers.capitalize and decides what to do with it. The defineModel macro (Vue 3.4+) makes this concise by returning a tuple of the model ref and the modifiers object.

<!-- Parent: v-model.capitalize -->
<MyInput v-model.capitalize="name" />
<!-- Child: defineModel with the set option -->
<script setup>
// defineModel returns [model, modifiers]
// model is a writable ref; modifiers is the object of modifier flags
const [model, modifiers] = defineModel({
  set(value) {
    // Apply the capitalize modifier in the setter
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  },
})
</script>

<template>
  <!-- Bind the model ref directly to the native input -->
  <input v-model="model" />
</template>

The set option is the transformation hook. When the native input emits a new value, the set function runs, applies the modifier logic, and returns the transformed value. The model ref is then updated, and the parent’s value is synchronized . The get option can transform the value on the way in, which is useful for formatting the displayed value without changing the underlying data.

For multiple named models, each defineModel call returns its own modifiers. The naming rule is: the default v-model uses modelModifiers, and a named model v-model:title uses titleModifiers . This is a common source of silent failures. If the component reads modelModifiers but the parent used v-model:title.capitalize, the modifier is under titleModifiers, and the component’s check fails.

<script setup>
// Named model with its own modifiers
const title = defineModel('title', {
  set(value) {
    // titleModifiers is the correct prop name for v-model:title
    // Access it through the second element of the tuple if needed
    return value
  },
})

// If you need the modifiers object for a named model:
const [titleModel, titleModifiers] = defineModel('title')
</script>

For projects on Vue 3.2 or 3.3 (before defineModel stabilized), the manual approach is defineProps with modelModifiers and defineEmits with update:modelValue. The type definitions are more verbose, but the runtime behavior is identical .


c. The defineModel contract and the pre-3.4 alternative

defineModel is a compiler macro. It does not exist at runtime. The compiler replaces it with a modelValue prop declaration, an update:modelValue emit declaration, and a writable ref whose get returns the prop and whose set emits the event . This is why the macro only works in <script setup> and why it requires Vue 3.4 or later.

The macro accepts prop options. defineModel({ required: true }) makes the model required. defineModel({ default: 0 }) provides a default value. The default value caveat is important: if the parent does not provide a value and the child has a default, the parent’s ref is undefined while the child’s model is the default. This de-synchronization is by design—the default is local to the child, and the parent’s state is not automatically updated .

The manual equivalent, for projects that cannot use defineModel:

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

const props = defineProps({
  modelValue: String,
  modelModifiers: { type: Object, default: () => ({}) },
})

const emit = defineEmits(['update:modelValue'])

// A writable computed ref that mirrors the prop and emits on set
const model = computed({
  get: () => props.modelValue,
  set: (value) => {
    // Apply modifiers before emitting
    if (props.modelModifiers.trim) {
      value = value.trim()
    }
    emit('update:modelValue', value)
  },
})
</script>

<template>
  <input v-model="model" />
</template>

The computed with a get and set is the pre-3.4 mechanism for a writable ref that is backed by a prop. The get returns the prop value, and the set emits the update event. The modifiers are applied in the set before the emit. This is the pattern that defineModel replaces, and it is still the correct pattern for older projects .


Complete Example Session

The following session builds a CapitalizedInput component that accepts v-model.capitalize and applies the modifier through defineModel, then shows the manual equivalent for pre-3.4 projects.

<!-- ============================================
     PART 1: THE CAPITALIZED INPUT (defineModel)
     ============================================ -->
<!-- CapitalizedInput.vue -->
<script setup>
// defineModel returns the model ref and the modifiers object
// The set option applies the capitalize modifier before emitting
const [model, modifiers] = defineModel({
  set(value) {
    // If the parent used v-model.capitalize, capitalize the value
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    // Otherwise return the value unchanged
    return value
  },
})
</script>

<template>
  <!-- Bind the model ref directly to the native input -->
  <input v-model="model" />
</template>
<!-- ============================================
     PART 2: THE PARENT COMPONENT
     ============================================ -->
<script setup>
import { ref } from 'vue'
import CapitalizedInput from './CapitalizedInput.vue'

// The bound value
const name = ref('')
</script>

<template>
  <!-- Use the custom modifier on the component -->
  <CapitalizedInput v-model.capitalize="name" />
  <!-- Display the value to verify the transformation -->
  <p>Value: {{ name }}</p>
</template>
<!-- ============================================
     PART 3: THE MANUAL EQUIVALENT (pre-3.4)
     ============================================ -->
<!-- CapitalizedInputManual.vue -->
<script setup>
import { computed } from 'vue'

// Declare the modelValue prop and the modelModifiers prop
const props = defineProps({
  modelValue: String,
  modelModifiers: { type: Object, default: () => ({}) },
})

// Declare the update event
const emit = defineEmits(['update:modelValue'])

// A writable computed ref backed by the prop
const model = computed({
  get: () => props.modelValue,
  set: (value) => {
    // Apply the modifier in the setter
    if (props.modelModifiers.capitalize) {
      value = value.charAt(0).toUpperCase() + value.slice(1)
    }
    // Emit the transformed value
    emit('update:modelValue', value)
  },
})
</script>

<template>
  <input v-model="model" />
</template>

The three parts cover the defineModel version, the parent that uses v-model.capitalize, and the manual computed version for older projects. What the session demonstrates is that the modifier is a flag delivered to the component, and the component applies the transformation in the setter. The defineModel version is shorter, but the manual version is the same contract with more boilerplate.


Quick Reference

Native vs Custom Expansion

ContextPropEvent
Native inputvalueinput
Native checkboxcheckedchange
Custom componentmodelValueupdate:modelValue

Built-in Modifiers

ModifierEffectApplies to
.trimStrip whitespaceText inputs
.numberCast to numberText/number inputs
.lazySync on changeText inputs

Modifier Prop Names

BindingModifier Prop
v-modelmodelModifiers
v-model:titletitleModifiers
v-model:firstNamefirstNameModifiers

defineModel Options

OptionEffect
required: trueProp is required
default: valueDefault value (local to child)
set(value)Transform before emit
get(value)Transform before display

Best Practices

✅ Do This:

<!-- Use defineModel for the default v-model -->
const model = defineModel()

<!-- Use defineModel with a name for multiple bindings -->
const title = defineModel('title')

<!-- Apply modifiers in the set option -->
const [model, modifiers] = defineModel({ set: (v) => modifiers.trim ? v.trim() : v })

<!-- Check the correct modifier prop name for named models -->
const [title, titleModifiers] = defineModel('title')

❌ Don’t Do This:

<!-- Declare value/input props in Vue 3 -->
props: ['value'] // v-model will not bind

<!-- Mutate the prop directly -->
props.modelValue = 'new' // runtime warning, parent not updated

<!-- Ignore modelModifiers on a custom component -->
// v-model.trim will be silently ineffective

<!-- Read modelModifiers for a named model -->
// v-model:title.trim uses titleModifiers, not modelModifiers

Common Pitfalls

PitfallWhy It HappensFix
v-model not bindingUsed value/input propsUse modelValue/update:modelValue
Modifier has no effectComponent ignores modelModifiersCheck the flag and apply the transformation
Named model modifier failsRead modelModifiers instead of titleModifiersUse the correct prop name
Prop mutation warningChild assigns to modelValueEmit update:modelValue
Default value desyncParent has undefined, child has defaultParent should provide the initial value

Real-World Examples

1. Native trim

<input v-model.trim="email" />

2. Native number

<input v-model.number="age" type="number" />

3. Custom component with defineModel

const model = defineModel()
<input v-model="model" />

4. Custom modifier with defineModel

const [model, modifiers] = defineModel({
  set: (v) => modifiers.capitalize ? v[0].toUpperCase() + v.slice(1) : v,
})

5. Multiple named models

const firstName = defineModel('firstName')
const lastName = defineModel('lastName')

6. Manual modelModifiers prop

const props = defineProps({ modelValue: String, modelModifiers: Object })

7. Check modifier flag

if (props.modelModifiers.trim) value = value.trim()

8. Named model modifier prop

const [title, titleModifiers] = defineModel('title')

9. Writable computed (pre-3.4)

const model = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) })

10. Chain modifiers

<input v-model.trim.lazy="username" />

Visual

Compile Expansion

┌──────────────────────────────────────────────┐
│  NATIVE: v-model="text"                      │
│  → :value="text" @input="text = $event.target.value" │
│                                              │
│  CUSTOM: v-model="text"                      │
│  → :modelValue="text"                        │
│    @update:modelValue="text = $event"        │
└──────────────────────────────────────────────┘

Modifier Delivery

┌──────────────────────────────────────────────┐
│  PARENT: v-model.trim="name"                 │
│         │                                    │
│         ▼                                    │
│  CHILD receives:                             │
│  modelValue = current value                  │
│  modelModifiers = { trim: true }             │
│         │                                    │
│         ▼                                    │
│  CHILD applies:                              │
│  if (modifiers.trim) value = value.trim()    │
│         │                                    │
│         ▼                                    │
│  CHILD emits: update:modelValue              │
└──────────────────────────────────────────────┘

Named Model Modifiers

┌──────────────────────────────────────────────┐
│  v-model:title.capitalize="name"             │
│         │                                    │
│         ▼                                    │
│  CHILD receives:                             │
│  title prop = current value                  │
│  titleModifiers = { capitalize: true }       │
│  (NOT modelModifiers)                        │
└──────────────────────────────────────────────┘

defineModel Contract

┌──────────────────────────────────────────────┐
│  const [model, modifiers] = defineModel()    │
│         │                                    │
│         ▼                                    │
│  Compiler generates:                         │
│  - modelValue prop                           │
│  - update:modelValue emit                    │
│  - writable ref (get prop, set emit)         │
│  - modifiers object from modelModifiers prop │
└──────────────────────────────────────────────┘

Summary

ItemValue
Native expansion:value + @input
Custom expansion:modelValue + @update:modelValue
Built-in modifiers.trim, .number, .lazy
Custom modifier propmodelModifiers (default)
Named modifier prop<name>Modifiers
defineModel return[modelRef, modifiers]
set optionTransform before emit
Vue 3.4+defineModel stable
Pre-3.4Manual computed with get/set

Key takeaways:

  • v-model is a compile-time transformation. On native inputs, it expands to a value binding and an input listener. On custom components, it expands to a modelValue prop and an update:modelValue event listener .
  • Vue 3 changed the default prop and event names. The old value/input pair is replaced by modelValue/update:modelValue. Components that still use the old names will silently fail to bind .
  • Built-in modifiers are automatic on native inputs but manual on custom components. The component receives modelModifiers as a prop and must check the flags and apply the transformation itself .
  • Named models use their own modifier prop. v-model:title.capitalize delivers titleModifiers, not modelModifiers. Reading the wrong prop is a silent failure .
  • defineModel simplifies the contract. It returns a writable ref and a modifiers object, and the set option is the transformation hook. It requires Vue 3.4+ and <script setup> .
  • The child never mutates the prop directly. The correct path is always to emit update:modelValue. The defineModel ref does this internally, but the manual version must do it explicitly .
  • Multiple v-model bindings replaced .sync. Vue 3 supports v-model:firstName and v-model:lastName on the same component, each with its own prop and event .

Remember: v-model is not a single directive with a single behavior. It is a contract between the parent and the child, and the compiler generates the plumbing. On native inputs, the plumbing is value and input. On custom components, the plumbing is modelValue and update:modelValue. The modifiers are flags that travel with the binding, and the component decides how to honor them. defineModel reduces the boilerplate, but the underlying contract is the same: the parent passes a prop, the child emits an event, and the value flows in a single direction with an explicit update signal. Understanding this contract is what turns v-model from magic into a predictable mechanism.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!