NOTFORM
Integrations

DaisyUI

Build forms in Vue using NotForm, DaisyUI, and Zod.

This guide demonstrates how to seamlessly integrate notform with DaisyUI layouts and Zod for robust schema validation. We will explore how to manage complex form states while maintaining the elegant aesthetics and accessibility of DaisyUI components.

Demo

The following demo showcases the full integration of notform with various DaisyUI 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 DaisyUI provides the visually appealing Tailwind CSS components. By using the NotField component, you can "wrap" any DaisyUI 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 DaisyUI's validator and validator-hint classes and NotMessage for consistent UI.
  • Validation: Powering everything with Zod schemas.

Anatomy

The core pattern involves wrapping your standard HTML elements (styled with DaisyUI classes) with NotForm's NotField. This provides the necessary context (errors, state, and event handlers) to your inputs.

form.vue
<script setup lang="ts">
import { NotForm, NotField, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

const { state, id, submit, reset } = useNotForm({ /* ... */ })
</script>

<template>
  <NotField
    v-slot="{ errors, name, methods }"
    name="username"
  >

    <label
      :for="name"
      class="fieldset"
    >

      <span class="fieldset-legend">Username</span>

      <input
        :id="name"
        v-bind="methods"
        v-model="state.username"
        type="text"
        class="validator input w-full"
        placeholder="daisyui"
        autocomplete="username"
        :aria-invalid="!!errors.length"
      >

      <NotMessage
        :name="name"
        class="validator-hint hidden"
      />
    </label>

  </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({
  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.',
    ),
})
</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, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

const schema = z.object({
  username: z.string().min(1, 'Required'),
})

const { id, reset, submit, state } = useNotForm({
  schema,
  initialState: {
    username: '',
  },
  onSubmit: data => console.log(data),
})
</script>

<template>
  <NotForm
    :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 :aria-invalid prop and using the validator and validator-hint classes, DaisyUI components can automatically switch to their "error" styling.

form.vue
<template>
  <NotField
    v-slot="{ errors, name, methods }"
    name="username"
  >

    <label
      :for="name"
      class="fieldset"
    >

      <span class="fieldset-legend">Username</span>

      <input
        :id="name"
        v-bind="methods"
        v-model="state.username"
        type="text"
        class="validator input w-full"
        :aria-invalid="!!errors.length"
      >

      <NotMessage
        :name="name"
        class="validator-hint hidden"
      />
    </label>

  </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 { NotForm, NotField, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

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 => newToast(data),
})

</script>

<template>
  <NotForm
    :id
    @submit="submit"
    @reset="reset()"
  >
    <NotField
      v-slot="{ errors, name, methods }"
      name="username"
    >

      <label
        :for="name"
        class="fieldset"
      >

        <span class="fieldset-legend">Username</span>

        <input
          :id="name"
          v-bind="methods"
          v-model="state.username"
          type="text"
          class="validator input w-full"
          placeholder="daisyui"
          autocomplete="username"
          :aria-invalid="!!errors.length"
        >

        <NotMessage
          :name="name"
          class="validator-hint hidden"
        />
      </label>

    </NotField>

  </NotForm>
</template>

Textarea

Multi-line text inputs for longer descriptions.

form.vue
<script setup lang="ts">
import { NotForm, NotField, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

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
    @submit="submit"
    @reset="reset()"
  >
    <NotField
      v-slot="{ errors, name, methods }"
      name="about"
    >
      <label
        class="fieldset"
        :for="name"
      >
        <span
          class="fieldset-legend"
        >
          More about you
        </span>

        <textarea
          :id="name"
          v-bind="methods"
          v-model="state.about"
          placeholder="I'm a software engineer..."
          class="validator textarea w-full"
          :aria-invalid="!!errors.length"
        />

        <div
          class="label text-wrap"
        >
          Tell us more about yourself. This will be used to help us personalize your experience.
        </div>

        <NotMessage
          :name="name"
          class="validator-hint hidden"
        />
      </label>
    </NotField>
  </NotForm>
</template>

Select

Dropdown menus for picking from a list of options.

form.vue
<script setup lang="ts">
import { NotForm, NotField, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

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 => newToast(data),
})

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

