---
title: "Vuetify"
description: "Use NotForm with Vuetify inputs, including text field, textarea, select, checkbox, checkbox group, radio group, switch, and array fields."
canonical_url: "https://notform.favorodera.dev/docs/integrations/vuetify"
---

> Index of every page on this site: [llms.txt](https://notform.favorodera.dev/llms.txt)

Vuetify provides styled inputs that show their own labels, hints, and error text. NotForm owns the form state and validation, and Vuetify renders the controls. The mappings below follow the pattern in [Custom components](https://notform.favorodera.dev/docs/integrations/custom-components). Check the props against your installed Vuetify version if a binding differs.

## Setup

Install and configure Vuetify by following its [installation guide](https://vuetifyjs.com/en/getting-started/installation/). NotForm needs no plugin and no extra configuration.

Vuetify's components are prefixed with `v-`, so they do not clash with NotForm's `Form`, `Field`, `FieldError`, or `ArrayField` so you do not need aliases.

## Use NotForm's Form, not VForm

Wrap your controls in NotForm's `Form`, not Vuetify's `v-form`. `v-form` has its own validation and submit pipeline built around the `rules` prop. Using both makes two systems compete over the same state. For the same reason, do not set `rules` on Vuetify inputs. Your schema owns every rule.

## Choose the right binding

Every `Field` slot gives you a `field` with a `model`, a set of `events`, and the validation state. How you connect them depends on what the Vuetify component reports.

<table>
<thead>
  <tr>
    <th>
      Component
    </th>
    
    <th>
      Binding
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        v-text-field
      </code>
      
      , <code>
        v-textarea
      </code>
    </td>
    
    <td>
      <code>
        v-model="field.model"
      </code>
      
       and <code>
        v-bind="field.events"
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        v-select
      </code>
      
      , <code>
        v-checkbox
      </code>
      
      , <code>
        v-switch
      </code>
    </td>
    
    <td>
      <code>
        v-model="field.model"
      </code>
      
      , <code>
        @update:model-value="field.events.onChange"
      </code>
      
       and <code>
        @blur="field.events.onBlur()"
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Checkbox group (several <code>
        v-checkbox
      </code>
      
       with <code>
        :value
      </code>
      
      )
    </td>
    
    <td>
      The same bindings on each checkbox, all sharing one <code>
        v-model="field.model"
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        v-radio-group
      </code>
    </td>
    
    <td>
      <code>
        v-model="field.model"
      </code>
      
      , <code>
        @update:model-value="field.events.onChange"
      </code>
      
       and <code>
        @focusout="field.events.onBlur()"
      </code>
    </td>
  </tr>
</tbody>
</table>

Text fields pass the native listeners in `field.events` to the underlying input, so spreading them is enough. Controls that report a picked value through `update:model-value` get explicit handlers instead: `onChange` when the value is picked, and `onBlur` when focus leaves.

The radio group listens to `focusout` because it wraps several radios. The event bubbles up from each radio, so it reports when focus leaves any of them.

<tip>

Keep `v-model` before `v-bind="field.events"`. The value has to be written before the validation event takes its snapshot.

</tip>

## Show errors

Vuetify inputs render error text from their own `error-messages` prop, so you do not need `FieldError` here. Map `field.errors` to an array of messages with a small helper:

```ts
import type { ValidationIssue } from 'notform'

function getErrorMessages(errors?: ValidationIssue[]) {
  return errors?.map((error) => {
    return error.message
  })
}
```

Controls that have no built-in message area, such as a checkbox group or an array field, use `v-messages` instead. Both cases are shown below.

Each section below shows the schema and the template it binds to. The snippets assume the `form`, `getErrorMessages`, and the data arrays from the [full example](#full-example).

## Text field

```ts
title: z.string().min(5, 'Bug title must be at least 5 characters.')
  .max(32)
```

<code-collapse>

```vue
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('title')"
  >
    <v-text-field
      v-model="field.model"
      v-bind="field.events"
      placeholder="Login button not working on mobile"
      autocomplete="off"
      class="inline-full"
      :error-messages="getErrorMessages(field.errors)"
      label="Bug Title"
      persistent-placeholder
      variant="outlined"
    />
  </Field>
</template>
```

</code-collapse>

## Textarea

`v-textarea` takes the same bindings as a text field. The `counter` prop only displays the character count. The schema's `.max(100)` is what enforces the limit.

```ts
description: z.string().min(20, 'Description must be at least 20 characters.')
  .max(100)
```

<code-collapse>

```vue
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('description')"
  >
    <v-textarea
      :id="field.path"
      v-model="field.model"
      v-bind="field.events"
      :error-messages="getErrorMessages(field.errors)"
      label="Description"
      placeholder="I'm having an issue with the login button on mobile."
      persistent-placeholder
      hint="Include steps to reproduce, expected behavior, and what actually happened."
      persistent-hint
      counter="100"
      rows="6"
      no-resize
      variant="outlined"
    />
  </Field>
</template>
```

</code-collapse>

## Select

Pass the options through `items`. Vuetify items use `title` and `value`, and an entry of `{ type: 'divider' }` renders a separator. The field's value is the selected item's `value`.

```ts
language: z.string().min(1, 'Please select your spoken language.')
  .refine((value) => {
    return value !== 'auto'
  }, 'Auto-detection is not allowed.')
```

In this example `Auto` can be selected but the schema rejects it, which shows a custom rule on a select.

<code-collapse>

```vue
<script setup lang="ts">
const spokenLanguages = [
  { title: 'Auto', value: 'auto' },
  { type: 'divider' as const },
  { title: 'English', value: 'en' },
  { title: 'Spanish', value: 'es' },
  { title: 'French', value: 'fr' },
]
</script>

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('language')"
  >
    <v-select
      :id="field.path"
      v-model="field.model"
      :error-messages="getErrorMessages(field.errors)"
      :items="spokenLanguages"
      label="Spoken Language"
      hint="For best results, select the language you speak."
      persistent-hint
      variant="outlined"
      @update:model-value="field.events.onChange"
      @blur="field.events.onBlur()"
    />
  </Field>
</template>
```

</code-collapse>

Keep an empty string as the initial value so the select starts empty and the `min(1)` message appears if the user leaves it unchosen.

## Checkbox

Bind a boolean with `v-model`, then report the change and blur to NotForm yourself.

```ts
responses: z.literal(true, 'Notifications are mandatory')
```

<code-collapse>

```vue
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('responses')"
  >
    <fieldset class="m-0">
      <legend>
        Responses
      </legend>

      <p class="mbs-1 text-sm">
        Get notified for requests that take time, like research or image
        generation.
      </p>

      <v-checkbox
        :id="field.path"
        v-model="field.model"
        :error-messages="getErrorMessages(field.errors)"
        label="Push notifications"
        @blur="field.events.onBlur()"
        @update:model-value="field.events.onChange"
      />
    </fieldset>
  </Field>
</template>
```

</code-collapse>

## Checkbox group

When several checkboxes share one field, give each its own `:value` and bind them all to the same `field.model`. Vuetify collects the selected values into an array, so you do not need to update the array yourself.

```ts
tasks: z
  .array(z.string())
  .min(1, 'Please select at least one notification type.')
  .refine((value) => {
    return value.every((taskId) => {
      return tasks.some((task) => {
        return task.id === taskId
      })
    })
  }, 'Invalid notification type selected.')
```

Each checkbox hides its own details area with `hide-details`, and a single `v-messages` below the group shows the field's error. Set `:error` on each checkbox so they are styled as invalid together.

<code-collapse>

```vue
<script setup lang="ts">
const tasks = [
  {
    id: 'push',
    label: 'Push notifications',
  },
  {
    id: 'email',
    label: 'Email notifications',
  },
] as const
</script>

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('tasks')"
  >
    <fieldset class="m-0">
      <legend>
        Tasks
      </legend>

      <p class="mbs-1 text-sm">
        Get notified when tasks you've created have updates.
      </p>

      <v-checkbox
        v-for="task in tasks"
        :id="`${field.path}-${task.id}`"
        :key="task.id"
        v-model="field.model"
        :value="task.id"
        :label="task.label"
        :error="!field.isValid"
        density="compact"
        hide-details
        @blur="field.events.onBlur()"
        @update:model-value="field.events.onChange"
      />

      <v-messages
        :active="!field.isValid"
        :messages="getErrorMessages(field.errors)"
        class="mbs-1 text-error"
      />
    </fieldset>
  </Field>
</template>
```

</code-collapse>

Start with an empty array in `initialValues` (`tasks: []`).

## Radio group

Bind the group, not the individual radios. Each `v-radio` only needs its own `value`. Use the `label` slot to render a title with a description.

```ts
plan: z.string().min(1, 'You must select a subscription plan to continue.')
```

<code-collapse>

```vue
<script setup lang="ts">
const plans = [
  {
    description: 'For everyday use with basic features.',
    id: 'starter',
    title: 'Starter (100K tokens/month)',
  },
  {
    description: 'For advanced AI usage with more features.',
    id: 'pro',
    title: 'Pro (1M tokens/month)',
  },
  {
    description: 'For large teams and heavy usage.',
    id: 'enterprise',
    title: 'Enterprise (Unlimited tokens)',
  },
] as const
</script>

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

      <p class="mbs-1 text-sm">
        You can upgrade or downgrade your plan at any time.
      </p>

      <v-radio-group
        v-model="field.model"
        :error-messages="getErrorMessages(field.errors)"
        @focusout="field.events.onBlur()"
        @update:model-value="field.events.onChange"
      >
        <v-radio
          v-for="plan in plans"
          :key="plan.id"
          :value="plan.id"
        >
          <template #label>
            <div class="py-1">
              <div class="font-medium">
                {{ plan.title }}
              </div>

              <div class="text-sm">
                {{ plan.description }}
              </div>
            </div>
          </template>
        </v-radio>
      </v-radio-group>
    </fieldset>
  </Field>
</template>
```

</code-collapse>

## Switch

`v-switch` follows the same pattern as `v-checkbox`: a boolean model plus explicit change and blur handlers.

```ts
twoFactor: z.boolean().refine((value) => {
  return value === true
}, 'It is highly recommended to enable two-factor authentication.')
```

<code-collapse>

```vue
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('twoFactor')"
  >
    <v-switch
      :id="field.path"
      v-model="field.model"
      :error-messages="getErrorMessages(field.errors)"
      label="Multi-factor authentication"
      hint="Enable multi-factor authentication to secure your account."
      persistent-hint
      inset
      @blur="field.events.onBlur()"
      @update:model-value="field.events.onChange"
    />
  </Field>
</template>
```

</code-collapse>

## Array fields

Use `ArrayField` for lists the user can grow, shrink, and reorder. Each item exposes `item.field('key')`, so you can wrap any of the inputs above in a `Field`.

```ts
emails: z.array(z.object({ address: z.email('Enter a valid email address.') }))
  .min(2, 'Add at least two email addresses.')
  .max(5, 'You can add up to 5 email addresses.')
```

<code-collapse>

```vue
<template>
  <ArrayField
    v-slot="{ arrayField }"
    :array-field="form.arrayField('emails')"
  >
    <fieldset class="m-0">
      <legend>
        Email Addresses
      </legend>

      <p class="mbe-3 text-sm">
        Add up to 5 email addresses where we can contact you.
      </p>

      <div class="flex flex-col gap-2">
        <Field
          v-for="item in arrayField.items"
          :key="item.id"
          v-slot="{ field }"
          :field="item.field('address')"
        >
          <v-text-field
            :id="field.path"
            v-model="field.model"
            v-bind="field.events"
            :error-messages="getErrorMessages(field.errors)"
            :aria-label="`Email address ${item.index + 1}`"
            placeholder="name@example.com"
            type="email"
            autocomplete="email"
            variant="outlined"
          >
            <template
              v-if="arrayField.items.length > 1"
              #append-inner
            >
              <v-btn
                type="button"
                icon="mdi-chevron-up"
                variant="text"
                size="small"
                density="comfortable"
                :disabled="item.index === 0"
                :aria-label="`Move email ${item.index + 1} up`"
                @click="arrayField.move(item.index, item.index - 1)"
              />

              <v-btn
                type="button"
                icon="mdi-chevron-down"
                variant="text"
                size="small"
                density="comfortable"
                :disabled="item.index === arrayField.items.length - 1"
                :aria-label="`Move email ${item.index + 1} down`"
                @click="arrayField.move(item.index, item.index + 1)"
              />

              <v-btn
                type="button"
                icon="mdi-close"
                variant="text"
                size="small"
                density="comfortable"
                :aria-label="`Remove email ${item.index + 1}`"
                @click="arrayField.remove(item.index)"
              />
            </template>
          </v-text-field>
        </Field>

        <v-btn
          type="button"
          variant="outlined"
          size="small"
          prepend-icon="mdi-plus"
          class="self-start"
          :disabled="arrayField.items.length >= 5"
          @click="arrayField.append({ address: '' })"
        >
          Add Email Address
        </v-btn>
      </div>

      <v-messages
        :active="!arrayField.isValid"
        :messages="getErrorMessages(arrayField.errors)"
        class="my-3 text-error"
      />
    </fieldset>
  </ArrayField>
</template>
```

</code-collapse>

A few things to note:

- `arrayField.items` gives you each item's `id` and `index`. Use `item.id` as the `:key`, not the index, so inputs keep their state when you reorder or remove items.
- `arrayField.append`, `arrayField.remove` and `arrayField.move` change the list. Set `type="button"` on every `v-btn` that calls them so they never submit the form.
- Errors for a single item come from its `field`. Errors for the list as a whole, such as the `min` and `max` rules, come from `arrayField.errors`, shown here with `v-messages`.
- Mirror the schema limits in the UI, as the **Add** button does with `arrayField.items.length >= 5`, so users cannot reach an invalid state by clicking.

## Full example

This form combines every input above.

<code-collapse>

```vue
<script setup lang="ts">
import { ArrayField, Field, Form, useForm, type ValidationIssue } from 'notform'
import { z } from 'zod'

const spokenLanguages = [
  { title: 'Auto', value: 'auto' },
  { type: 'divider' as const },
  { title: 'English', value: 'en' },
  { title: 'Spanish', value: 'es' },
  { title: 'French', value: 'fr' },
]

const plans = [
  {
    description: 'For everyday use with basic features.',
    id: 'starter',
    title: 'Starter (100K tokens/month)',
  },
  {
    description: 'For advanced AI usage with more features.',
    id: 'pro',
    title: 'Pro (1M tokens/month)',
  },
  {
    description: 'For large teams and heavy usage.',
    id: 'enterprise',
    title: 'Enterprise (Unlimited tokens)',
  },
] as const

const tasks = [
  {
    id: 'push',
    label: 'Push notifications',
  },
  {
    id: 'email',
    label: 'Email notifications',
  },
] as const

const schema = z.object({
  description: z.string().min(20, 'Description must be at least 20 characters.')
    .max(100),
  emails: z.array(z.object({ address: z.email('Enter a valid email address.') }))
    .min(2, 'Add at least two email addresses.')
    .max(5, 'You can add up to 5 email addresses.'),
  language: z.string().min(1, 'Please select your spoken language.')
    .refine((value) => {
      return value !== 'auto'
    }, 'Auto-detection is not allowed.'),
  plan: z.string().min(1, 'You must select a subscription plan to continue.'),
  responses: z.literal(true, 'Notifications are mandatory'),
  tasks: z
    .array(z.string())
    .min(1, 'Please select at least one notification type.')
    .refine((value) => {
      return value.every((taskId) => {
        return tasks.some((task) => {
          return task.id === taskId
        })
      })
    }, 'Invalid notification type selected.'),
  title: z.string().min(5, 'Bug title must be at least 5 characters.')
    .max(32),
  twoFactor: z.boolean().refine((value) => {
    return value === true
  }, 'It is highly recommended to enable two-factor authentication.'),
})

const form = useForm({
  initialValues: {
    description: '',
    emails: [{ address: '' }, { address: '' }],
    language: '',
    plan: '',
    responses: true,
    tasks: [],
    title: '',
    twoFactor: false,
  },
  onSubmit(data) {
    console.log(data)
  },
  schema,
})

function getErrorMessages(errors?: ValidationIssue[]) {
  return errors?.map((error) => {
    return error.message
  })
}
</script>

<template>
  <Form
    :form
    class="mx-auto space-y-6 p-8 inline-full max-inline-xl"
  >
    <!-- Text input -->
    <Field
      v-slot="{ field }"
      :field="form.field('title')"
    >
      <v-text-field
        v-model="field.model"
        v-bind="field.events"
        placeholder="Login button not working on mobile"
        autocomplete="off"
        class="inline-full"
        :error-messages="getErrorMessages(field.errors)"
        label="Bug Title"
        persistent-placeholder
        variant="outlined"
      />
    </Field>

    <!-- Textarea -->
    <Field
      v-slot="{ field }"
      :field="form.field('description')"
    >
      <v-textarea
        :id="field.path"
        v-model="field.model"
        v-bind="field.events"
        :error-messages="getErrorMessages(field.errors)"
        label="Description"
        placeholder="I'm having an issue with the login button on mobile."
        persistent-placeholder
        hint="Include steps to reproduce, expected behavior, and what actually happened."
        persistent-hint
        counter="100"
        rows="6"
        no-resize
        variant="outlined"
      />
    </Field>

    <!-- Select -->
    <Field
      v-slot="{ field }"
      :field="form.field('language')"
    >
      <v-select
        :id="field.path"
        v-model="field.model"
        :error-messages="getErrorMessages(field.errors)"
        :items="spokenLanguages"
        label="Spoken Language"
        hint="For best results, select the language you speak."
        persistent-hint
        variant="outlined"
        @update:model-value="field.events.onChange"
        @blur="field.events.onBlur()"
      />
    </Field>

    <!-- Checkbox -->
    <Field
      v-slot="{ field }"
      :field="form.field('responses')"
    >
      <fieldset class="m-0">
        <legend>
          Responses
        </legend>

        <p class="mbs-1 text-sm">
          Get notified for requests that take time, like research or image
          generation.
        </p>

        <v-checkbox
          :id="field.path"
          v-model="field.model"
          :error-messages="getErrorMessages(field.errors)"
          label="Push notifications"
          @blur="field.events.onBlur()"
          @update:model-value="field.events.onChange"
        />
      </fieldset>
    </Field>

    <!-- Checkbox group -->
    <Field
      v-slot="{ field }"
      :field="form.field('tasks')"
    >
      <fieldset class="m-0">
        <legend>
          Tasks
        </legend>

        <p class="mbs-1 text-sm">
          Get notified when tasks you've created have updates.
        </p>

        <v-checkbox
          v-for="task in tasks"
          :id="`${field.path}-${task.id}`"
          :key="task.id"
          v-model="field.model"
          :value="task.id"
          :label="task.label"
          :error="!field.isValid"
          density="compact"
          hide-details
          @blur="field.events.onBlur()"
          @update:model-value="field.events.onChange"
        />

        <v-messages
          :active="!field.isValid"
          :messages="getErrorMessages(field.errors)"
          class="mbs-1 text-error"
        />
      </fieldset>
    </Field>

    <!-- Radio group -->
    <Field
      v-slot="{ field }"
      :field="form.field('plan')"
    >
      <fieldset class="m-0">
        <legend>
          Plan
        </legend>

        <p class="mbs-1 text-sm">
          You can upgrade or downgrade your plan at any time.
        </p>

        <v-radio-group
          v-model="field.model"
          :error-messages="getErrorMessages(field.errors)"
          @focusout="field.events.onBlur()"
          @update:model-value="field.events.onChange"
        >
          <v-radio
            v-for="plan in plans"
            :key="plan.id"
            :value="plan.id"
          >
            <template #label>
              <div class="py-1">
                <div class="font-medium">
                  {{ plan.title }}
                </div>

                <div class="text-sm">
                  {{ plan.description }}
                </div>
              </div>
            </template>
          </v-radio>
        </v-radio-group>
      </fieldset>
    </Field>

    <!-- Switch -->
    <Field
      v-slot="{ field }"
      :field="form.field('twoFactor')"
    >
      <v-switch
        :id="field.path"
        v-model="field.model"
        :error-messages="getErrorMessages(field.errors)"
        label="Multi-factor authentication"
        hint="Enable multi-factor authentication to secure your account."
        persistent-hint
        inset
        @blur="field.events.onBlur()"
        @update:model-value="field.events.onChange"
      />
    </Field>

    <!-- Array field -->
    <ArrayField
      v-slot="{ arrayField }"
      :array-field="form.arrayField('emails')"
    >
      <fieldset class="m-0">
        <legend>
          Email Addresses
        </legend>

        <p class="mbe-3 text-sm">
          Add up to 5 email addresses where we can contact you.
        </p>

        <div class="flex flex-col gap-2">
          <Field
            v-for="item in arrayField.items"
            :key="item.id"
            v-slot="{ field }"
            :field="item.field('address')"
          >
            <v-text-field
              :id="field.path"
              v-model="field.model"
              v-bind="field.events"
              :error-messages="getErrorMessages(field.errors)"
              :aria-label="`Email address ${item.index + 1}`"
              placeholder="name@example.com"
              type="email"
              autocomplete="email"
              variant="outlined"
            >
              <template
                v-if="arrayField.items.length > 1"
                #append-inner
              >
                <v-btn
                  type="button"
                  icon="mdi-chevron-up"
                  variant="text"
                  size="small"
                  density="comfortable"
                  :disabled="item.index === 0"
                  :aria-label="`Move email ${item.index + 1} up`"
                  @click="arrayField.move(item.index, item.index - 1)"
                />

                <v-btn
                  type="button"
                  icon="mdi-chevron-down"
                  variant="text"
                  size="small"
                  density="comfortable"
                  :disabled="item.index === arrayField.items.length - 1"
                  :aria-label="`Move email ${item.index + 1} down`"
                  @click="arrayField.move(item.index, item.index + 1)"
                />

                <v-btn
                  type="button"
                  icon="mdi-close"
                  variant="text"
                  size="small"
                  density="comfortable"
                  :aria-label="`Remove email ${item.index + 1}`"
                  @click="arrayField.remove(item.index)"
                />
              </template>
            </v-text-field>
          </Field>

          <v-btn
            type="button"
            variant="outlined"
            size="small"
            prepend-icon="mdi-plus"
            class="self-start"
            :disabled="arrayField.items.length >= 5"
            @click="arrayField.append({ address: '' })"
          >
            Add Email Address
          </v-btn>
        </div>

        <v-messages
          :active="!arrayField.isValid"
          :messages="getErrorMessages(arrayField.errors)"
          class="my-3 text-error"
        />
      </fieldset>
    </ArrayField>

    <v-row density="compact">
      <v-col>
        <v-btn
          type="reset"
          variant="outlined"
          block
        >
          Reset
        </v-btn>
      </v-col>

      <v-col>
        <v-btn
          type="submit"
          color="primary"
          block
        >
          Submit
        </v-btn>
      </v-col>
    </v-row>
  </Form>
</template>
```

</code-collapse>

## Related

- [Custom components](https://notform.favorodera.dev/docs/integrations/custom-components)

## Sitemap

See the full [sitemap](https://notform.favorodera.dev/sitemap.md) for all pages.
