8.6k

VeeValidate

PreviousNext

Build forms in Vue using VeeValidate and Zod.

In this guide, we will take a look at building forms with VeeValidate. We'll cover building forms with the <Field /> component, adding schema validation using Zod, error handling, accessibility, and more.

Demo

We are going to build the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.

Bug Report

Help us improve by reporting bugs you encounter.

0/100 characters

Include steps to reproduce, expected behavior, and what actually happened.

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/registry/new-york-v4/ui/field'
import { Input } from '@/registry/new-york-v4/ui/input'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from '@/registry/new-york-v4/ui/input-group'

const formSchema = toTypedSchema(
  z.object({
    title: z
      .string()
      .min(5, 'Bug title must be at least 5 characters.')
      .max(32, 'Bug title must be at most 32 characters.'),
    description: z
      .string()
      .min(20, 'Description must be at least 20 characters.')
      .max(100, 'Description must be at most 100 characters.'),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    title: '',
    description: '',
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Bug Report</CardTitle>
      <CardDescription>
        Help us improve by reporting bugs you encounter.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-demo" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="title">
            <Field :data-invalid="!!errors.length">
              <FieldLabel for="form-vee-demo-title">
                Bug Title
              </FieldLabel>
              <Input
                id="form-vee-demo-title"
                v-bind="field"
                placeholder="Login button not working on mobile"
                autocomplete="off"
                :aria-invalid="!!errors.length"
              />
              <FieldError v-if="errors.length" :errors="errors" />
            </Field>
          </VeeField>

          <VeeField v-slot="{ field, errors }" name="description">
            <Field :data-invalid="!!errors.length">
              <FieldLabel for="form-vee-demo-description">
                Description
              </FieldLabel>
              <InputGroup>
                <InputGroupTextarea
                  id="form-vee-demo-description"
                  v-bind="field"
                  placeholder="I'm having an issue with the login button on mobile."
                  :rows="6"
                  class="min-h-24 resize-none"
                  :aria-invalid="!!errors.length"
                />
                <InputGroupAddon align="block-end">
                  <InputGroupText class="tabular-nums">
                    {{ field.value?.length || 0 }}/100 characters
                  </InputGroupText>
                </InputGroupAddon>
              </InputGroup>
              <FieldDescription>
                Include steps to reproduce, expected behavior, and what actually
                happened.
              </FieldDescription>
              <FieldError v-if="errors.length" :errors="errors" />
            </Field>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-demo">
          Submit
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

Approach

This form leverages VeeValidate for performant, flexible form handling. We'll build our form using the <Field /> component, which gives you complete flexibility over the markup and styling.

  • Uses VeeValidate's useForm composable for form state management.
  • VeeValidate's <Field /> component with scoped slots for controlled inputs with validation.
  • shadcn-vue <Field /> components for building accessible forms.
  • Client-side validation using Zod with toTypedSchema.

Anatomy

Here's a basic example of a form using VeeValidate's <Field /> component with scoped slots and shadcn-vue <Field /> components.

<template>
  <VeeField v-slot="{ field, errors }" name="title">
    <Field :data-invalid="!!errors.length">
      <FieldLabel for="title">Bug Title</FieldLabel>
      <Input
        id="title"
        v-bind="field"
        placeholder="Login button not working on mobile"
        autocomplete="off"
        :aria-invalid="!!errors.length"
      />
      <FieldDescription>
        Provide a concise title for your bug report.
      </FieldDescription>
      <FieldError v-if="errors.length" :errors="errors" />
    </Field>
  </VeeField>
</template>

Form

Create a form schema

We'll start by defining the shape of our form using a Zod schema

Form.vue
<script setup lang="ts">
import * as z from 'zod'

const formSchema = z.object({
  title: z
    .string()
    .min(5, 'Bug title must be at least 5 characters.')
    .max(32, 'Bug title must be at most 32 characters.'),
  description: z
    .string()
    .min(20, 'Description must be at least 20 characters.')
    .max(100, 'Description must be at most 100 characters.'),
})
</script>

Setup the form

Next, we'll use the useForm composable from VeeValidate to create our form instance. We'll also add the Zod schema for validation.

Form.vue
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { Field as VeeField, useForm } from 'vee-validate'
import * as z from 'zod'

const formSchema = z.object({
  title: z
    .string()
    .min(5, 'Bug title must be at least 5 characters.')
    .max(32, 'Bug title must be at most 32 characters.'),
  description: z
    .string()
    .min(20, 'Description must be at least 20 characters.')
    .max(100, 'Description must be at most 100 characters.'),
})

const { handleSubmit } = useForm({
  validationSchema: toTypedSchema(formSchema),
  initialValues: {
    title: '',
    description: '',
  },
})

const onSubmit = handleSubmit((values) => {
  // Do something with the form values.
  console.log(values)
})
</script>

<template>
  <form @submit="onSubmit">
    <!-- Build the form here -->
  </form>
</template>

Build the form

We can now build the form using VeeValidate's <Field /> component with scoped slots and shadcn-vue <Field /> components.

Form.vue
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/registry/new-york-v4/ui/field'
import { Input } from '@/registry/new-york-v4/ui/input'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from '@/registry/new-york-v4/ui/input-group'

const formSchema = toTypedSchema(
  z.object({
    title: z
      .string()
      .min(5, 'Bug title must be at least 5 characters.')
      .max(32, 'Bug title must be at most 32 characters.'),
    description: z
      .string()
      .min(20, 'Description must be at least 20 characters.')
      .max(100, 'Description must be at most 100 characters.'),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    title: '',
    description: '',
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Bug Report</CardTitle>
      <CardDescription>
        Help us improve by reporting bugs you encounter.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-demo" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="title">
            <Field :data-invalid="!!errors.length">
              <FieldLabel for="form-vee-demo-title">
                Bug Title
              </FieldLabel>
              <Input
                id="form-vee-demo-title"
                v-bind="field"
                placeholder="Login button not working on mobile"
                autocomplete="off"
                :aria-invalid="!!errors.length"
              />
              <FieldError v-if="errors.length" :errors="errors" />
            </Field>
          </VeeField>

          <VeeField v-slot="{ field, errors }" name="description">
            <Field :data-invalid="!!errors.length">
              <FieldLabel for="form-vee-demo-description">
                Description
              </FieldLabel>
              <InputGroup>
                <InputGroupTextarea
                  id="form-vee-demo-description"
                  v-bind="field"
                  placeholder="I'm having an issue with the login button on mobile."
                  :rows="6"
                  class="min-h-24 resize-none"
                  :aria-invalid="!!errors.length"
                />
                <InputGroupAddon align="block-end">
                  <InputGroupText class="tabular-nums">
                    {{ field.value?.length || 0 }}/100 characters
                  </InputGroupText>
                </InputGroupAddon>
              </InputGroup>
              <FieldDescription>
                Include steps to reproduce, expected behavior, and what actually
                happened.
              </FieldDescription>
              <FieldError v-if="errors.length" :errors="errors" />
            </Field>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-demo">
          Submit
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

Done

That's it. You now have a fully accessible form with client-side validation.

When you submit the form, the onSubmit function will be called with the validated form data. If the form data is invalid, VeeValidate will display the errors next to each field.

Validation

Client-side Validation

VeeValidate validates your form data using the Zod schema. Define a schema and pass it to the validationSchema option of the useForm composable.

ExampleForm.vue
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { Field as VeeField, useForm } from 'vee-validate'
import * as z from 'zod'

const formSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
})

const { handleSubmit } = useForm({
  validationSchema: toTypedSchema(formSchema),
  initialValues: {
    title: '',
    description: '',
  },
})
</script>

Validation Modes

VeeValidate supports different validation strategies through the Field component props.

Form.vue
<VeeField
  v-slot="{ field, errors }"
  name="title"
  :validate-on-input="true"
>
  <!-- field content -->
</VeeField>
PropDescription
validateOnInputValidation triggers on input event.
validateOnChangeValidation triggers on change event.
validateOnBlurValidation triggers on blur event.
validateOnMountValidation triggers when component is mounted.

Displaying Errors

Display errors next to the field using <FieldError />. For styling and accessibility:

  • Add the :data-invalid prop to the shadcn-vue <Field /> component.
  • Add the :aria-invalid prop to the form control such as <Input />, <SelectTrigger />, <Checkbox />, etc.
Form.vue
<template>
  <VeeField v-slot="{ field, errors }" name="email">
    <Field :data-invalid="!!errors.length">
      <FieldLabel for="email">Email</FieldLabel>
      <Input
        id="email"
        v-bind="field"
        type="email"
        :aria-invalid="!!errors.length"
      />
      <FieldError v-if="errors.length" :errors="errors"></FieldError>
    </Field>
  </VeeField>
</template>

Working with Different Field Types

Input

  • For input fields, use v-bind="field" to bind VeeValidate's field object to the input.
  • To show errors, add the :aria-invalid prop to the <Input /> component and the :data-invalid prop to the shadcn-vue <Field /> component.

Profile Settings

Update your profile information below.

This is your public display name. Must be between 3 and 10 characters. Must only contain letters, numbers, and underscores.

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/registry/new-york-v4/ui/field'
import { Input } from '@/registry/new-york-v4/ui/input'

const formSchema = toTypedSchema(
  z.object({
    username: z
      .string()
      .min(3, 'Username must be at least 3 characters.')
      .max(10, 'Username must be at most 10 characters.')
      .regex(
        /^\w+$/,
        'Username can only contain letters, numbers, and underscores.',
      ),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    username: '',
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Profile Settings</CardTitle>
      <CardDescription>
        Update your profile information below.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-input" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="username">
            <Field :data-invalid="!!errors.length">
              <FieldLabel for="form-vee-input-username">
                Username
              </FieldLabel>
              <Input
                id="form-vee-input-username"
                v-bind="field"
                :aria-invalid="!!errors.length"
                placeholder="shadcn"
                autocomplete="username"
              />
              <FieldDescription>
                This is your public display name. Must be between 3 and 10
                characters. Must only contain letters, numbers, and
                underscores.
              </FieldDescription>
              <FieldError v-if="errors.length" :errors="errors" />
            </Field>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-input">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

For simple text inputs, use VeeValidate's Field component with scoped slots.

Form.vue
<template>
  <VeeField v-slot="{ field, errors }" name="name">
    <Field :data-invalid="!!errors.length">
      <FieldLabel for="name">Name</FieldLabel>
      <Input
        id="name"
        v-bind="field"
        placeholder="Enter your name"
        :aria-invalid="!!errors.length"
      />
      <FieldError v-if="errors.length" :errors="errors"></FieldError>
    </Field>
  </VeeField>
</template>

Textarea

  • For textarea fields, use v-bind="field" to bind VeeValidate's field object to the textarea.
  • To show errors, add the :aria-invalid prop to the <Textarea /> component and the :data-invalid prop to the shadcn-vue <Field /> component.

Personalization

Customize your experience by telling us more about yourself.

Tell us more about yourself. This will be used to help us personalize your experience.

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/registry/new-york-v4/ui/field'
import { Textarea } from '@/registry/new-york-v4/ui/textarea'

const formSchema = toTypedSchema(
  z.object({
    about: z
      .string()
      .min(10, 'Please provide at least 10 characters.')
      .max(200, 'Please keep it under 200 characters.'),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    about: '',
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Personalization</CardTitle>
      <CardDescription>
        Customize your experience by telling us more about yourself.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-textarea" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="about">
            <Field :data-invalid="!!errors.length">
              <FieldLabel for="form-vee-textarea-about">
                More about you
              </FieldLabel>
              <Textarea
                id="form-vee-textarea-about"
                v-bind="field"
                :aria-invalid="!!errors.length"
                placeholder="I'm a software engineer..."
                class="min-h-[120px]"
              />
              <FieldDescription>
                Tell us more about yourself. This will be used to help us
                personalize your experience.
              </FieldDescription>
              <FieldError v-if="errors.length" :errors="errors" />
            </Field>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-textarea">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

For textarea fields, use VeeValidate's Field component with scoped slots.

Form.vue
<template>
  <VeeField v-slot="{ field, errors }" name="about">
    <Field :data-invalid="!!errors.length">
      <FieldLabel for="about">More about you</FieldLabel>
      <Textarea
        id="about"
        v-bind="field"
        placeholder="I'm a software engineer..."
        class="min-h-[120px]"
        :aria-invalid="!!errors.length"
      />
      <FieldDescription>
        Tell us more about yourself. This will be used to help us personalize your experience.
      </FieldDescription>
      <FieldError v-if="errors.length" :errors="errors"></FieldError>
    </Field>
  </VeeField>
</template>

Select

  • For select components, use field.value and @update:model-value="field.onChange" for proper binding.
  • To show errors, add the :aria-invalid prop to the <SelectTrigger /> component and the :data-invalid prop to the shadcn-vue <Field /> component.

Language Preferences

Select your preferred spoken language.

For best results, select the language you speak.

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/registry/new-york-v4/ui/field'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from '@/registry/new-york-v4/ui/select'

const spokenLanguages = [
  { label: 'English', value: 'en' },
  { label: 'Spanish', value: 'es' },
  { label: 'French', value: 'fr' },
  { label: 'German', value: 'de' },
  { label: 'Italian', value: 'it' },
  { label: 'Chinese', value: 'zh' },
  { label: 'Japanese', value: 'ja' },
] as const

const formSchema = toTypedSchema(
  z.object({
    language: z
      .string()
      .min(1, 'Please select your spoken language.')
      .refine(val => val !== 'auto', {
        message:
          'Auto-detection is not allowed. Please select a specific language.',
      }),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    language: '',
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-lg">
    <CardHeader>
      <CardTitle>Language Preferences</CardTitle>
      <CardDescription>
        Select your preferred spoken language.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-select" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="language">
            <Field
              orientation="responsive"
              :data-invalid="!!errors.length"
            >
              <FieldContent>
                <FieldLabel for="form-vee-select-language">
                  Spoken Language
                </FieldLabel>
                <FieldDescription>
                  For best results, select the language you speak.
                </FieldDescription>
                <FieldError v-if="errors.length" :errors="errors" />
              </FieldContent>
              <Select
                :name="field.name"
                :model-value="field.value"
                @update:model-value="field.onChange"
              >
                <SelectTrigger
                  id="form-vee-select-language"
                  :aria-invalid="!!errors.length"
                  class="min-w-[120px]"
                >
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectContent position="item-aligned">
                  <SelectItem value="auto">
                    Auto
                  </SelectItem>
                  <SelectSeparator />
                  <SelectItem
                    v-for="language in spokenLanguages"
                    :key="language.value"
                    :value="language.value"
                  >
                    {{ language.label }}
                  </SelectItem>
                </SelectContent>
              </Select>
            </Field>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-select">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
Form.vue
<template>
  <VeeField v-slot="{ field, errors }" name="language">
    <Field orientation="responsive" :data-invalid="!!errors.length">
      <FieldContent>
        <FieldLabel for="language">Spoken Language</FieldLabel>
        <FieldDescription>For best results, select the language you speak.</FieldDescription>
        <FieldError v-if="errors.length" :errors="errors"></FieldError>
      </FieldContent>
      <Select
        :model-value="field.value"
        @update:model-value="field.onChange"
        @blur="field.onBlur"
      >
        <SelectTrigger
          id="language"
          class="min-w-[120px]"
          :aria-invalid="!!errors.length"
        >
          <SelectValue placeholder="Select" />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          <SelectItem value="auto">Auto</SelectItem>
          <SelectItem value="en">English</SelectItem>
        </SelectContent>
      </Select>
    </Field>
  </VeeField>
</template>

Checkbox

  • For checkbox arrays, use VeeValidate's Field component with a custom handler to manage array state.
  • To show errors, add the :aria-invalid prop to the <Checkbox /> component and the :data-invalid prop to the shadcn-vue <Field /> component.
  • Remember to add data-slot="checkbox-group" to the <FieldGroup /> component for proper styling and spacing.

Notifications

Manage your notification preferences.

Responses

Get notified for requests that take time, like research or image generation.

Tasks

Get notified when tasks you've created have updates.

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import { Checkbox } from '@/registry/new-york-v4/ui/checkbox'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from '@/registry/new-york-v4/ui/field'

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

const formSchema = toTypedSchema(
  z.object({
    responses: z.boolean(),
    tasks: z
      .array(z.string())
      .min(1, 'Please select at least one notification type.')
      .refine(
        value => value.every(task => tasks.some(t => t.id === task)),
        {
          message: 'Invalid notification type selected.',
        },
      ),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    responses: true,
    tasks: [],
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Notifications</CardTitle>
      <CardDescription>Manage your notification preferences.</CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-checkbox" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="responses" type="checkbox">
            <FieldSet :data-invalid="!!errors.length">
              <FieldLegend variant="label">
                Responses
              </FieldLegend>
              <FieldDescription>
                Get notified for requests that take time, like research or image
                generation.
              </FieldDescription>
              <FieldGroup data-slot="checkbox-group">
                <Field orientation="horizontal">
                  <Checkbox
                    id="form-vee-checkbox-responses"
                    :name="field.name"
                    :model-value="field.value"
                    disabled
                    @update:model-value="field.onChange"
                  />
                  <FieldLabel
                    for="form-vee-checkbox-responses"
                    class="font-normal"
                  >
                    Push notifications
                  </FieldLabel>
                </Field>
              </FieldGroup>
              <FieldError v-if="errors.length" :errors="errors" />
            </FieldSet>
          </VeeField>
          <FieldSeparator />
          <VeeField v-slot="{ field, errors }" name="tasks">
            <FieldSet :data-invalid="!!errors.length">
              <FieldLegend variant="label">
                Tasks
              </FieldLegend>
              <FieldDescription>
                Get notified when tasks you've created have updates.
              </FieldDescription>
              <FieldGroup data-slot="checkbox-group">
                <Field
                  v-for="task in tasks"
                  :key="task.id"
                  orientation="horizontal"
                  :data-invalid="!!errors.length"
                >
                  <Checkbox
                    :id="`form-vee-checkbox-${task.id}`"
                    :name="field.name"
                    :aria-invalid="!!errors.length"
                    :model-value="field.value?.includes(task.id)"
                    @update:model-value="
                      (checked: boolean | 'indeterminate') => {
                        const newValue = checked
                          ? [...(field.value || []), task.id]
                          : (field.value || []).filter(
                            (value: string) => value !== task.id,
                          );
                        field.onChange(newValue);
                      }
                    "
                  />
                  <FieldLabel
                    :for="`form-vee-checkbox-${task.id}`"
                    class="font-normal"
                  >
                    {{ task.label }}
                  </FieldLabel>
                </Field>
              </FieldGroup>
              <FieldError v-if="errors.length" :errors="errors" />
            </FieldSet>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-checkbox">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
Form.vue
<template>
  <VeeField v-slot="{ field, errors }" name="tasks">
    <FieldSet>
      <FieldLegend variant="label">Tasks</FieldLegend>
      <FieldDescription>Get notified when tasks you've created have updates.</FieldDescription>
      <FieldGroup data-slot="checkbox-group">
        <Field
          v-for="task in tasks"
          :key="task.id"
          orientation="horizontal"
          :data-invalid="!!errors.length"
        >
          <Checkbox
            :id="`task-${task.id}`"
            :model-value="field.value?.includes(task.id) ?? false"
            :aria-invalid="!!errors.length"
            @update:model-value="(checked | 'indeterminate') => {
              const currentTasks = field.value || []
              const newValue = checked
                ? [...currentTasks, task.id]
                : currentTasks.filter(id => id !== task.id)
              field.onChange(newValue)
            }"
          />
          <FieldLabel :for="`task-${task.id}`" class="font-normal">
            {{ task.label }}
          </FieldLabel>
        </Field>
      </FieldGroup>
      <FieldError v-if="errors.length" :errors="errors"></FieldError>
    </FieldSet>
  </VeeField>
</template>

Radio Group

  • For radio groups, use field.value and @update:model-value="field.onChange" for proper binding.
  • To show errors, add the :aria-invalid prop to the <RadioGroupItem /> component and the :data-invalid prop to the shadcn-vue <Field /> component.

Subscription Plan

See pricing and features for each plan.

Plan

You can upgrade or downgrade your plan at any time.

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
  FieldTitle,
} from '@/registry/new-york-v4/ui/field'
import {
  RadioGroup,
  RadioGroupItem,
} from '@/registry/new-york-v4/ui/radio-group'

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

const formSchema = toTypedSchema(
  z.object({
    plan: z.string().min(1, 'You must select a subscription plan to continue.'),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    plan: '',
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Subscription Plan</CardTitle>
      <CardDescription>
        See pricing and features for each plan.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-radiogroup" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="plan">
            <FieldSet :data-invalid="!!errors.length">
              <FieldLegend>Plan</FieldLegend>
              <FieldDescription>
                You can upgrade or downgrade your plan at any time.
              </FieldDescription>
              <RadioGroup
                :name="field.name"
                :model-value="field.value"
                :aria-invalid="!!errors.length"
                @update:model-value="field.onChange"
              >
                <FieldLabel
                  v-for="plan in plans"
                  :key="plan.id"
                  :for="`form-vee-radiogroup-${plan.id}`"
                >
                  <Field
                    orientation="horizontal"
                    :data-invalid="!!errors.length"
                  >
                    <FieldContent>
                      <FieldTitle>{{ plan.title }}</FieldTitle>
                      <FieldDescription>
                        {{ plan.description }}
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      :id="`form-vee-radiogroup-${plan.id}`"
                      :value="plan.id"
                      :aria-invalid="!!errors.length"
                    />
                  </Field>
                </FieldLabel>
              </RadioGroup>
              <FieldError v-if="errors.length" :errors="errors" />
            </FieldSet>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-radiogroup">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
Form.vue
<template>
  <VeeField v-slot="{ field, errors }" name="plan">
    <FieldSet>
      <FieldLegend>Plan</FieldLegend>
      <FieldDescription>
        You can upgrade or downgrade your plan at any time.
      </FieldDescription>
      <RadioGroup
        :model-value="field.value"
        @update:model-value="field.onChange"
      >
        <FieldLabel v-for="planOption in plans" :key="planOption.id" :for="`plan-${planOption.id}`">
          <Field orientation="horizontal" :data-invalid="!!errors.length">
            <FieldContent>
              <FieldTitle>{{ planOption.title }}</FieldTitle>
              <FieldDescription>{{ planOption.description }}</FieldDescription>
            </FieldContent>
            <RadioGroupItem
              :id="`plan-${planOption.id}`"
              :value="planOption.id"
              :aria-invalid="!!errors.length"
            />
          </Field>
        </FieldLabel>
      </RadioGroup>
      <FieldError v-if="errors.length" :errors="errors"></FieldError>
    </FieldSet>
  </VeeField>
</template>

Switch

  • For switches, use :model-value="field.value" and @update:model-value="field.onChange" for proper binding.
  • To show errors, add the :aria-invalid prop to the <Switch /> component and the :data-invalid prop to the shadcn-vue <Field /> component.

Security Settings

Manage your account security preferences.

Enable multi-factor authentication to secure your account.

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/registry/new-york-v4/ui/field'
import { Switch } from '@/registry/new-york-v4/ui/switch'

const formSchema = toTypedSchema(
  z.object({
    twoFactor: z.boolean().refine(val => val === true, {
      message: 'It is highly recommended to enable two-factor authentication.',
    }),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    twoFactor: false,
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader>
      <CardTitle>Security Settings</CardTitle>
      <CardDescription>
        Manage your account security preferences.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-switch" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="twoFactor" type="checkbox">
            <Field
              orientation="horizontal"
              :data-invalid="!!errors.length"
            >
              <FieldContent>
                <FieldLabel for="form-vee-switch-twoFactor">
                  Multi-factor authentication
                </FieldLabel>
                <FieldDescription>
                  Enable multi-factor authentication to secure your account.
                </FieldDescription>
                <FieldError v-if="errors.length" :errors="errors" />
              </FieldContent>
              <Switch
                id="form-vee-switch-twoFactor"
                :name="field.name"
                :model-value="field.value"
                :aria-invalid="!!errors.length"
                @update:model-value="field.onChange"
              />
            </Field>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter>
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-switch">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>
Form.vue
<template>
  <VeeField v-slot="{ field, errors }" name="twoFactor">
    <Field orientation="horizontal" :data-invalid="!!errors.length">
      <FieldContent>
        <FieldLabel for="two-factor">Multi-factor authentication</FieldLabel>
        <FieldDescription>
          Enable multi-factor authentication to secure your account.
        </FieldDescription>
        <FieldError v-if="errors.length" :errors="errors"></FieldError>
      </FieldContent>
      <Switch
        id="two-factor"
        :model-value="field.value"
        @update:model-value="field.onChange"
        :aria-invalid="!!errors.length"
      />
    </Field>
  </VeeField>
</template>

Complex Forms

Here is an example of a more complex form with multiple fields and validation.

You're almost there!

Choose your subscription plan and billing period.

Subscription Plan

Choose your subscription plan.

Choose how often you want to be billed.

Add-ons

Select additional features you'd like to include.

Advanced analytics and reporting

Automated daily backups

24/7 premium customer support

Receive email updates about your subscription

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import { Checkbox } from '@/registry/new-york-v4/ui/checkbox'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldTitle,
} from '@/registry/new-york-v4/ui/field'
import {
  RadioGroup,
  RadioGroupItem,
} from '@/registry/new-york-v4/ui/radio-group'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/registry/new-york-v4/ui/select'
import { Switch } from '@/registry/new-york-v4/ui/switch'

const addons = [
  {
    id: 'analytics',
    title: 'Analytics',
    description: 'Advanced analytics and reporting',
  },
  {
    id: 'backup',
    title: 'Backup',
    description: 'Automated daily backups',
  },
  {
    id: 'support',
    title: 'Priority Support',
    description: '24/7 premium customer support',
  },
] as const

const formSchema = toTypedSchema(
  z.object({
    plan: z
      .string({
        required_error: 'Please select a subscription plan',
      })
      .min(1, 'Please select a subscription plan')
      .refine(value => value === 'basic' || value === 'pro', {
        message: 'Invalid plan selection. Please choose Basic or Pro',
      }),
    billingPeriod: z
      .string({
        required_error: 'Please select a billing period',
      })
      .min(1, 'Please select a billing period'),
    addons: z
      .array(z.string())
      .min(1, 'Please select at least one add-on')
      .max(3, 'You can select up to 3 add-ons')
      .refine(
        value => value.every(addon => addons.some(a => a.id === addon)),
        {
          message: 'You selected an invalid add-on',
        },
      ),
    emailNotifications: z.boolean(),
  }),
)

const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  initialValues: {
    plan: 'basic',
    billingPeriod: '',
    addons: [],
    emailNotifications: false,
  },
})

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full max-w-sm">
    <CardHeader class="border-b">
      <CardTitle>You're almost there!</CardTitle>
      <CardDescription>
        Choose your subscription plan and billing period.
      </CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-complex" @submit="onSubmit">
        <FieldGroup>
          <VeeField v-slot="{ field, errors }" name="plan">
            <FieldSet :data-invalid="!!errors.length">
              <FieldLegend variant="label">
                Subscription Plan
              </FieldLegend>
              <FieldDescription>
                Choose your subscription plan.
              </FieldDescription>
              <RadioGroup
                :name="field.name"
                :model-value="field.value"
                :aria-invalid="!!errors.length"
                @update:model-value="field.onChange"
              >
                <FieldLabel for="form-vee-complex-basic">
                  <Field orientation="horizontal">
                    <FieldContent>
                      <FieldTitle>Basic</FieldTitle>
                      <FieldDescription>
                        For individuals and small teams
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      id="form-vee-complex-basic"
                      value="basic"
                    />
                  </Field>
                </FieldLabel>
                <FieldLabel for="form-vee-complex-pro">
                  <Field orientation="horizontal">
                    <FieldContent>
                      <FieldTitle>Pro</FieldTitle>
                      <FieldDescription>
                        For businesses with higher demands
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem
                      id="form-vee-complex-pro"
                      value="pro"
                    />
                  </Field>
                </FieldLabel>
              </RadioGroup>
              <FieldError v-if="errors.length" :errors="errors" />
            </FieldSet>
          </VeeField>
          <FieldSeparator />
          <VeeField v-slot="{ field, errors }" name="billingPeriod">
            <Field :data-invalid="!!errors.length">
              <FieldLabel for="form-vee-complex-billingPeriod">
                Billing Period
              </FieldLabel>
              <Select
                :name="field.name"
                :model-value="field.value"
                @update:model-value="field.onChange"
              >
                <SelectTrigger
                  id="form-vee-complex-billingPeriod"
                  :aria-invalid="!!errors.length"
                >
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="monthly">
                    Monthly
                  </SelectItem>
                  <SelectItem value="yearly">
                    Yearly
                  </SelectItem>
                </SelectContent>
              </Select>
              <FieldDescription>
                Choose how often you want to be billed.
              </FieldDescription>
              <FieldError v-if="errors.length" :errors="errors" />
            </Field>
          </VeeField>
          <FieldSeparator />
          <VeeField v-slot="{ field, errors }" name="addons">
            <FieldSet>
              <FieldLegend>Add-ons</FieldLegend>
              <FieldDescription>
                Select additional features you'd like to include.
              </FieldDescription>
              <FieldGroup data-slot="checkbox-group">
                <Field
                  v-for="addon in addons"
                  :key="addon.id"
                  orientation="horizontal"
                  :data-invalid="!!errors.length"
                >
                  <Checkbox
                    :id="`form-vee-complex-${addon.id}`"
                    :name="field.name"
                    :aria-invalid="!!errors.length"
                    :model-value="field.value?.includes(addon.id)"
                    @update:model-value="(checked: boolean | 'indeterminate') => {
                      const newValue = checked
                        ? [...(field.value || []), addon.id]
                        : (field.value || []).filter((value: string) => value !== addon.id)
                      field.onChange(newValue)
                    }"
                  />
                  <FieldContent>
                    <FieldLabel :for="`form-vee-complex-${addon.id}`">
                      {{ addon.title }}
                    </FieldLabel>
                    <FieldDescription>
                      {{ addon.description }}
                    </FieldDescription>
                  </FieldContent>
                </Field>
              </FieldGroup>
              <FieldError v-if="errors.length" :errors="errors" />
            </FieldSet>
          </VeeField>
          <FieldSeparator />
          <VeeField
            v-slot="{ field, errors }"
            name="emailNotifications"
            type="checkbox"
          >
            <Field
              orientation="horizontal"
              :data-invalid="!!errors.length"
            >
              <FieldContent>
                <FieldLabel for="form-vee-complex-emailNotifications">
                  Email Notifications
                </FieldLabel>
                <FieldDescription>
                  Receive email updates about your subscription
                </FieldDescription>
              </FieldContent>
              <Switch
                id="form-vee-complex-emailNotifications"
                :name="field.name"
                :model-value="field.value"
                :aria-invalid="!!errors.length"
                @update:model-value="field.onChange"
              />
              <FieldError v-if="errors.length" :errors="errors" />
            </Field>
          </VeeField>
        </FieldGroup>
      </form>
    </CardContent>
    <CardFooter class="border-t">
      <Field>
        <Button type="submit" form="form-vee-complex">
          Save Preferences
        </Button>
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

Resetting the Form

Use the resetForm function returned by useForm to reset the form to its initial values.

<script setup lang="ts">
const { handleSubmit, resetForm } = useForm({
  validationSchema: formSchema,
  // ...
})
</script>

<template>
  <Button type="button" variant="outline" @click="resetForm">
    Reset
  </Button>
</template>

Array Fields

VeeValidate provides a FieldArray component for managing dynamic array fields. This is useful when you need to add or remove fields dynamically.

Contact Emails

Manage your contact email addresses.

Email Addresses

Add up to 5 email addresses where we can contact you.

<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { X } from 'lucide-vue-next'
import { useFieldArray, useForm, Field as VeeField } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'

import { Button } from '@/registry/new-york-v4/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/registry/new-york-v4/ui/card'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLegend,
  FieldSet,
} from '@/registry/new-york-v4/ui/field'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from '@/registry/new-york-v4/ui/input-group'

const formSchema = toTypedSchema(
  z.object({
    emails: z
      .array(
        z.object({
          address: z.string().email('Enter a valid email address.'),
        }),
      )
      .min(1, 'Add at least one email address.')
      .max(5, 'You can add up to 5 email addresses.'),
  }),
)

const { handleSubmit, resetForm, errors } = useForm({
  validationSchema: formSchema,
  initialValues: {
    emails: [{ address: '' }, { address: '' }],
  },
})

const { remove, push, fields } = useFieldArray('emails')

function addEmail() {
  push({ address: '' })
}

const onSubmit = handleSubmit((data) => {
  toast('You submitted the following values:', {
    description: h('pre', { class: 'bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4' }, h('code', JSON.stringify(data, null, 2))),
    position: 'bottom-right',
    class: 'flex flex-col gap-2',
    style: {
      '--border-radius': 'calc(var(--radius)  + 4px)',
    },
  })
})
</script>

<template>
  <Card class="w-full sm:max-w-md">
    <CardHeader class="border-b">
      <CardTitle>Contact Emails</CardTitle>
      <CardDescription>Manage your contact email addresses.</CardDescription>
    </CardHeader>
    <CardContent>
      <form id="form-vee-array" @submit="onSubmit">
        <FieldSet class="gap-4">
          <FieldLegend variant="label">
            Email Addresses
          </FieldLegend>
          <FieldDescription>
            Add up to 5 email addresses where we can contact you.
          </FieldDescription>
          <FieldGroup class="gap-4">
            <VeeField
              v-for="(field, index) in fields"
              :key="field.key"
              v-slot="{ field: fieldProps, errors: fieldErrors }"
              :name="`emails[${index}].address`"
            >
              <Field
                orientation="horizontal"
                :data-invalid="!!fieldErrors.length"
              >
                <FieldContent>
                  <InputGroup>
                    <InputGroupInput
                      :id="`form-vee-array-email-${index}`"
                      v-bind="fieldProps"
                      :aria-invalid="!!fieldErrors.length"
                      placeholder="name@example.com"
                      type="email"
                      autocomplete="email"
                    />
                    <InputGroupAddon
                      v-if="fields.length > 1"
                      align="inline-end"
                    >
                      <InputGroupButton
                        type="button"
                        variant="ghost"
                        size="icon-xs"
                        :aria-label="`Remove email ${index + 1}`"
                        @click="remove(index)"
                      >
                        <X />
                      </InputGroupButton>
                    </InputGroupAddon>
                  </InputGroup>
                  <FieldError v-if="fieldErrors.length" :errors="fieldErrors" />
                </FieldContent>
              </Field>
            </VeeField>
            <Button
              type="button"
              variant="outline"
              size="sm"
              :disabled="fields.length >= 5"
              @click="addEmail"
            >
              Add Email Address
            </Button>
          </FieldGroup>
          <FieldError v-if="errors.emails" :errors="[errors.emails]" />
        </FieldSet>
      </form>
    </CardContent>
    <CardFooter class="border-t">
      <Field orientation="horizontal">
        <Button type="button" variant="outline" @click="resetForm">
          Reset
        </Button>
        <Button type="submit" form="form-vee-array">
          Save
        </Button>
      </Field>
    </CardFooter>
  </Card>
</template>

Using FieldArray

Use the FieldArray component to manage array fields. It provides fields, push, and remove methods through its slot props.

Form.vue
<script setup lang="ts">
import { FieldArray as VeeFieldArray } from 'vee-validate'
</script>

<VeeFieldArray v-slot="{ fields, push, remove }" name="emails">
  <!-- Array items go here -->
</VeeFieldArray>

Array Field Structure

Wrap your array fields in a <FieldSet /> with a <FieldLegend /> and <FieldDescription />.

Form.vue
<template>
  <FieldSet class="gap-4">
    <FieldLegend variant="label">Email Addresses</FieldLegend>
    <FieldDescription>
      Add up to 5 email addresses where we can contact you.
    </FieldDescription>
    <FieldGroup class="gap-4">
      <!-- Array items go here -->
    </FieldGroup>
  </FieldSet>
</template>

Field Pattern for Array Items

Map over the fields array and create fields for each item. Make sure to use field.key as the key.

Form.vue
<template>
  <VeeFieldArray v-slot="{ fields, push, remove }" name="emails">
    <VeeField
      v-for="(field, index) in fields"
      :key="field.key"
      :name="`emails[${index}].address`"
      v-slot="{ field: controllerField, errors }"
    >
      <Field orientation="horizontal" :data-invalid="!!errors.length">
        <FieldContent class="flex-1">
          <InputGroup>
            <InputGroupInput
              :id="`email-${index}`"
              v-bind="controllerField"
              type="email"
              placeholder="name@example.com"
              autocomplete="email"
              :aria-invalid="!!errors.length"
            />
            <!-- Remove button -->
          </InputGroup>
          <FieldError v-if="errors.length" :errors="errors"></FieldError>
        </FieldContent>
      </Field>
    </VeeField>
  </VeeFieldArray>
</template>

Adding Items

Use the push method to add new items to the array.

Form.vue
<template>
  <Button
    type="button"
    variant="outline"
    size="sm"
    :disabled="fields.length >= 5"
    @click="push({ address: '' })"
  >
    Add Email Address
  </Button>
<template>

Removing Items

Use the remove method to remove items from the array. Add the remove button conditionally.

Form.vue
<template>
  <InputGroupAddon v-if="fields.length > 1" align="inline-end">
    <InputGroupButton
      type="button"
      variant="ghost"
      size="icon-xs"
      :aria-label="`Remove email ${index + 1}`"
      @click="remove(index)"
    >
      <XIcon />
    </InputGroupButton>
  </InputGroupAddon>
</template>

Array Validation

Use Zod's array method to validate array fields.

Form.vue
const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email('Enter a valid email address.'),
      }),
    )
    .min(1, 'Add at least one email address.')
    .max(5, 'You can add up to 5 email addresses.'),
})