---
title: "Nuxt UI"
description: "Use NotForm with Nuxt UI inputs and form fields, including text, textarea, select, checkbox, checkbox group, radio group, switch, and array fields."
canonical_url: "https://notformdocs.vercel.app/docs/integrations/nuxt-ui"
---

> Index of every page on this site: [llms.txt](https://notformdocs.vercel.app/llms.txt)

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.

```ts [nuxt.config.ts]
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`.

<table>
<thead>
  <tr>
    <th>
      Component
    </th>
    
    <th>
      Binding
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        UInput
      </code>
      
      , <code>
        UTextarea
      </code>
    </td>
    
    <td>
      <code>
        v-model="field.model"
      </code>
      
      , <code>
        @update:model-value="field.events.onInput"
      </code>
      
       and <code>
        @blur="field.events.onBlur"
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        USelect
      </code>
      
      , <code>
        UCheckbox
      </code>
      
      , <code>
        UCheckboxGroup
      </code>
      
      , <code>
        URadioGroup
      </code>
      
      , <code>
        USwitch
      </code>
    </td>
    
    <td>
      <code>
        v-model="field.model"
      </code>
      
      , <code>
        @update:model-value="field.events.onChange"
      </code>
      
      and <code>
        @blur="field.events.onBlur"
      </code>
    </td>
  </tr>
</tbody>
</table>

## 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:

```ts
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](#full-example).

## Text input

```ts
title: z.string().min(5, 'Bug title must be at least 5 characters.')
  .max(32)
```

<code-collapse>

```vue
<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>
```

</code-collapse>

## 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.

```ts
description: z.string().min(20, 'Description must be at least 20 characters.')
  .max(100)
```

<code-collapse>

```vue
<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>
```

</code-collapse>

## Select

Pass the options through `items`.

```ts
language: z.string().min(1, 'Please select your spoken language.')
```

<code-collapse>

```vue
<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>
```

</code-collapse>

## Checkbox

```ts
responses: z.literal(true, 'Notifications are mandatory')
```

<code-collapse>

```vue
<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>
```

</code-collapse>

## Checkbox group

`UCheckboxGroup` binds the array of selected values directly through `v-model:model-value`.

```ts
tasks: z.array(z.string()).min(1, 'Please select at least one notification type.')
```

<code-collapse>

```vue
<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>
```

</code-collapse>

## Radio group

```ts
plan: z.string().min(1, 'You must select a subscription plan to continue.')
```

<code-collapse>

```vue
<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>
```

</code-collapse>

## Switch

```ts
twoFactor: z.boolean().refine((value) => {
  return value === true
}, 'It is highly recommended to enable two-factor authentication.')
```

<code-collapse>

```vue
<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>
```

</code-collapse>

## 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`.

```ts
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.')
```

<code-collapse>

```vue
<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>
```

</code-collapse>

A few things to note:

- `arrayField.items` gives you each item's `id` and `index`. Use `item.id` as the `:key`, not the index, so inputs keep their state when you reorder or remove items.
- `arrayField.append`, `arrayField.remove` and `arrayField.move` change the list.
- Errors for a single item come from its `field`. Errors for the list as a whole, such as the `min` and `max` rules, come from `arrayField.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.

<code-collapse>

```vue
<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>
```

</code-collapse>

## Related

- [Custom components](https://notformdocs.vercel.app/docs/integrations/custom-components)
- [Nuxt module](https://notformdocs.vercel.app/docs/getting-started/nuxt-module)

## Sitemap

See the full [sitemap](https://notformdocs.vercel.app/sitemap.md) for all pages.
