---
title: "Controls"
description: "Map schema fields to Nuxt UI controls via the type field, or register and inject custom Vue components to extend form fields."
canonical_url: "https://nuxt.mhaibaraai.cn/en/docs/auto-form/controls"
---
# Controls

> Map schema fields to Nuxt UI controls via the type field, or register and inject custom Vue components to extend form fields.

## Overview

AutoForm maps schema fields to specific rendering controls via the `type` field, and supports two ways to extend with custom controls:

- **Register type**: Register a control type via `useAutoForm()`, then reference it in the schema via `type`, suitable for reusable controls
- **Direct injection**: Pass a component instance directly via `component` in the schema, suitable for one-off usage

## Built-in Controls

### Default Mapping

| afz Method | Default Control | Description |
| --- | --- | --- |
| `afz.string()` | `UInput` | Text input |
| `afz.number()` | `UInputNumber` | Number input |
| `afz.boolean()` | `UCheckbox` | Checkbox |
| `afz.enum()` | `USelect` | Dropdown select |
| `afz.file()` | `UFileUpload` | File upload |
| `afz.calendarDate()` | `DatePicker` | Date picker |
| `inputDate` | `UInputDate` | Date input |
| `inputTime` | `UInputTime` | Time input |

### Available `type` Values

Specify the following built-in controls in the schema via the `type` field:

| type value | Control Component | Description |
| --- | --- | --- |
| `textarea` | `UTextarea` | Multi-line text input |
| `switch` | `USwitch` | Toggle switch |
| `slider` | `USlider` | Slider selector |
| `pinInput` | `UPinInput` | PIN input |
| `inputTags` | `UInputTags` | Tag input |
| `selectMenu` | `USelectMenu` | Dropdown menu selector |
| `inputMenu` | `UInputMenu` | Input menu |
| `listbox` | `UListbox` | Listbox selector |
| `checkboxGroup` | `UCheckboxGroup` | Checkbox group |
| `radioGroup` | `URadioGroup` | Radio button group |
| `inputDate` | `UInputDate` | Date input |
| `inputTime` | `UInputTime` | Time input |
| `withClear` | `WithClear` | Input with clear button |
| `withPasswordToggle` | `WithPasswordToggle` | Input with password visibility toggle |
| `withCopy` | `WithCopy` | Input with copy button |
| `withCharacterLimit` | `WithCharacterLimit` | Input with character counter |
| `withFloatingLabel` | `WithFloatingLabel` | Input with floating label |
| `asPhoneNumberInput` | `AsPhoneNumberInput` | Phone number input |
| `colorChooser` | `ColorChooser` | Color picker |
| `slideVerify` | `SlideVerify` | Slide verification |
| `pillGroup` | `PillGroup` | Pill radio group |

### Controls Overview

Reference built-in controls in the schema via `type`, demonstrating all built-in controls grouped by text, number, boolean, selection, date, and custom:

