ShadcnVue
ShadcnVue copies component source into your project, so the exact props depend on the version you installed. The mappings below follow the pattern in Custom components. Check your local component files if a binding differs.
Resolve the naming conflict
Recent ShadcnVue releases include Field and FieldError components. NotForm exports components with the same names. Rename one set at the import.
<script setup lang="ts">
import { Form, Field as NotField, FieldError as NotFieldError, useForm } from 'notform'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
</script>
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@notform/nuxt', 'shadcn-vue/nuxt'],
notform: {
aliases: {
Field: 'NotField',
FieldError: 'NotFieldError'
},
},
})
The examples below use NotField for NotForm and Field for ShadcnVue.
Choose the right binding
Every NotField slot gives you a field with a model, a set of events, and the validation state (isValid and errors). How you connect them depends on what the ShadcnVue component emits.
| Component | Binding |
|---|---|
Input, Textarea, InputGroupInput, InputGroupTextarea | v-model="field.model" and v-bind="field.events" |
Select | v-model="field.model" and @update:model-value="field.events.onChange", with @blur="field.events.onBlur" on SelectTrigger |
Checkbox, Switch, RadioGroup | v-model="field.model", @update:model-value="field.events.onChange" and @blur="field.events.onBlur" |
| Checkbox group (array of values) | :model-value and a handler that updates field.model, then calls field.events.onChange() |
Components that only emit update:modelValue don't fire native input or change events, so v-bind="field.events" does nothing for them. Report changes and blur explicitly instead.
Each section below shows the template and the schema it binds to. The snippets assume the form and the imports from the full example.
Text input
title: z
.string()
.min(5, 'Bug title must be at least 5 characters.')
.max(32, 'Bug title must be at most 32 characters.')
<template>
<NotField
v-slot="{ field }"
:field="form.field('title')"
>
<Field :data-invalid="!field.isValid">
<FieldLabel :for="field.path">
Bug Title
</FieldLabel>
<Input
:id="field.path"
v-bind="field.events"
v-model="field.model"
placeholder="Login button not working on mobile"
autocomplete="off"
:aria-invalid="!field.isValid"
/>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</Field>
</NotField>
</template>
field.errors is an array of issues that each have a message, which is the shape ShadcnVue's FieldError renders.
Textarea
Use InputGroupTextarea inside an InputGroup when you want an addon, such as a character counter. It takes the same bindings as a text input.
description: z
.string()
.min(20, 'Description must be at least 20 characters.')
.max(100, 'Description must be at most 100 characters.')
<template>
<NotField
v-slot="{ field }"
:field="form.field('description')"
>
<Field :data-invalid="!field.isValid">
<FieldLabel :for="field.path">
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
:id="field.path"
v-model="field.model"
v-bind="field.events"
placeholder="I'm having an issue with the login button on mobile."
:rows="6"
class="resize-none min-block-24"
:aria-invalid="!field.isValid"
/>
<InputGroupAddon align="block-end">
<InputGroupText class="tabular-nums">
{{ field.model?.length || 0 }}/100 characters
</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>
Include steps to reproduce, expected behavior, and what actually
happened.
</FieldDescription>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</Field>
</NotField>
</template>
field.model is reactive, so you can read it anywhere in the slot, which is how the counter stays in sync.
Select
The trigger is the focusable element, so put the id and report blur from it.
language: z.string().min(1, 'Please select your spoken language.')
<template>
<NotField
v-slot="{ field }"
:field="form.field('language')"
>
<Field :data-invalid="!field.isValid">
<FieldLabel :for="field.path">
Spoken Language
</FieldLabel>
<!-- eslint-disable-next-line vue-a11y/form-control-has-label -->
<Select
v-model="field.model"
@update:model-value="field.events.onChange"
>
<SelectTrigger
:id="field.path"
:aria-invalid="!field.isValid"
@blur="field.events.onBlur()"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="language in spokenLanguages"
:key="language.value"
:value="language.value"
>
{{ language.label }}
</SelectItem>
</SelectContent>
</Select>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</Field>
</NotField>
</template>
Keep an empty string as the initial value so the placeholder shows until the user picks something.
Checkbox
Bind the checkbox with v-model:model-value, then report changes and blur to NotForm yourself: call field.events.onChange from update:model-value and field.events.onBlur from blur.
responses: z.literal(true, 'Notifications are mandatory')
<template>
<NotField
v-slot="{ field }"
:field="form.field('responses')"
>
<Field
orientation="horizontal"
:data-invalid="!field.isValid"
>
<Checkbox
:id="field.path"
v-model="field.model"
:aria-invalid="!field.isValid"
@blur="field.events.onBlur"
@update:model-value="field.events.onChange"
/>
<FieldLabel
:for="field.path"
class="font-normal"
>
Push notifications
</FieldLabel>
</Field>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</NotField>
</template>
If the checkbox can be in an indeterminate state, its value is not a plain boolean, so your schema must accept that.
Checkbox group
When several checkboxes share one field, store the selected values in an array. The checkbox doesn't bind to field.model directly. Instead, derive its checked state and write the new array back yourself.
tasks: z
.array(z.string())
.min(1, 'Please select at least one notification type.')
<template>
<NotField
v-slot="{ field }"
:field="form.field('tasks')"
>
<FieldSet :data-invalid="!field.isValid">
<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="!field.isValid"
>
<Checkbox
:id="`${field.path}-${task.id}`"
:aria-invalid="!field.isValid"
:model-value="field.model?.includes(task.id)"
@blur="field.events.onBlur()"
@update:model-value="(checked: boolean | 'indeterminate') => {
field.model = checked === true
? [...(field.model ?? []), task.id]
: (field.model ?? []).filter(value => value !== task.id)
field.events.onChange()
}"
/>
<FieldLabel
:for="`${field.path}-${task.id}`"
class="font-normal"
>
{{ task.label }}
</FieldLabel>
</Field>
</FieldGroup>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldSet>
</NotField>
</template>
Give each checkbox a unique id by appending the item's id to field.path, and call field.events.onChange() after you update the model so validation runs against the new value.
Radio group
Bind the group, not the items. Each RadioGroupItem only needs its own value.
plan: z.string().min(1, 'You must select a subscription plan to continue.')
<template>
<NotField
v-slot="{ field }"
:field="form.field('plan')"
>
<FieldSet :data-invalid="!field.isValid">
<FieldLegend>Plan</FieldLegend>
<FieldDescription>
You can upgrade or downgrade your plan at any time.
</FieldDescription>
<RadioGroup
v-model="field.model"
:aria-invalid="!field.isValid"
@blur="field.events.onBlur"
@update:model-value="field.events.onChange"
>
<FieldLabel
v-for="plan in plans"
:key="plan.id"
:for="`${field.path}-${plan.id}`"
>
<Field
orientation="horizontal"
:data-invalid="!field.isValid"
>
<FieldContent>
<FieldTitle>{{ plan.title }}</FieldTitle>
<FieldDescription>
{{ plan.description }}
</FieldDescription>
</FieldContent>
<RadioGroupItem
:id="`${field.path}-${plan.id}`"
:value="plan.id"
:aria-invalid="!field.isValid"
/>
</Field>
</FieldLabel>
</RadioGroup>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldSet>
</NotField>
</template>
Switch
Switch follows the same pattern as 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>
<NotField
v-slot="{ field }"
:field="form.field('twoFactor')"
>
<Field
orientation="horizontal"
:data-invalid="!field.isValid"
>
<FieldContent>
<FieldLabel :for="field.path">
Multi-factor authentication
</FieldLabel>
<FieldDescription>
Enable multi-factor authentication to secure your account.
</FieldDescription>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldContent>
<Switch
:id="field.path"
v-model="field.model"
:aria-invalid="!field.isValid"
@blur="field.events.onBlur"
@update:model-value="field.events.onChange"
/>
</Field>
</NotField>
</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 NotField.
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 :data-invalid="!arrayField.isValid">
<FieldLegend variant="label">
Email Addresses
</FieldLegend>
<FieldDescription>
Add up to 5 email addresses where we can contact you.
</FieldDescription>
<FieldGroup class="gap-4">
<NotField
v-for="item in arrayField.items"
:key="item.id"
v-slot="{ field }"
:field="item.field('address')"
>
<Field
orientation="horizontal"
:data-invalid="!field.isValid"
>
<FieldContent>
<InputGroup>
<InputGroupInput
:id="field.path"
v-model="field.model"
v-bind="field.events"
:aria-invalid="!field.isValid"
placeholder="name@example.com"
type="email"
autocomplete="email"
/>
<InputGroupAddon
v-if="arrayField.items.length > 1"
align="inline-end"
>
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
:disabled="item.index === 0"
:aria-label="`Move email ${item.index + 1} up`"
@click="arrayField.move(item.index, item.index - 1)"
>
<ChevronUp />
</InputGroupButton>
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
:disabled="item.index === arrayField.items.length - 1"
:aria-label="`Move email ${item.index + 1} down`"
@click="arrayField.move(item.index, item.index + 1)"
>
<ChevronDown />
</InputGroupButton>
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
:aria-label="`Remove email ${item.index + 1}`"
@click="arrayField.remove(item.index)"
>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldContent>
</Field>
</NotField>
<Button
type="button"
variant="outline"
size="sm"
:disabled="arrayField.items.length >= 5"
@click="arrayField.append({ address: '' })"
>
Add Email Address
</Button>
</FieldGroup>
<FieldError
v-if="!arrayField.isValid"
:errors="arrayField.errors"
/>
</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 every button that calls them so they don't 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. - 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. It assumes vue, zod, @lucide/vue and the ShadcnVue components you've added to your project.
<script setup lang="ts">
import { ChevronDown, ChevronUp, X } from '@lucide/vue'
import { ArrayField, Form, Field as NotField, useForm } from 'notform'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from '@/components/ui/input-group'
import {
RadioGroup,
RadioGroupItem,
} from '@/components/ui/radio-group'
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
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 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, 'Description must be at most 100 characters.'),
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. Please select a specific language.'),
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, 'Bug title must be at most 32 characters.'),
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,
})
</script>
<template>
<Form
:form
class="
mx-auto p-8 inline-full
sm:max-inline-xl
"
>
<FieldGroup>
<!-- Text input -->
<NotField
v-slot="{ field }"
:field="form.field('title')"
>
<Field :data-invalid="!field.isValid">
<FieldLabel :for="field.path">
Bug Title
</FieldLabel>
<Input
:id="field.path"
v-model="field.model"
v-bind="field.events"
placeholder="Login button not working on mobile"
autocomplete="off"
:aria-invalid="!field.isValid"
/>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</Field>
</NotField>
<!-- Textarea -->
<NotField
v-slot="{ field }"
:field="form.field('description')"
>
<Field :data-invalid="!field.isValid">
<FieldLabel :for="field.path">
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
:id="field.path"
v-model="field.model"
v-bind="field.events"
placeholder="I'm having an issue with the login button on mobile."
:rows="6"
class="resize-none min-block-24"
:aria-invalid="!field.isValid"
/>
<InputGroupAddon align="block-end">
<InputGroupText class="tabular-nums">
{{ field.model?.length || 0 }}/100 characters
</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>
Include steps to reproduce, expected behavior, and what actually
happened.
</FieldDescription>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</Field>
</NotField>
<!-- Select -->
<NotField
v-slot="{ field }"
:field="form.field('language')"
>
<Field :data-invalid="!field.isValid">
<FieldContent>
<FieldLabel :for="field.path">
Spoken Language
</FieldLabel>
<FieldDescription>
For best results, select the language you speak.
</FieldDescription>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldContent>
<!-- eslint-disable-next-line vue-a11y/form-control-has-label -->
<Select
v-model="field.model"
@update:model-value="field.events.onChange"
>
<SelectTrigger
:id="field.path"
:aria-invalid="!field.isValid"
class="min-inline-[120px]"
@blur="field.events.onBlur()"
>
<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>
</NotField>
<!-- Checkbox -->
<NotField
v-slot="{ field }"
:field="form.field('responses')"
>
<FieldSet :data-invalid="!field.isValid">
<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="field.path"
v-model="field.model"
@blur="field.events.onBlur"
@update:model-value="field.events.onChange"
/>
<FieldLabel
:for="field.path"
class="font-normal"
>
Push notifications
</FieldLabel>
</Field>
</FieldGroup>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldSet>
</NotField>
<!-- Checkbox group -->
<NotField
v-slot="{ field }"
:field="form.field('tasks')"
>
<FieldSet :data-invalid="!field.isValid">
<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="!field.isValid"
>
<Checkbox
:id="`${field.path}-${task.id}`"
:aria-invalid="!field.isValid"
:model-value="field.model?.includes(task.id)"
@blur="field.events.onBlur()"
@update:model-value="(checked: boolean | 'indeterminate') => {
field.model = checked === true
? [...(field.model ?? []), task.id]
: (field.model ?? []).filter(value => value !== task.id)
field.events.onChange()
}"
/>
<FieldLabel
:for="`${field.path}-${task.id}`"
class="font-normal"
>
{{ task.label }}
</FieldLabel>
</Field>
</FieldGroup>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldSet>
</NotField>
<!-- Radio group -->
<NotField
v-slot="{ field }"
:field="form.field('plan')"
>
<FieldSet :data-invalid="!field.isValid">
<FieldLegend>Plan</FieldLegend>
<FieldDescription>
You can upgrade or downgrade your plan at any time.
</FieldDescription>
<RadioGroup
v-model="field.model"
:aria-invalid="!field.isValid"
@blur="field.events.onBlur"
@update:model-value="field.events.onChange"
>
<FieldLabel
v-for="plan in plans"
:key="plan.id"
:for="`${field.path}-${plan.id}`"
>
<Field
orientation="horizontal"
:data-invalid="!field.isValid"
>
<FieldContent>
<FieldTitle>{{ plan.title }}</FieldTitle>
<FieldDescription>
{{ plan.description }}
</FieldDescription>
</FieldContent>
<RadioGroupItem
:id="`${field.path}-${plan.id}`"
:value="plan.id"
:aria-invalid="!field.isValid"
/>
</Field>
</FieldLabel>
</RadioGroup>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldSet>
</NotField>
<!-- Switch -->
<NotField
v-slot="{ field }"
:field="form.field('twoFactor')"
>
<Field
:data-invalid="!field.isValid"
orientation="horizontal"
>
<FieldContent>
<FieldLabel :for="field.path">
Multi-factor authentication
</FieldLabel>
<FieldDescription>
Enable multi-factor authentication to secure your account.
</FieldDescription>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldContent>
<Switch
:id="field.path"
v-model="field.model"
:aria-invalid="!field.isValid"
@blur="field.events.onBlur"
@update:model-value="field.events.onChange"
/>
</Field>
</NotField>
<!-- Array field -->
<ArrayField
v-slot="{ arrayField }"
:array-field="form.arrayField('emails')"
>
<FieldSet :data-invalid="!arrayField.isValid">
<FieldLegend variant="label">
Email Addresses
</FieldLegend>
<FieldDescription>
Add up to 5 email addresses where we can contact you.
</FieldDescription>
<FieldGroup class="gap-4">
<NotField
v-for="item in arrayField.items"
:key="item.id"
v-slot="{ field }"
:field="item.field('address')"
>
<Field
orientation="horizontal"
:data-invalid="!field.isValid"
>
<FieldContent>
<InputGroup>
<InputGroupInput
:id="field.path"
v-model="field.model"
v-bind="field.events"
:aria-invalid="!field.isValid"
placeholder="name@example.com"
type="email"
autocomplete="email"
/>
<InputGroupAddon
v-if="arrayField.items.length > 1"
align="inline-end"
>
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
:disabled="item.index === 0"
:aria-label="`Move email ${item.index + 1} up`"
@click="arrayField.move(item.index, item.index - 1)"
>
<ChevronUp />
</InputGroupButton>
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
:disabled="item.index === arrayField.items.length - 1"
:aria-label="`Move email ${item.index + 1} down`"
@click="arrayField.move(item.index, item.index + 1)"
>
<ChevronDown />
</InputGroupButton>
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
:aria-label="`Remove email ${item.index + 1}`"
@click="arrayField.remove(item.index)"
>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldError
v-if="!field.isValid"
:errors="field.errors"
/>
</FieldContent>
</Field>
</NotField>
<Button
type="button"
variant="outline"
size="sm"
:disabled="arrayField.items.length >= 5"
@click="arrayField.append({ address: '' })"
>
Add Email Address
</Button>
</FieldGroup>
<FieldError
v-if="!arrayField.isValid"
:errors="arrayField.errors"
/>
</FieldSet>
</ArrayField>
<Field
orientation="horizontal"
class="*:flex-1"
>
<Button
type="reset"
variant="outline"
>
Reset
</Button>
<Button type="submit">
Submit
</Button>
</Field>
</FieldGroup>
</Form>
</template>