</script>

<template>
  <NotForm
    :id
    @submit="submit"
    @reset="reset()"
  >

    <NotField
      v-slot="{ errors, name, methods }"
      name="language"
    >
      <label
        :for="name"
        class="fieldset"
      >

        <span class="fieldset-legend">Spoken Language</span>

        <div
          class="fieldset-label text-wrap"
        >
          For best results, select the language you speak.
        </div>

        <select
          :id="name"
          v-bind="methods"
          v-model="state.language"
          class="validator select w-full"
          :aria-invalid="!!errors.length"
        >
          <option
            value=""
            disabled
            selected
            hidden
          >
            Select
          </option>

          <option value="auto">
            Auto
          </option>

          <option
            v-for="language in spokenLanguages"
            :key="language.value"
            :value="language.value"
            :selected="language.value === state.language"
          >
            {{ language.label }}
          </option>
        </select>

        <NotMessage
          :name="name"
          class="validator-hint hidden"
        />
      </label>

    </NotField>

  </NotForm>
</template>

Checkbox Group

Managing a collection of Boolean options or an array of selected values.

form.vue
<script setup lang="ts">
import { NotForm, NotField, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

const tasks = [
  {
    id: 'push',
    label: 'Push notifications',
  },
  {
    id: 'email',
    label: 'Email notifications',
  },
] as const

const { id, submit, reset, setState, state } = useNotForm({
  schema: z.object({
    responses: z.boolean(),
    tasks: z
      .array(z.string())
      .min(1, 'Please select at least one notification type.')
      .refine(
        value => value.every(task => tasks.some(t => t.id === task)),
        {
          message: 'Invalid notification type selected.',
        },
      ),
  }),
  initialState: {
    responses: true,
    tasks: [],
  },
  onSubmit: data => newToast(data),
})
</script>

<template>
  <NotForm
    :id
    @submit="submit"
    @reset="reset()"
  >

    <fieldset class="fieldset">
      <legend class="fieldset-legend">
        Responses
      </legend>

      <div class="fieldset-label text-wrap">
        Get notified for requests that take time, like research or image
        generation.
      </div>

      <NotField
        v-slot="{ name,errors,methods}"
        name="responses"
      >

        <label
          :for="name"
          class="validator join join-horizontal gap-4"
        >
          <input
            :id="name"
            v-model="state.responses"
            v-bind="methods"
            :name="name"
            type="checkbox"
            disabled
            :checked="state.responses"
            class="checkbox"
            :aria-invalid="!!errors.length"
          >
          <span class="fieldset-legend">Push notifications</span>
        </label>

        <NotMessage
          :name="name"
          class="validator-hint hidden"
        />
      </NotField>
    </fieldset>

    <div class="divider" />

    <fieldset class="fieldset">
      <legend class="fieldset-legend">
        Tasks
      </legend>

      <div class="fieldset-label text-wrap">
        Get notified when tasks you've created have updates.
      </div>

      <NotField
        v-slot="{ name,errors,methods}"
        name="tasks"
      >

        <label
          v-for="task in tasks"
          :key="task.id"
          :for="task.id"
          class="validator join join-horizontal gap-4"
        >
          <input
            :id="task.id"
            :name="name"
            :value="task.id"
            type="checkbox"
            class="checkbox"
            :checked="state.tasks.includes(task.id)"
            :aria-invalid="!!errors.length"
            v-bind="methods"
            @change="(event) => {
              const target = event.target as HTMLInputElement;
              const newTasks = target.checked
                ? [...state.tasks, task.id]
                : state.tasks.filter(id => id !== task.id);

              setState({ tasks: newTasks },false);
              methods.onChange()
            }"
          >
          <span class="fieldset-legend">{{ task.label }}</span>
        </label>

        <NotMessage
          :name="name"
          class="validator-hint hidden"
        />
      </NotField>
    </fieldset>

  </NotForm>
