NOTFORM
Integrations

Shadcn Vue

Build forms in Vue using NotForm, shadcn-vue, and Zod.

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 useNotForm hook.
  • Field Isolation: Using NotField to expose errors and methods to individual controls.
  • Visual Feedback: Utilizing shadcn's Field and FieldError components 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.

form.vue
<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.

form.vue
<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.

form.vue
<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.

ExampleForm.vue
<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).

form.vue
<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.

form.vue
<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.

form.vue
<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.

form.vue
<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.

form.vue
<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.

form.vue
<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.

form.vue
<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.

form.vue
<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.

form.vue
<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.

form.vue
<script setup lang="ts">
const { id, reset } = useNotForm({
  // ...
})
</script>

<template>
  <Button
    type="reset"
    variant="outline"
    @click="reset()"
  >
    Reset
  </Button>
</template>