21

Binding inputs

Connect text inputs, textareas, numbers, checkboxes, radios, selects, dates, and files to NotForm fields.

Every control connects to a field the same way: write the value through field.model, and report interaction through field.events. This page shows the pattern for each native input type. For custom components, see Custom components.

The pattern

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('email')"
  >
    <label :for="field.path">Email</label>

    <input
      :id="field.path"
      v-model="field.model"
      v-bind="field.events"
      autocomplete="email"
      type="email"
    >
  </Field>
</template>
  • field.model is a two-way binding to form.values at the field's path. Writing to it does not validate.
  • field.events is { onBlur, onChange, onInput }. These mark the field touched and trigger validation according to the field's configuration.
  • Put v-model before v-bind="field.events". The value must be written before the validation event reads it.

Always give initial values

Though not mandatory, provide an entry in initialValues for every field you bind. Missing values reach your schema as undefined, so validators report their generic "expected string, received undefined" message instead of your custom one, and isDirty can become true after a user types and deletes.

const form = useForm({
  initialValues: {
    age: undefined,
    interests: [],
    name: '',
    newsletter: false
  },
  schema,
})

Text, email, password, search, URL

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('name')"
  >
    <label :for="field.path">Name</label>

    <input
      :id="field.path"
      v-model="field.model"
      v-bind="field.events"
      type="text"
    >

    <FieldError :errors="field" />
  </Field>
</template>

Native modifiers work as usual. v-model.trim for example removes surrounding whitespace as the user types.

Textarea

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('message')"
  >
    <label :for="field.path">Message</label>

    <textarea
      :id="field.path"
      v-model="field.model"
      v-bind="field.events"
      rows="4"
    />
  </Field>
</template>

Number

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('quantity')"
  >
    <label :for="field.path">Quantity</label>

    <input
      :id="field.path"
      v-model.number="field.model"
      v-bind="field.events"
      type="number"
      min="0"
    >
  </Field>
</template>
const schema = z.object({ quantity: z.number().min(1, 'Order at least one.') })
// initialValues: { quantity: 1 }

v-model.number converts typed text to a number. If the user clears the box, the browser reports an empty string, which a z.number() schema rejects with its type message. Set a custom type message in your schema, or keep the input as a string and convert in the schema. See Schema input and output.

Single checkbox

Bind a boolean.

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('acceptTerms')"
  >
    <label>
      <input
        v-model="field.model"
        v-bind="field.events"
        type="checkbox"
      >
      I accept the terms
    </label>

    <FieldError :errors="field" />
  </Field>
</template>
const schema = z.object({
  acceptTerms: z.boolean('You must accept the terms.'),
})
// initialValues: { acceptTerms: false }

Checkbox group

Bind one field to an array of the selected values. Each checkbox has its own :value.

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('interests')"
  >
    <fieldset>
      <legend>Interests</legend>

      <label
        v-for="option in ['design', 'code', 'writing']"
        :key="option"
      >
        <input
          v-model="field.model"
          v-bind="field.events"
          type="checkbox"
          :value="option"
        >
        {{ option }}
      </label>
    </fieldset>

    <FieldError :errors="field" />
  </Field>
</template>
const schema = z.object({
  interests: z.array(z.string()).min(1, 'Pick at least one.'),
})
// initialValues: { interests: [] }
A checkbox group is a plain form.field() whose value happens to be an array of fixed choices. Use form.arrayField() when users add, remove, or reorder rows of structured data.

Radio group

All radios share one field and differ by :value.

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('plan')"
  >
    <fieldset>
      <legend>Plan</legend>

      <label
        v-for="option in ['free', 'pro']"
        :key="option"
      >
        <input
          v-model="field.model"
          v-bind="field.events"
          type="radio"
          :value="option"
        >
        {{ option }}
      </label>
    </fieldset>

    <FieldError :errors="field" />
  </Field>
</template>

Select

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('nationality')"
  >
    <label :for="field.path">Nationality</label>

    <select
      :id="field.path"
      v-model="field.model"
      v-bind="field.events"
    >
      <option
        value=""
        disabled
      >
        Choose a country
      </option>

      <option
        v-for="country in countries"
        :key="country.code"
        :value="country.code"
      >
        {{ country.name }}
      </option>
    </select>

    <FieldError :errors="field" />
  </Field>
</template>

For <select multiple>, bind an array exactly like a checkbox group.

Dates and times

Native date and time inputs produce strings such as 2026-11-02. Bind them like text, and validate or transform them in the schema.

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('birthday')"
  >
    <label :for="field.path">Birthday</label>

    <input
      :id="field.path"
      v-model="field.model"
      v-bind="field.events"
      type="date"
    >

    <FieldError :errors="field" />
  </Field>
</template>

Files

v-model does not work on file inputs. Write the file with an event handler instead and report the change yourself.

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('avatar')"
  >
    <label :for="field.path">Avatar</label>

    <input
      :id="field.path"
      type="file"
      @change="(event) => {
        field.model = (event.target as HTMLInputElement).files?.[0]
        field.events.onChange()
      }"
      @blur="field.events.onBlur"
    >

    <FieldError :errors="field" />
  </Field>
</template>

Skipping the Field component

Field only hands the field to its slot. If you prefer, create the field in script and use it directly. This is common in reusable input components.

<script setup lang="ts">
const email = form.field('email')
</script>

<template>
  <input
    v-bind="email.events"
    :id="email.path"
    v-model="email.model"
    type="email"
    :name="email.path"
  >
</template>

Labels and accessibility

  • Use field.path for the control's id and the label's for. field.id is a symbol and is not valid as an HTML id.
  • Mark invalid controls with :aria-invalid="!field.isValid".
  • Link errors to controls with aria-describedby. See Errors.

Next steps

  • Validation controls when errors appear.
  • Errors covers rendering every kind of error.
Copyright © 2026 NotForm
Published under MIT License