</template>

Radio Group

Single choice selection from a list of options.

form.vue
<script lang="ts" setup>
import { NotForm, NotField, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

const plans = [
  {
    id: 'starter',
    title: 'Starter (100K tokens/month)',
    description: 'For everyday use with basic features.',
  },
  {
    id: 'pro',
    title: 'Pro (1M tokens/month)',
    description: 'For advanced AI usage with more features.',
  },
  {
    id: 'enterprise',
    title: 'Enterprise (Unlimited tokens)',
    description: 'For large teams and heavy usage.',
  },
] as const

const { state, id, submit, reset, setState } = useNotForm({
  schema: z.object({
    plan: z.string().min(1, 'You must select a subscription plan to continue.'),
  }),
  initialState: {
    plan: '',
  },
  onSubmit: data => newToast(data),
})
</script>

<template>
  <NotForm
    :id="id"
    @submit="submit"
    @reset="reset()"
  >

    <fieldset class="fieldset">
      <legend class="fieldset-legend">
        Plan
      </legend>

      <div class="fieldset-label text-wrap">
        You can upgrade or downgrade your plan at any time.
      </div>

      <NotField
        v-slot="{ name,errors,methods}"
        name="plan"
      >

        <label
          v-for="plan in plans"
          :key="plan.id"
          :for="plan.id"
          class="
            validator join join-horizontal cursor-pointer items-center gap-4
            rounded-lg border p-4 transition
            hover:bg-base-200
          "
          style="color: var(--input-color)"
        >

          <div class="flex-1">
            <div class="fieldset-legend">{{ plan.title }}</div>
            <div class="fieldset-label">{{ plan.description }}</div>
          </div>

          <input
            :id="plan.id"
            :name="name"
            :value="plan.id"
            type="radio"
            class="radio"
            :checked="state.plan === plan.id"
            :aria-invalid="!!errors.length"
            v-bind="methods"
            @change="(event) => {
              const target = event.target as HTMLInputElement;
              setState({plan:target.value},false)
              methods.onChange()
            }"
          >
        </label>

        <NotMessage
          :name="name"
          class="validator-hint hidden"
        />
      </NotField>

    </fieldset>
  </NotForm>
</template>

Switch

Simple toggle for Boolean settings.

