FormControl
A uniform wrapper for form inputs. FormControl picks an underlying control from its type prop and threads label, description, error, required, size, and variant down to it. Use it when you want one consistent shape for every field in a form.
Example
A 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[],
startDate: '',
availability: '',
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.startDate) e.startDate = 'Pick a start date.'
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: [],
startDate: '',
availability: '',
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.startDate"
type="date"
label="Start date"
placeholder="Pick a start date"
:error="errors.startDate"
required
/>
<FormControl
v-model="form.availability"
type="daterange"
label="Availability window"
description="When are you free for onboarding?"
placeholder="Pick a range"
/>
<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 types
type | Renders |
|---|---|
text (default), email, password, number, search, tel, url, file, range, month, week, datetime-local | TextInput with the matching HTML type |
textarea | Textarea |
select | Select |
combobox | Combobox |
multiselect | MultiSelect |
checkbox | Checkbox |
date | DatePicker |
daterange | DateRangePicker |
datetime | DateTimePicker |
time | TimePicker |
Breaking change in v1:
type="date"andtype="time"used to render native HTML inputs throughTextInput. They now resolve toDatePickerandTimePicker. UseTextInputdirectly (ortype="datetime-local") if you need the native input.
type="autocomplete"is deprecated. See Legacy components.
Labeling and errors
label, description, error, and required are forwarded to the underlying control, which owns the rendered label, helper text, and error message. Setting error flips aria-invalid on the control and swaps the description for the error message.
<FormControl
v-model="email"
type="email"
label="Email"
description="We'll never share your email."
:error="errors.email"
required
/>Forwarding
Everything else is forwarded generically:
- Props and listeners —
placeholder,disabled,modelValue,options,min/max,formatter, etc. land on the resolved component.FormControldoes not redeclare control-specific props. - Slots — every slot you pass is forwarded by name. Which slots are available depends on
type(#prefix/#suffixforTextInputand the pickers,#item-prefix/#item-labelforSelectandCombobox, and so on — see the target component's docs).
Because forwarding is generic, FormControl does not type-check control-specific props or the v-model shape per type. The value shape follows the underlying component — daterange emits a tuple string from DateRangePicker, multiselect emits an array, checkbox emits a boolean, and so on. When the prop surface starts to drive your decision, reach for the underlying component directly.
When to reach past it
- Use
FormControlwhen the goal is a consistent stack of labelled fields and you want one API to remember. - Use the underlying component when you need its specific layout (custom triggers, complex slots) or its full typed surface.
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. FormControl is a thin dispatcher — it forwards
* `label`/`description`/`error`/`required`/`size`/`variant` plus all
* remaining attrs/listeners to the resolved child component. Type-specific
* props (e.g. `options` for select/combobox, `min`/`max`/`formatter` for
* date pickers, `:options` for multiselect) and the `v-model` value shape
* follow the underlying component — see that component's docs/types for
* the full surface. Slots are forwarded by name; only slot names declared
* on FormControl get IDE typing, others pass through at runtime.
*/
type?:
| TextInputTypes
| 'textarea'
| 'select'
| 'checkbox'
| 'autocomplete'
| 'combobox'
| 'multiselect'
| 'date'
| 'daterange'
| 'datetime'
| 'time'
/** 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" | "daterange" | "datetime" Type of input to render. FormControl is a thin dispatcher — it forwards `label`/`description`/`error`/`required`/`size`/`variant` plus all remaining attrs/listeners to the resolved child component. Type-specific props (e.g. `options` for select/combobox, `min`/`max`/`formatter` for date pickers, `:options` for multiselect) and the `v-model` value shape follow the underlying component — see that component's docs/types for the full surface. Slots are forwarded by name; only slot names declared on FormControl get IDE typing, others pass through at runtime. |
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 |