FormControl
A single import for building consistent forms. FormControl picks the right input component based on its type and forwards label, description, error, and required down to it. Use it when you want a uniform API across every kind of control in a form.
Example
A realistic create-account form using every control type.
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Button, FormControl } from 'frappe-ui'
const form = reactive({
fullName: '',
email: '',
password: '',
role: '',
team: '',
skills: [] as string[],
bio: '',
acceptTerms: false,
})
const submitted = ref(false)
const errors = computed(() => {
if (!submitted.value) return {} as Record<string, string>
const e: Record<string, string> = {}
if (!form.fullName.trim()) e.fullName = 'Full name is required.'
if (!form.email.trim()) e.email = 'Email is required.'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
e.email = 'Enter a valid email.'
if (!form.password) e.password = 'Pick a password.'
else if (form.password.length < 8)
e.password = 'Use at least 8 characters.'
if (!form.role) e.role = 'Pick a role.'
if (!form.team) e.team = 'Pick a team.'
if (form.skills.length === 0) e.skills = 'Pick at least one skill.'
if (!form.acceptTerms) e.acceptTerms = 'You must accept the terms.'
return e
})
const isValid = computed(() => Object.keys(errors.value).length === 0)
const roleOptions = [
{ label: 'Engineer', value: 'engineer' },
{ label: 'Designer', value: 'designer' },
{ label: 'Product Manager', value: 'pm' },
{ label: 'Other', value: 'other' },
]
const teamOptions = [
{ label: 'Platform', value: 'platform' },
{ label: 'Growth', value: 'growth' },
{ label: 'Frontend', value: 'frontend' },
{ label: 'Backend', value: 'backend' },
{ label: 'Mobile', value: 'mobile' },
]
const skillOptions = [
{ label: 'Vue', value: 'vue' },
{ label: 'TypeScript', value: 'typescript' },
{ label: 'Python', value: 'python' },
{ label: 'Frappe Framework', value: 'frappe' },
{ label: 'Tailwind CSS', value: 'tailwind' },
{ label: 'PostgreSQL', value: 'postgres' },
]
function submit() {
submitted.value = true
}
function reset() {
Object.assign(form, {
fullName: '',
email: '',
password: '',
role: '',
team: '',
skills: [],
bio: '',
acceptTerms: false,
})
submitted.value = false
}
</script>
<template>
<form
class="w-full max-w-lg space-y-4 py-4"
@submit.prevent="submit"
>
<div class="space-y-1">
<h2 class="text-lg font-semibold text-ink-gray-9">Create account</h2>
<p class="text-p-sm text-ink-gray-6">
Every <code>FormControl</code> type rendered together. Submit to see
validation light up.
</p>
</div>
<FormControl
v-model="form.fullName"
type="text"
label="Full name"
placeholder="Jane Doe"
:error="errors.fullName"
required
/>
<FormControl
v-model="form.email"
type="email"
label="Email"
description="We'll never share your email."
placeholder="you@example.com"
:error="errors.email"
required
/>
<FormControl
v-model="form.password"
type="password"
label="Password"
description="At least 8 characters."
:error="errors.password"
required
/>
<FormControl
v-model="form.role"
type="select"
label="Role"
placeholder="Pick a role"
:options="roleOptions"
:error="errors.role"
required
/>
<FormControl
v-model="form.team"
type="combobox"
label="Team"
placeholder="Type to filter"
:options="teamOptions"
:error="errors.team"
required
/>
<FormControl
v-model="form.skills"
type="multiselect"
label="Skills"
description="Pick all that apply."
placeholder="Pick skills"
:options="skillOptions"
:error="errors.skills"
required
/>
<FormControl
v-model="form.bio"
type="textarea"
label="Short bio"
description="A sentence or two — optional."
placeholder="Tell us a little about yourself..."
/>
<FormControl
v-model="form.acceptTerms"
type="checkbox"
label="I accept the terms and privacy policy"
/>
<p v-if="errors.acceptTerms" class="text-p-sm text-ink-red-3">
{{ errors.acceptTerms }}
</p>
<div class="flex items-center gap-2 pt-2">
<Button variant="solid" type="submit">Create account</Button>
<Button variant="ghost" type="button" @click="reset">Reset</Button>
<span
v-if="submitted && isValid"
class="ml-auto text-p-sm text-ink-green-3"
>
Looks good!
</span>
</div>
</form>
</template>Supported input types
FormControl can render these control types:
- text-like inputs via
TextInput(text,email,password,number,date, etc.) textareaselectcomboboxmultiselectcheckbox
type="autocomplete"is deprecated. See Legacy components.
Labeling, description, and error
label, description, error, and required flow through to the underlying control, which owns the rendered label, helper text, and error message. Set error to flip aria-invalid on the control and replace the description with the error message.
<FormControl
v-model="email"
type="email"
label="Email"
description="We'll never share your email."
:error="errors.email"
required
/>Each underlying component (TextInput, Select, Combobox, MultiSelect, Textarea, Checkbox) also accepts these props directly — reach for the underlying component when you don't need a uniform dispatcher.
Attribute forwarding
Other props such as placeholder, disabled, modelValue, options, and similar control-specific attributes are forwarded to the rendered input. This means FormControl acts as a convenience wrapper rather than redefining every prop from each underlying control.
Slot forwarding
All slots are forwarded to the rendered child generically. The available slots depend on the rendered control type — for example, #prefix / #suffix for TextInput, #item-prefix / #item-label for Select and Combobox, etc.
Usage notes
- Prefer
FormControlfor standard forms where consistency matters more than custom structure. - Prefer the underlying component directly when you need full control over layout or component-specific features.
API Reference
Show types
import type { TextInputTypes } from '../types/TextInput'
import type { FrappeUIError } from '../../composables/useInputLabeling'
export interface FormControlProps {
/** Label text displayed above the input */
label?: string
/** Optional description or helper text shown below the input */
description?: string
/** Error message shown below the input. Sets aria-invalid on the control. */
error?: string | FrappeUIError
/** Type of input to render */
type?:
| TextInputTypes
| 'textarea'
| 'select'
| 'checkbox'
| 'autocomplete'
| 'combobox'
| 'multiselect'
/** Size of the input */
size?: 'sm' | 'md'
/** Visual variant of the input */
variant?: 'subtle' | 'outline'
/** Whether the input is required */
required?: boolean
}| Prop | Default | Type |
|---|---|---|
label | — | string Label text displayed above the input |
description | — | string Optional description or helper text shown below the input |
error | — | string | FrappeUIError Error message shown below the input. Sets aria-invalid on the control. |
type | "text" | "select" | TextInputTypes | "textarea" | "checkbox" | "autocomplete" | "combobox" | "multiselect" Type of input to render |
size | "sm" | "md" | "sm" Size of the input |
variant | "subtle" | "subtle" | "outline" Visual variant of the input |
required | — | boolean Whether the input is required |
| Slot | Payload |
|---|---|
prefix | — Custom content rendered before the input (prefix icon/content) |
suffix | — Custom content rendered after the input (suffix icon/content) |
description | — Custom description slot (replaces description prop) |
label | { required: boolean; } Custom label slot (replaces label prop). Receives `{ required }`. |
item-prefix | { item: any; } Custom slot for autocomplete items prefix (if using Autocomplete type) |
default | — Default slot override for full input rendering |