form.vue
<script setup lang="ts">
import { NotForm, NotField, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

const { id, reset, submit, state } = useNotForm({
  schema: z.object({
    twoFactor: z.boolean().refine(val => val === true, {
      message: 'It is highly recommended to enable two-factor authentication.',
    }),
  }),
  initialState: {
    twoFactor: false,
  },
  onSubmit: data => newToast(data),
})

</script>

<template>
  <NotForm
    :id
    @submit="submit"
    @reset="reset()"
  >


    <NotField
      v-slot="{ name,errors,methods}"
      name="twoFactor"
    >

      <label
        :for="name"
        class="
          validator join join-horizontal cursor-pointer items-center gap-4
        "
      >

        <div class="flex-1">
          <div class="fieldset-legend">Multi-factor authentication</div>
          <div class="fieldset-label">Enable two-factor authentication to add an extra layer of security to your account.</div>
        </div>

        <input
          :id="name"
          v-model="state.twoFactor"
          type="checkbox"
          class="validator toggle"
          :name="name"
          v-bind="methods"
          :checked="state.twoFactor"
          :aria-invalid="!!errors.length"
        >
      </label>

      <NotMessage
        :name="name"
        class="validator-hint hidden"
      />
    </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 lang="ts" setup>
import { NotField, NotForm, NotArrayField, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

const { state, id, submit, reset, schema } = useNotForm({
  schema: z.object({
    emails: z
      .array(
        z.object({
          address: z.string().email('Enter a valid email address.'),
        }),
      )
      .min(1, 'Add at least one email address.')
      .max(5, 'You can add up to 5 email addresses.'),
  }),
  initialState: {
    emails: [{ address: '' }, { address: '' }],
  },
  onSubmit: data => newToast(data),
})
</script>

<template>
  <NotForm
    :id="id"
    @submit="submit"
    @reset="reset()"
  >

    <NotArrayField
      v-slot="{ fields, remove, append }"
      name="emails"
      :schema="schema.shape.emails"
    >

      <fieldset class="fieldset">
        <legend class="fieldset-legend">
          Email Addresses
        </legend>
        <div class="fieldset-label text-wrap">
          Add up to 5 email addresses where we can contact you.
        </div>

        <div class="flex flex-col gap-2">

          <NotField
            v-for="(field, index) in fields"
            :key="field.key"
            v-slot="{ errors, methods, name }"
            :name="`emails[${index}].address`"
          >
            <label
              :for="name"
              class="validator join w-full"
            >
              <input
                :id="name"
                v-model="state.emails[index]!.address"
                type="email"
                class="validator input join-item w-full"
                placeholder="name@example.com"
                autocomplete="email"
                :aria-invalid="!!errors.length"
                v-bind="methods"
              >
              <button
                v-if="fields.length > 1"
                type="button"
                class="btn join-item"
                :aria-label="`Remove email ${index + 1}`"
                @click="remove(index)"
              >
                x
              </button>
            </label>
            <NotMessage
              :name="name"
              class="validator-hint hidden"
            />
          </NotField>

          <button
            type="button"
            class="btn mt-2 items-center btn-sm btn-neutral"
            :disabled="state.emails.length >= 5"
            @click="append({ address: '' })"
          >
            Add Email Address
          </button>

        </div>

      </fieldset>

      <NotMessage
        name="emails"
        class="text-error"
      />
    </NotArrayField>

  </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, NotMessage } from 'notform'
import { useNotForm } from 'notform'
import * as z from 'zod'

const addons = [
  {
    id: 'analytics',
    title: 'Analytics',
    description: 'Advanced analytics and reporting',
  },
  {
    id: 'backup',
    title: 'Backup',
    description: 'Automated daily backups',
  },
  {
    id: 'support',
    title: 'Priority Support',
    description: '24/7 premium customer support',
  },
] as const

const plans = [
  {
    id: 'basic',
    title: 'Basic',
    description: 'For individuals and small teams',
  },
  {
    id: 'pro',
    title: 'Pro',
    description: 'For businesses with higher demands',
  },
] as const

const { id, reset, submit, state, setState } = useNotForm({
  schema: z.object({
    plan: z
      .string()
      .min(1, 'Please select a subscription plan')
      .refine(value => value === 'basic' || value === 'pro', {
        message: 'Invalid plan selection. Please choose Basic or Pro',
      }),
    billingPeriod: z
      .string()
      .min(1, 'Please select a billing period'),
    addons: z
      .array(z.string())
      .min(1, 'Please select at least one add-on')
      .max(3, 'You can select up to 3 add-ons')
      .refine(
        value => value.every(addon => addons.some(a => a.id === addon)),
        {
          message: 'You selected an invalid add-on',
        },
      ),
    emailNotifications: z.boolean(),
  }),
  initialState: {
    plan: 'basic',
    billingPeriod: '',
    addons: [],
    emailNotifications: false,
  },
  onSubmit: data => newToast(data),
})
</script>

<template>
  <NotForm
    :id
    @submit="submit"
    @reset="reset()"
  >
    <div class="flex flex-col">
      <fieldset class="fieldset">
        <legend class="fieldset-legend">
          Subscription Plan
        </legend>

        <div class="fieldset-label text-wrap">
          Choose your subscription plan.
        </div>

        <NotField
          v-slot="{ methods, name, errors }"
          name="plan"
        >
          <label
            v-for="plan in plans"
            :key="plan.id"
            :for="plan.id"
            class="
              validator join join-horizontal cursor-pointer items-center gap-4
              rounded-lg border p-4 transition
              hover:bg-base-200
            "
            style="color: var(--input-color)"
          >
            <div class="flex-1">
              <div class="fieldset-legend">{{ plan.title }}</div>
              <div class="fieldset-label">{{ plan.description }}</div>
            </div>

            <input
              :id="plan.id"
              type="radio"
              :name="name"
              :value="plan.id"
              class="radio"
              :checked="state.plan === plan.id"
              :aria-invalid="!!errors.length"
              v-bind="methods"
              @change="(event) => {
                const target = event.target as HTMLInputElement;
                setState({ plan: target.value }, false)
                methods.onChange()
              }"
            >
          </label>

          <NotMessage
            :name="name"
            class="validator-hint hidden"
          />
        </NotField>
      </fieldset>

      <div class="divider" />

      <NotField
        v-slot="{ methods, name, errors }"
        name="billingPeriod"
      >
        <label
          :for="name"
          class="fieldset"
        >
          <span class="fieldset-legend">Billing Period</span>

          <div class="fieldset-label text-wrap">
            Choose how often you want to be billed.
          </div>

          <select
            :id="name"
            v-model="state.billingPeriod"
            class="validator select w-full"
            :aria-invalid="!!errors.length"
            v-bind="methods"
          >
            <option
              value=""
              disabled
              selected
              hidden
            >
              Select
            </option>
            <option value="monthly">
              Monthly
            </option>
            <option value="yearly">
              Yearly
            </option>
          </select>

          <NotMessage
            :name="name"
            class="validator-hint hidden"
          />
        </label>
      </NotField>

      <div class="divider" />

      <fieldset class="fieldset">
        <legend class="fieldset-legend">
          Add-ons
        </legend>

        <div class="fieldset-label text-wrap">
          Select additional features you'd like to include.
        </div>

        <NotField
          v-slot="{ methods, name, errors }"
          name="addons"
        >
          <label
            v-for="addon in addons"
            :key="addon.id"
            :for="addon.id"
            class="validator join join-horizontal gap-4"
          >
            <input
              :id="addon.id"
              type="checkbox"
              class="checkbox"
              :checked="state.addons.includes(addon.id)"
              :aria-invalid="!!errors.length"
              v-bind="methods"
              @change="(event) => {
                const target = event.target as HTMLInputElement;
                const newAddons = target.checked
                  ? [...state.addons, addon.id]
                  : state.addons.filter(id => id !== addon.id);
                setState({ addons: newAddons }, false);
                methods.onChange()
              }"
            >
            <div class="flex flex-col">
              <span class="fieldset-legend">{{ addon.title }}</span>
              <span class="text-xs opacity-70">{{ addon.description }}</span>
            </div>
          </label>

          <NotMessage
            :name="name"
            class="validator-hint hidden"
          />
        </NotField>
      </fieldset>

      <div class="divider" />

      <NotField
        v-slot="{ methods, name, errors }"
        name="emailNotifications"
      >
        <label
          :for="name"
          class="
            validator join join-horizontal cursor-pointer items-center gap-4
          "
        >
          <div class="flex-1">
            <div class="fieldset-legend">Email Notifications</div>
            <div class="fieldset-label text-wrap">
              Receive email updates about your subscription
            </div>
          </div>

          <input
            :id="name"
            v-model="state.emailNotifications"
            type="checkbox"
            class="validator toggle"
            :name="name"
            v-bind="methods"
            :checked="state.emailNotifications"
            :aria-invalid="!!errors.length"
          >
        </label>
        <NotMessage
          :name="name"
          class="validator-hint hidden"
        />
      </NotField>
    </div>
  </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
    class="btn btn-secondary"
    type="reset"
    @click="reset()"
  >
    Reset
  </button>
</template>