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.

Create account

Every FormControl type rendered together. Submit to see validation light up.

We'll never share your email.

At least 8 characters.

Pick all that apply.

A sentence or two — optional.

vue
<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.)
  • textarea
  • select
  • combobox
  • multiselect
  • checkbox

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.

vue
<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 FormControl for 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
typescript
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