```vue [AutoFormControlsExample.vue]
<script setup lang="ts">
import { UTabs } from '#components'
import type { z } from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  $tabs: afz.layout({
    component: UTabs,
    props: {
      ui: { content: 'pt-4 space-y-4' },
      items: [
        { label: '文本', icon: 'i-lucide-type', slot: 'text' },
        { label: '数字', icon: 'i-lucide-hash', slot: 'number' },
        { label: '布尔', icon: 'i-lucide-toggle-left', slot: 'boolean' },
        { label: '选择', icon: 'i-lucide-list', slot: 'select' },
        { label: '日期', icon: 'i-lucide-calendar', slot: 'date' },
        { label: '自定义', icon: 'i-lucide-palette', slot: 'custom' }
      ]
    },
    fieldSlots: {
      string: 'text',
      textarea: 'text',
      withClear: 'text',
      withCopy: 'text',
      withPasswordToggle: 'text',
      withCharacterLimit: 'text',
      asPhoneNumberInput: 'text',
      withFloatingLabel: 'text',
      inputMenu: 'text',
      pinInput: 'text',

      number: 'number',
      slider: 'number',

      boolean: 'boolean',
      switch: 'boolean',
      slideVerify: 'boolean',

      enum: 'select',
      selectMenu: 'select',
      radioGroup: 'select',
      listbox: 'select',
      checkboxGroup: 'select',
      inputTags: 'select',
      pillGroup: 'select',

      calendarDate: 'date',
      inputDate: 'date',
      inputTime: 'date',

      colorChooser: 'custom'
    },
    fields: {
      string: afz.string({ controlProps: { placeholder: '默认文本' } }).meta({ label: 'Input' }),
      textarea: afz.string({ type: 'textarea', controlProps: { rows: 2 } }).meta({ label: 'Textarea' }),
      withClear: afz.string({ type: 'withClear' }).default('movk').meta({ label: 'WithClear' }),
      withCopy: afz.string({ type: 'withCopy' }).default('https://movk.dev').meta({ label: 'WithCopy' }),
      withPasswordToggle: afz.string({ type: 'withPasswordToggle' }).default('s3cret').meta({ label: 'WithPasswordToggle' }),
      withCharacterLimit: afz.string({ type: 'withCharacterLimit', controlProps: { maxlength: 30 } }).meta({ label: 'WithCharacterLimit' }),
      asPhoneNumberInput: afz.string({ type: 'asPhoneNumberInput' }).default('13800138000').meta({ label: 'AsPhoneNumberInput' }),
      withFloatingLabel: afz.string({ type: 'withFloatingLabel' }).meta({ label: 'WithFloatingLabel' }),
      inputMenu: afz.string({ type: 'inputMenu', controlProps: { items: ['Vue', 'Nuxt', 'Nitro'] } }).meta({ label: 'InputMenu' }),
      pinInput: afz.array(afz.string(), { type: 'pinInput', controlProps: { length: 4 } }).meta({ label: 'PinInput' }),

      number: afz.number({ controlProps: { placeholder: '默认数字' } }).meta({ label: 'InputNumber' }),
      slider: afz.number({ type: 'slider', controlProps: { min: 0, max: 100, step: 5 } }).default(40).meta({ label: 'Slider' }),

      boolean: afz.boolean().meta({ label: 'Checkbox' }),
      switch: afz.boolean({ type: 'switch' }).meta({ label: 'Switch' }),
      slideVerify: afz.boolean({ type: 'slideVerify' }).meta({ label: 'SlideVerify' }),

      enum: afz.enum(['low', 'medium', 'high']).default('medium').meta({ label: 'Select' }),
      selectMenu: afz.enum(['北京', '上海', '广州', '深圳'], { type: 'selectMenu' }).meta({ label: 'SelectMenu' }),
      radioGroup: afz.enum(['男', '女', '其他'], { type: 'radioGroup' }).meta({ label: 'RadioGroup' }),
      listbox: afz.enum(['草稿', '已发布', '已归档'], { type: 'listbox' }).default('草稿').meta({ label: 'Listbox' }),
      checkboxGroup: afz.array(afz.string(), { type: 'checkboxGroup', controlProps: { items: ['阅读', '游戏', '运动'] } }).meta({ label: 'CheckboxGroup' }),
      inputTags: afz.array(afz.string(), { type: 'inputTags' }).default(['nuxt', 'vue']).meta({ label: 'InputTags' }),
      pillGroup: afz.enum(['全部', '前端', '后端'], { type: 'pillGroup' }).default('全部').meta({ label: 'PillGroup' }),

      calendarDate: afz.calendarDate({ controlProps: { valueFormat: 'iso' } }).meta({ label: 'DatePicker' }),
      inputDate: afz.inputDate().meta({ label: 'InputDate' }),
      inputTime: afz.inputTime().meta({ label: 'InputTime' }),

      colorChooser: afz.string({ type: 'colorChooser', controlProps: { formats: ['hex', 'rgb'] } }).default('#0ea5e9').meta({ label: 'ColorChooser' })
    }
  })
})

const state = reactive<Partial<z.output<typeof schema>>>({})
</script>

<template>
  <MAutoForm :schema="schema" :state="state" />
</template>
```

## Custom Controls

When built-in controls cannot meet your needs, you can integrate custom Vue components through registration or direct injection.

### Register a Control

Register a control via `useAutoForm()`, then reference it in the schema via the `type` field:

```ts
import { TagSelector } from '#components'

const { afz, controls } = useAutoForm({
  tagSelector: defineControl({
    component: TagSelector,
    controlProps: { class: 'w-full' } // global controlProps (can be overridden at field level)
  })
})
```

> [!TIP]
> 
> It is recommended to use 
> 
> defineControl
> 
>  to register custom controls for more stable 
> 
> controlProps
> 
>  / 
> 
> controlSlots
> 
>  type hints.

> [!NOTE]
> 
> The plain object syntax (
> 
> tagSelector: { component: TagSelector }
> 
> ) is still supported and also benefits from type inference.

Use the registered control type in the schema:

```ts
const schema = afz.object({
  skills: afz
    .array(afz.string(), {
      type: 'tagSelector',       // specify control type
      controlProps: {
        options: ['Vue.js', 'Nuxt', 'TypeScript'],
        max: 5
      }
    })
    .meta({ label: 'Skill Tags' })
})
```

Pass `controls` to `MAutoForm`:

```vue
<MAutoForm
  :schema="schema"
  :state="form"
  :controls="controls"
  @submit="onSubmit"
/>
```

### Inject Component Directly

No registration needed — pass a component instance directly in the schema meta (`UInputRating` requires `@nuxt/ui >=4.10.0`):

```ts
import { UInputRating } from '#components'

const schema = afz.object({
  rating: afz.number({
    component: UInputRating,
    controlProps: { length: 10 }
  }).meta({ label: 'Rating' })
})
```

> [!NOTE]
> 
> When injecting a component directly, 
> 
> type
> 
>  and 
> 
> component
> 
>  are mutually exclusive; if both are present, 
> 
> component
> 
>  takes precedence.

