Shadcn Vue
This guide demonstrates how to seamlessly integrate notform with shadcn-vue layouts and Zod for robust schema validation. We will explore how to manage complex form states while maintaining the elegant aesthetics and accessibility of shadcn components.
Demo
The following demo showcases the full integration of notform with various shadcn-vue components, including inputs, textarea, selects, checkboxes, radio groups, and dynamic array fields.
Approach
NotForm serves as the logic engine for your forms, handling state, validation, and submission, while shadcn-vue provides the visual building blocks. By using the NotField component, you can "wrap" any shadcn control to inject validation logic without sacrificing styling freedom.
- State Management: Handled via the
useNotFormhook. - Field Isolation: Using
NotFieldto expose errors and methods to individual controls. - Visual Feedback: Utilizing shadcn's
FieldandFieldErrorcomponents for consistent UI. - Validation: Powering everything with Zod schemas.
Anatomy
The core pattern involves wrapping shadcn's Field layout with NotForm's NotField . This provides the necessary context (errors, state, and event handlers) to your inputs.
<script setup lang="ts">
const { state } = useNotForm({ /* ... */ })
</script>
<template>
<NotField
name="title"
v-slot="{ errors, name, methods }"
>
<Field :data-invalid="!!errors.length">
<FieldLabel :for="name">
Bug Title
</FieldLabel>
<Input
:id="name"
v-bind="methods"
v-model="state.title"
placeholder="Describe the issue"
:aria-invalid="!!errors.length"
/>
<FieldDescription>
Keep it short and descriptive.
</FieldDescription>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</Field>
</NotField>
</template>
Form
Defining a Schema
Start by establishing the "contract" for your form using Zod. This ensures your data structure is consistent and validates against your rules.
<script setup lang="ts">
import * as z from 'zod'
const schema = z.object({
title: z
.string()
.min(5, 'Title is too short.')
.max(30, 'Title is too long.'),
description: z
.string()
.min(10, 'Please provide more details.'),
})
</script>
Initializing the Form
Use the useNotForm hook to wire up your schema and set initial values. This centralizes your form logic into a single, manageable object.
<script setup lang="ts">
import { NotForm, NotField } from 'notform'
import * as z from 'zod'
const schema = z.object({
title: z.string().min(1, 'Required'),
description: z.string().min(1, 'Required'),
})
const { id, state, submit, reset } = useNotForm({
schema,
initialState: {
title: '',
description: '',
},
onSubmit: (values) => {
// Send data to your API
console.log('Form data:', values)
},
})
</script>
<template>
<NotForm
:id="id"
@submit="submit"
@reset="reset()"
>
<!-- Form fields go here -->
</NotForm>
</template>
Validation
Logic-First Validation
Validation is handled automatically whenever state changes or a field loses focus. notform deep integration with Zod means you only need to define your rules once in the schema.
<script setup lang="ts">
const schema = z.object({
email: z.string().email('Invalid email format.'),
role: z.enum(['admin', 'user']),
})
const { id, state, submit } = useNotForm({ schema })
</script>
Error Handling
Communicating errors effectively is key to a good user experience. By passing !!errors.length to the :data-invalid prop, shadcn components can automatically switch to their "error" styling (e.g., red borders).
<template>
<NotField
name="email"
v-slot="{ methods, name, errors }"
>
<Field :data-invalid="!!errors.length">
<FieldLabel :for="name">
Email address
</FieldLabel>
<Input
:id="name"
v-bind="methods"
v-model="state.email"
type="email"
:aria-invalid="!!errors.length"
/>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</Field>
</NotField>
</template>
Working with Different Field Types
Explore the full implementation for various field types. Click each component to see the complete Vue code.
Input
Simple text inputs with validation.
<script setup lang="ts">
import { Input } from '@/components/input'
import { NotForm, NotField } from 'notform'
const { id, reset, submit, state } = useNotForm({
schema: 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.',
),
}),
initialState: {
username: '',
},
onSubmit: data => submitToast(data),
})
</script>
<template>
<NotForm
:id="id"
@submit="submit"
@reset="reset()"
>
<FieldGroup>
<NotField
name="username"
v-slot="{ errors, name, methods }"
>
<Field :data-invalid="!!errors.length">
<FieldLabel :for="name">
Username
</FieldLabel>
<Input
:id="name"
v-bind="methods"
v-model="state.username"
:aria-invalid="!!errors.length"
placeholder="shadcn"
autocomplete="username"
/>
<FieldDescription>
This is your public display name. Must be between 3 and 10 characters.
</FieldDescription>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</Field>
</NotField>
</FieldGroup>
</NotForm>
</template>
Textarea
Multi-line text inputs for longer descriptions.
<script setup lang="ts">
import { Textarea } from '@/components/textarea'
import { NotForm, NotField } from 'notform'
const { id, reset, submit, state } = useNotForm({
schema: z.object({
about: z
.string()
.min(3, 'About must be at least 3 characters.')
.max(100, 'About must be at most 100 characters.'),
}),
initialState: {
about: '',
},
onSubmit: data => submitToast(data),
})
</script>
<template>
<NotForm
:id="id"
@submit="submit"
@reset="reset()"
>
<FieldGroup>
<NotField
name="about"
v-slot="{ errors, name, methods }"
>
<Field :data-invalid="!!errors.length">
<FieldLabel :for="name">
More about you
</FieldLabel>
<Textarea
:id="name"
v-bind="methods"
v-model="state.about"
placeholder="I'm a software engineer..."
class="min-h-[120px]"
:aria-invalid="!!errors.length"
/>
<FieldDescription>
Tell us more about yourself to personalize your experience.
</FieldDescription>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</Field>
</NotField>
</FieldGroup>
</NotForm>
</template>
Select
Dropdown menus for picking from a list of options.
<script setup lang="ts">
import { Select } from '@/components/select'
import { NotForm, NotField } from 'notform'
const { id, reset, submit, state } = useNotForm({
schema: z.object({
language: z
.string()
.min(1, 'Please select your spoken language.')
.refine(value => value !== 'auto', {
message: 'Auto-detection is not allowed. Please select a specific language.',
}),
}),
initialState: {
language: '',
},
onSubmit: data => submitToast(data),
})
</script>
<template>
<NotForm
:id="id"
@submit="submit"
@reset="reset()"
>
<FieldGroup>
<NotField
name="language"
v-slot="{ errors, name, methods }"
>
<Field
orientation="responsive"
:data-invalid="!!errors.length"
>
<FieldContent>
<FieldLabel :for="name">
Spoken Language
</FieldLabel>
<FieldDescription>
Select the language you speak natively.
</FieldDescription>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</FieldContent>
<Select
:name="name"
v-model="state.language"
@update:model-value="methods.onBlur()"
>
<SelectTrigger
:id="name"
:aria-invalid="!!errors.length"
class="min-w-[120px]"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="auto">
Auto
</SelectItem>
<SelectItem value="en">
English
</SelectItem>
<SelectItem value="es">
Spanish
</SelectItem>
</SelectContent>
</Select>
</Field>
</NotField>
</FieldGroup>
</NotForm>
</template>
Checkbox Group
Managing a collection of Boolean options or an array of selected values.
<script setup lang="ts">
import { Checkbox } from '@/components/checkbox'
import { NotForm, NotField } from 'notform'
const { id, submit, reset, state, setState } = useNotForm({
schema: z.object({
tasks: z
.array(z.string())
.min(1, 'Please select at least one notification type.'),
}),
initialState: {
tasks: [],
},
onSubmit: data => submitToast(data),
})
const tasks = [
{ id: 'push', label: 'Push notifications' },
{ id: 'email', label: 'Email notifications' },
]
</script>
<template>
<NotForm
:id="id"
@submit="submit"
@reset="reset()"
>
<NotField
name="tasks"
v-slot="{ methods, name, errors }"
>
<FieldSet :data-invalid="!!errors.length">
<FieldLegend variant="label">
Tasks
</FieldLegend>
<FieldGroup data-slot="checkbox-group">
<Field
v-for="task in tasks"
:key="task.id"
orientation="horizontal"
>
<Checkbox
:id="`task-${task.id}`"
:name="name"
:aria-invalid="!!errors.length"
:model-value="state.tasks.includes(task.id)"
@update:model-value="(checked) => {
const current = state.tasks || [];
const next = checked
? [...current, task.id]
: current.filter(id => id !== task.id);
setState({ tasks: next });
methods.onBlur();
}"
/>
<FieldLabel
:for="`task-${task.id}`"
class="font-normal"
>
{{ task.label }}
</FieldLabel>
</Field>
</FieldGroup>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</FieldSet>
</NotField>
</NotForm>
</template>
Radio Group
Single choice selection from a list of options.
<script lang="ts" setup>
import { RadioGroup } from '@/components/radio-group'
import { NotForm, NotField } from 'notform'
const { state, id, submit, reset } = useNotForm({
schema: z.object({
plan: z.string().min(1, 'You must select a subscription plan.'),
}),
initialState: { plan: '' },
onSubmit: data => submitToast(data),
})
</script>
<template>
<NotForm
:id="id"
:state="state"
@submit="submit"
@reset="reset()"
>
<NotField
name="plan"
v-slot="{ errors, name, methods }"
>
<FieldSet :data-invalid="!!errors.length">
<FieldLegend>
Plan
</FieldLegend>
<RadioGroup
:name="name"
v-model="state.plan"
@update:model-value="methods.onBlur()"
>
<FieldLabel for="starter">
<Field
orientation="horizontal"
:data-invalid="!!errors.length"
>
<FieldContent>
<FieldTitle>
Starter
</FieldTitle>
</FieldContent>
<RadioGroupItem
id="starter"
value="starter"
/>
</Field>
</FieldLabel>
<FieldLabel for="pro">
<Field
orientation="horizontal"
:data-invalid="!!errors.length"
>
<FieldContent>
<FieldTitle>
Pro
</FieldTitle>
</FieldContent>
<RadioGroupItem
id="pro"
value="pro"
/>
</Field>
</FieldLabel>
</RadioGroup>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</FieldSet>
</NotField>
</NotForm>
</template>
Switch
Simple toggle for Boolean settings.
<script setup lang="ts">
import { Switch } from '@/components/switch'
import { NotForm, NotField } from 'notform'
const { id, reset, submit, state } = useNotForm({
schema: z.object({
twoFactor: z.boolean().refine(val => val === true, {
message: 'Two-factor authentication is highly recommended.',
}),
}),
initialState: { twoFactor: false },
onSubmit: data => submitToast(data),
})
</script>
<template>
<NotForm
:id="id"
@submit="submit"
@reset="reset()"
>
<NotField
name="twoFactor"
v-slot="{ errors, name, methods }"
>
<Field
orientation="horizontal"
:data-invalid="!!errors.length"
>
<FieldContent>
<FieldLabel :for="name">
Multi-factor authentication
</FieldLabel>
<FieldDescription>
Enable added security for your account.
</FieldDescription>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</FieldContent>
<Switch
:id="name"
:name="name"
v-model="state.twoFactor"
@update:model-value="methods.onBlur()"
/>
</Field>
</NotField>
</NotForm>
</template>
Array Fields
NotForm provides a NotArrayField component for managing dynamic array fields.
Using NotArrayField
Use the NotArrayField component to manage array fields. It provides fields, append,remove and other methods through its slot props.
<script setup lang="ts">
import { NotArrayField, NotField, NotForm } from 'notform'
const { state, id, submit, reset, schema, getFieldErrors } = useNotForm({
schema: z.object({
emails: z
.array(z.object({ address: z.string().email('Invalid email.') }))
.min(1, 'Add at least one email.')
.max(5, 'Max 5 emails.'),
}),
initialState: {
emails: [{ address: '' }],
},
onSubmit: data => submitToast(data),
})
const emailsRootErrors = computed(() => getFieldErrors('emails'))
</script>
<template>
<NotForm
:id="id"
@submit="submit"
@reset="reset()"
>
<FieldSet class="gap-4">
<FieldLegend variant="label">
Email Addresses
</FieldLegend>
<NotArrayField
name="emails"
:schema="schema.shape.emails"
v-slot="{ fields, remove, append }"
>
<NotField
v-for="(field, index) in fields"
:key="field.key"
:name="`emails[${index}].address`"
v-slot="{ errors, methods, name }"
>
<Field
orientation="horizontal"
:data-invalid="!!errors.length"
>
<FieldContent>
<InputGroup>
<InputGroupInput
:id="name"
v-bind="methods"
v-model="state.emails[index]!.address"
placeholder="name@example.com"
/>
<InputGroupAddon
v-if="fields.length > 1"
align="inline-end"
>
<InputGroupButton
type="button"
variant="ghost"
@click="remove(index)"
>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldError
v-if="errors.length"
:errors="errors"
/>
</FieldContent>
</Field>
</NotField>
<Button
type="button"
variant="outline"
@click="append({ address: '' })"
>
Add Email
</Button>
</NotArrayField>
<FieldError
v-if="emailsRootErrors.length"
:errors="emailsRootErrors"
/>
</FieldSet>
</NotForm>
</template>
Complex Forms
For more advanced scenarios, you can combine multiple field types into a single interface.
<script setup lang="ts">
import { NotForm, NotField } from 'notform'
const { id, reset, submit, state, setState } = useNotForm({
schema: z.object({
plan: z.string().min(1, 'Select a plan'),
emailNotifications: z.boolean(),
}),
initialState: {
plan: 'basic',
emailNotifications: false,
},
onSubmit: data => submitToast(data),
})
</script>
<template>
<NotForm
:id="id"
@submit="submit"
@reset="reset()"
>
<FieldGroup>
<!-- Subscriptions & Settings combined -->
<NotField
name="plan"
v-slot="{ methods, name, errors }"
>
<!-- Radio Group implementation -->
</NotField>
<NotField
name="emailNotifications"
v-slot="{ methods, name, errors }"
>
<!-- Switch implementation -->
</NotField>
</FieldGroup>
</NotForm>
</template>
Resetting the Form
The reset function allows you to clear the form or revert it to its initialState with a single call.
<script setup lang="ts">
const { id, reset } = useNotForm({
// ...
})
</script>
<template>
<Button
type="reset"
variant="outline"
@click="reset()"
>
Reset
</Button>
</template>