Vuetify
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. Check the props against your installed Vuetify version if a binding differs.
Setup
Install and configure Vuetify by following its installation guide. 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.
| Component | Binding |
|---|---|
v-text-field, v-textarea | v-model="field.model" and v-bind="field.events" |
v-select, v-checkbox, v-switch | v-model="field.model", @update:model-value="field.events.onChange" and @blur="field.events.onBlur()" |
Checkbox group (several v-checkbox with :value) | The same bindings on each checkbox, all sharing one v-model="field.model" |
v-radio-group | v-model="field.model", @update:model-value="field.events.onChange" and @focusout="field.events.onBlur()" |
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.
v-model before v-bind="field.events". The value has to be written before the validation event takes its snapshot.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:
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.
Text field
title: z.string().min(5, 'Bug title must be at least 5 characters.')
.max(32)
<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>
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.
description: z.string().min(20, 'Description must be at least 20 characters.')
.max(100)
<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>
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.
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.
<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>
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.
responses: z.literal(true, 'Notifications are mandatory')
<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>
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.
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.
<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>
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.
plan: z.string().min(1, 'You must select a subscription plan to continue.')
<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>
Switch
v-switch follows the same pattern as v-checkbox: a boolean model plus explicit change and blur handlers.
twoFactor: z.boolean().refine((value) => {
return value === true
}, 'It is highly recommended to enable two-factor authentication.')
<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>
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.
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.')
<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>
A few things to note:
arrayField.itemsgives you each item'sidandindex. Useitem.idas the:key, not the index, so inputs keep their state when you reorder or remove items.arrayField.append,arrayField.removeandarrayField.movechange the list. Settype="button"on everyv-btnthat 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 theminandmaxrules, come fromarrayField.errors, shown here withv-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.
<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>
Related
ShadcnVue
Use NotForm with ShadcnVue inputs, including text, textarea, select, checkbox, radio group, switch, and array fields, and avoid the Field and FieldError naming conflict.
Migrating from NotForm v3
Update NotForm v3 code to v4: renamed APIs, explicit field instances, moved validation config, and array fields.