Nuxt UI
Nuxt UI provides styled inputs and a UFormField wrapper for labels and error text. NotForm owns the form state and validation, and Nuxt UI renders the controls.
Setup
Install both modules and register them.
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@notform/nuxt'],
})
Use NotForm's form, not UForm
Wrap your controls in NotForm's Form, not Nuxt UI's UForm. UForm has its own validation and submit pipeline. Using both makes two systems compete over the same state.
UFormField is safe to use on its own. It lays out the label, description, and error text.
Choose the right binding
Bind the model with v-model:model-value, then report changes and blur to NotForm yourself. Text inputs report with onInput, and the other controls report with onChange.
| Component | Binding |
|---|---|
UInput, UTextarea | v-model="field.model", @update:model-value="field.events.onInput" and @blur="field.events.onBlur" |
USelect, UCheckbox, UCheckboxGroup, URadioGroup, USwitch | v-model="field.model", @update:model-value="field.events.onChange"and @blur="field.events.onBlur" |
Show errors
UFormField renders error text from its error prop, so you do not need FieldError here. Pass it the first message of field.errors with a small helper:
import type { ValidationIssue } from 'notform'
function getFirstErrorMessage(errors?: ValidationIssue[]) {
return errors?.[0]?.message
}
Each section below shows the schema and the template it binds to. The snippets assume the form, getFirstErrorMessage and the items from the full example.
Text input
title: z.string().min(5, 'Bug title must be at least 5 characters.')
.max(32)
<template>
<Field
v-slot="{ field }"
:field="form.field('title')"
>
<UFormField
label="Bug Title"
:error="getFirstErrorMessage(field.errors)"
>
<UInput
v-model="field.model"
placeholder="Login button not working on mobile"
autocomplete="off"
class="inline-full"
@update:model-value="field.events.onInput"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
</template>
Textarea
UTextarea takes the same bindings as a text input. Because field.model is reactive, you can read it anywhere in the slot, which is how the character counter stays in sync.
description: z.string().min(20, 'Description must be at least 20 characters.')
.max(100)
<template>
<Field
v-slot="{ field }"
:field="form.field('description')"
>
<UFormField
label="Description"
help="Include steps to reproduce, expected behavior, and what actually happened."
:error="getFirstErrorMessage(field.errors)"
>
<UTextarea
v-model="field.model"
:rows="6"
class="inline-full"
placeholder="I'm having an issue with the login button on mobile."
@update:model-value="field.events.onInput"
@blur="field.events.onBlur"
/>
<p class="mbs-1 text-xs text-muted tabular-nums">
{{ field.model?.length || 0 }}/100 characters
</p>
</UFormField>
</Field>
</template>
Select
Pass the options through items.
language: z.string().min(1, 'Please select your spoken language.')
<template>
<Field
v-slot="{ field }"
:field="form.field('language')"
>
<UFormField
label="Spoken Language"
description="For best results, select the language you speak."
:error="getFirstErrorMessage(field.errors)"
>
<USelect
v-model="field.model"
:items="spokenLanguages"
placeholder="Select"
class="inline-full"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
</template>
Checkbox
responses: z.literal(true, 'Notifications are mandatory')
<template>
<Field
v-slot="{ field }"
:field="form.field('responses')"
>
<UFormField
label="Responses"
description="Get notified for requests that take time."
:error="getFirstErrorMessage(field.errors)"
>
<UCheckbox
v-model="field.model"
label="Push notifications"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
</template>
Checkbox group
UCheckboxGroup binds the array of selected values directly through v-model:model-value.
tasks: z.array(z.string()).min(1, 'Please select at least one notification type.')
<template>
<Field
v-slot="{ field }"
:field="form.field('tasks')"
>
<UFormField :error="getFirstErrorMessage(field.errors)">
<UCheckboxGroup
v-model="field.model"
legend="Tasks"
:items="tasks"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
</template>
Radio group
plan: z.string().min(1, 'You must select a subscription plan to continue.')
<template>
<Field
v-slot="{ field }"
:field="form.field('plan')"
>
<UFormField
description="You can upgrade or downgrade your plan at any time."
:error="getFirstErrorMessage(field.errors)"
>
<URadioGroup
v-model="field.model"
legend="Plan"
variant="card"
indicator="end"
:items="plans"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
</template>
Switch
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')"
>
<UFormField
label="Multi-factor authentication"
description="Enable multi-factor authentication to secure your account."
:error="getFirstErrorMessage(field.errors)"
>
<USwitch
v-model="field.model"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</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')"
>
<UFormField
label="Email Addresses"
description="Add up to 5 email addresses where we can contact you."
:error="getFirstErrorMessage(arrayField.errors)"
>
<div class="space-y-3">
<Field
v-for="item in arrayField.items"
:key="item.id"
v-slot="{ field }"
:field="item.field('address')"
>
<UFormField :error="getFirstErrorMessage(field.errors)">
<UInput
v-model="field.model"
type="email"
placeholder="name@example.com"
class="inline-full"
@update:model-value="field.events.onInput"
@blur="field.events.onBlur"
>
<template
v-if="arrayField.items.length > 1"
#trailing
>
<UButton
icon="i-lucide-chevron-up"
color="neutral"
variant="ghost"
size="xs"
:disabled="item.index === 0"
@click="arrayField.move(item.index, item.index - 1)"
/>
<UButton
icon="i-lucide-chevron-down"
color="neutral"
variant="ghost"
size="xs"
:disabled="item.index === arrayField.items.length - 1"
@click="arrayField.move(item.index, item.index + 1)"
/>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
@click="arrayField.remove(item.index)"
/>
</template>
</UInput>
</UFormField>
</Field>
<UButton
color="neutral"
variant="outline"
block
size="sm"
:disabled="arrayField.items.length >= 5"
@click="arrayField.append({ address: '' })"
>
Add Email Address
</UButton>
</div>
</UFormField>
</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.- Errors for a single item come from its
field. Errors for the list as a whole, such as theminandmaxrules, come fromarrayField.errors. - Mirror the schema limits in the UI, as the Add button does with
arrayField.items.length >= 5, so users can't reach an invalid state by clicking.
Full example
This form combines every input above.
<script setup lang="ts">
import type { ValidationIssue } from 'notform'
import { z } from 'zod'
const spokenLanguages = [
{ label: 'Auto', value: 'auto' },
{ type: 'separator' as const },
{ label: 'English', value: 'en' },
{ label: 'Spanish', value: 'es' },
{ label: 'French', value: 'fr' },
]
const plans = [
{ description: 'For everyday use with basic features.', label: 'Starter (100K tokens/month)', value: 'starter' },
{ description: 'For advanced AI usage with more features.', label: 'Pro (1M tokens/month)', value: 'pro' },
{ description: 'For large teams and heavy usage.', label: 'Enterprise (Unlimited tokens)', value: 'enterprise' },
]
const tasks = [
{ label: 'Push notifications', value: 'push' },
{ label: 'Email notifications', value: 'email' },
]
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.'),
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 getFirstErrorMessage(errors?: ValidationIssue[]) {
return errors?.[0]?.message
}
</script>
<template>
<UApp>
<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')"
>
<UFormField
label="Bug Title"
:error="getFirstErrorMessage(field.errors)"
>
<UInput
v-model="field.model"
placeholder="Login button not working on mobile"
autocomplete="off"
class="inline-full"
@update:model-value="field.events.onInput"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
<!-- Textarea -->
<Field
v-slot="{ field }"
:field="form.field('description')"
>
<UFormField
label="Description"
help="Include steps to reproduce, expected behavior, and what actually happened."
:error="getFirstErrorMessage(field.errors)"
>
<UTextarea
v-model="field.model"
:rows="6"
class="inline-full"
placeholder="I'm having an issue with the login button on mobile."
@update:model-value="field.events.onInput"
@blur="field.events.onBlur"
/>
<p class="mbs-1 text-xs text-muted tabular-nums">
{{ field.model?.length || 0 }}/100 characters
</p>
</UFormField>
</Field>
<!-- Select -->
<Field
v-slot="{ field }"
:field="form.field('language')"
>
<UFormField
label="Spoken Language"
description="For best results, select the language you speak."
:error="getFirstErrorMessage(field.errors)"
>
<USelect
v-model="field.model"
:items="spokenLanguages"
placeholder="Select"
class="inline-full"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
<!-- Checkbox -->
<Field
v-slot="{ field }"
:field="form.field('responses')"
>
<UFormField
label="Responses"
description="Get notified for requests that take time."
:error="getFirstErrorMessage(field.errors)"
>
<UCheckbox
v-model="field.model"
label="Push notifications"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
<!-- Checkbox group -->
<Field
v-slot="{ field }"
:field="form.field('tasks')"
>
<UFormField :error="getFirstErrorMessage(field.errors)">
<UCheckboxGroup
v-model="field.model"
legend="Tasks"
:items="tasks"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
<!-- Radio group -->
<Field
v-slot="{ field }"
:field="form.field('plan')"
>
<UFormField
description="You can upgrade or downgrade your plan at any time."
:error="getFirstErrorMessage(field.errors)"
>
<URadioGroup
v-model="field.model"
legend="Plan"
variant="card"
indicator="end"
:items="plans"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
<!-- Switch -->
<Field
v-slot="{ field }"
:field="form.field('twoFactor')"
>
<UFormField
label="Multi-factor authentication"
description="Enable multi-factor authentication to secure your account."
:error="getFirstErrorMessage(field.errors)"
>
<USwitch
v-model="field.model"
@update:model-value="field.events.onChange"
@blur="field.events.onBlur"
/>
</UFormField>
</Field>
<!-- Array field -->
<ArrayField
v-slot="{ arrayField }"
:array-field="form.arrayField('emails')"
>
<UFormField
label="Email Addresses"
description="Add up to 5 email addresses where we can contact you."
:error="getFirstErrorMessage(arrayField.errors)"
>
<div class="space-y-3">
<Field
v-for="item in arrayField.items"
:key="item.id"
v-slot="{ field }"
:field="item.field('address')"
>
<UFormField :error="getFirstErrorMessage(field.errors)">
<UInput
v-model="field.model"
type="email"
placeholder="name@example.com"
class="inline-full"
@update:model-value="field.events.onInput"
@blur="field.events.onBlur"
>
<template
v-if="arrayField.items.length > 1"
#trailing
>
<UButton
icon="i-lucide-chevron-up"
color="neutral"
variant="ghost"
size="xs"
:disabled="item.index === 0"
@click="arrayField.move(item.index, item.index - 1)"
/>
<UButton
icon="i-lucide-chevron-down"
color="neutral"
variant="ghost"
size="xs"
:disabled="item.index === arrayField.items.length - 1"
@click="arrayField.move(item.index, item.index + 1)"
/>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
@click="arrayField.remove(item.index)"
/>
</template>
</UInput>
</UFormField>
</Field>
<UButton
color="neutral"
variant="outline"
block
size="sm"
:disabled="arrayField.items.length >= 5"
@click="arrayField.append({ address: '' })"
>
Add Email Address
</UButton>
</div>
</UFormField>
</ArrayField>
<div
class="
flex gap-2
*:flex-1 *:justify-center
"
>
<UButton
type="reset"
color="neutral"
variant="outline"
>
Reset
</UButton>
<UButton type="submit">
Submit
</UButton>
</div>
</Form>
</UApp>
</template>
Related
Custom components
Connect any Vue input component to a NotForm field by mapping its value and events, and build reusable field wrappers.
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.