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.

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.

When are you free for onboarding?

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[],
  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

typeRenders
text (default), email, password, number, search, tel, url, file, range, month, week, datetime-localTextInput with the matching HTML type
textareaTextarea
selectSelect
comboboxCombobox
multiselectMultiSelect
checkboxCheckbox
dateDatePicker
daterangeDateRangePicker
datetimeDateTimePicker
timeTimePicker

Breaking change in v1: type="date" and type="time" used to render native HTML inputs through TextInput. They now resolve to DatePicker and TimePicker. Use TextInput directly (or type="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.

vue
<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 listenersplaceholder, disabled, modelValue, options, min/max, formatter, etc. land on the resolved component. FormControl does not redeclare control-specific props.
  • Slots — every slot you pass is forwarded by name. Which slots are available depends on type (#prefix / #suffix for TextInput and the pickers, #item-prefix / #item-label for Select and Combobox, 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 FormControl when 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
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. 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