### Component Specification

Custom control components must meet the following requirements.

#### Support v-model

```vue
<script setup>
// Recommended syntax for Vue 3.4+
const model = defineModel()

// Or the traditional approach
const props = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
</script>
```

#### Accept controlProps

Fields in `controlProps` are passed through as props to the control component. The component should declare the corresponding props:

```vue
<script setup>
defineProps<{
  modelValue?: string[]
  options?: string[]  // corresponds to controlProps.options
  max?: number        // corresponds to controlProps.max
  disabled?: boolean
}>()
</script>
```

## Example

Register `TagSelector` as the `tagSelector` control type via `defineControl`, reference it with `type: 'tagSelector'` in the schema, and pass candidate tags and maximum selection count via `controlProps`:

```vue [AutoFormCustomControlExample.vue]
<script lang="ts" setup>
import type { FormSubmitEvent } from '@nuxt/ui'
import type { z } from 'zod'
import { TagSelector } from '#components'

const toast = useToast()

const { afz, controls } = useAutoForm({
  tagSelector: defineControl({
    component: TagSelector,
    controlProps: { class: 'w-full' }
  })
})

const SKILL_OPTIONS = [
  'Vue.js', 'Nuxt', 'TypeScript', 'React', 'Node.js',
  'Python', 'GraphQL', 'Docker', 'Tailwind CSS', 'Vite'
]

const ROLE_OPTIONS = {
  frontend: '前端开发',
  backend: '后端开发',
  fullstack: '全栈开发',
  devops: 'DevOps',
  designer: 'UI / 设计'
}

const schema = afz.object({
  name: afz
    .string({ controlProps: { placeholder: '请输入您的姓名' } })
    .min(2, '姓名至少 2 个字符')
    .meta({ label: '姓名' }),

  role: afz
    .enum(Object.keys(ROLE_OPTIONS) as [string, ...string[]])
    .default('fullstack')
    .meta({ label: '职位', description: '您的主要技术方向' }),

  skills: afz
    .array(afz.string(), {
      type: 'tagSelector',
      controlProps: {
        options: SKILL_OPTIONS,
        max: 5
      }
    })
    .default([])
    .meta({ label: '技能标签', description: '最多选择 5 项技能' }),

  bio: afz
    .string({
      type: 'textarea',
      controlProps: { placeholder: '简短介绍一下自己...' }
    })
    .optional()
    .meta({ label: '个人简介' }),

  openToWork: afz
    .boolean()
    .default(false)
    .meta({ label: '求职中', hint: '开启后将对招聘者可见' })
})

type Schema = z.output<typeof schema>

const form = ref<Partial<Schema>>({})

async function onSubmit(event: FormSubmitEvent<Schema>) {
  toast.add({
    title: '提交成功',
    color: 'success',
    description: JSON.stringify(event.data, null, 2)
  })
}
</script>

<template>
  <MAutoForm
    :schema="schema"
    :state="form"
    :controls="controls"
    @submit="onSubmit"
  />
</template>
```

```vue [app/components/TagSelector.vue]
<script setup lang="ts">
import { UBadge } from '#components'

export interface TagSelectorProps {
  /**
   * List of selectable tags
   */
  options?: string[]
  /**
   * Maximum number of selections (unlimited if not set)
   */
  max?: number
  /**
   * Disabled state
   */
  disabled?: boolean
}

const props = withDefaults(defineProps<TagSelectorProps>(), {
  options: () => [],
  disabled: false
})

const model = defineModel<string[]>({ default: () => [] })

function isSelected(tag: string) {
  return (model.value ?? []).includes(tag)
}

function isMaxReached(tag: string) {
  const current = model.value ?? []
  return !!props.max && current.length >= props.max && !current.includes(tag)
}

function toggle(tag: string) {
  if (props.disabled || isMaxReached(tag)) return

  const current = model.value ?? []
  model.value = current.includes(tag)
    ? current.filter(t => t !== tag)
    : [...current, tag]
}
</script>

<template>
  <div class="space-y-2">
    <div
      v-if="options.length === 0"
      class="text-sm text-muted"
    >
      No tags available
    </div>
    <div
      v-else
      class="flex flex-wrap gap-2"
    >
      <UBadge
        v-for="tag in options"
        :key="tag"
        :label="tag"
        :variant="isSelected(tag) ? 'solid' : 'outline'"
        color="primary"
        class="transition-opacity select-none"
        :class="[
          disabled || isMaxReached(tag)
            ? 'opacity-40 cursor-not-allowed'
            : 'cursor-pointer hover:opacity-80'
        ]"
        @click="toggle(tag)"
      />
    </div>
    <p
      v-if="max"
      class="text-xs text-muted"
    >
      Selected {{ (model ?? []).length }} / {{ max }}
    </p>
  </div>
</template>
```


## Sitemap

See the full [sitemap](https://nuxt.mhaibaraai.cn/sitemap.md) for all pages.
