Form
Form validation is not bundled in fluent-design-vue. The library provides the building blocks (FuiField, invalid prop, aria-invalid) that integrate naturally with any validation library. This guide covers basic patterns and integration with VeeValidate + Zod.
Basic form (manual validation)
Basic form
vue
<script setup lang="ts">
import { ref, reactive } from 'vue'
const form = reactive({ name: '', email: '' })
const errors = reactive({ name: '', email: '' })
const submitted = ref(false)
function validate() {
errors.name = form.name ? '' : 'Name is required'
errors.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) ? '' : 'Valid email required'
return !errors.name && !errors.email
}
function handleSubmit() {
submitted.value = validate()
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<FuiStack spacing="m">
<FuiField
label="Name"
:validation-message="errors.name"
:validation-state="errors.name ? 'error' : 'none'"
>
<FuiInput v-model="form.name" />
</FuiField>
<FuiField
label="Email"
:validation-message="errors.email"
:validation-state="errors.email ? 'error' : 'none'"
>
<FuiInput v-model="form.email" />
</FuiField>
<FuiButton type="submit">Submit</FuiButton>
</FuiStack>
</form>
</template>VeeValidate + Zod
VeeValidate + Zod validation
vue
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(z.object({
name: z.string().min(2, 'At least 2 characters'),
email: z.string().email('Invalid email'),
password: z.string().min(6, 'At least 6 characters'),
}))
const { errors, defineField, handleSubmit, isSubmitting, values } = useForm({
validationSchema: schema,
})
const [name, nameAttrs] = defineField('name')
const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')
function onSubmit() {
alert(JSON.stringify(values.value))
}
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<FuiStack spacing="m">
<FuiField
label="Name"
:validation-message="errors.name"
:validation-state="errors.name ? 'error' : 'none'"
>
<FuiInput v-model="name" v-bind="nameAttrs" />
</FuiField>
<FuiField
label="Email"
:validation-message="errors.email"
:validation-state="errors.email ? 'error' : 'none'"
>
<FuiInput v-model="email" v-bind="emailAttrs" />
</FuiField>
<FuiField
label="Password"
:validation-message="errors.password"
:validation-state="errors.password ? 'error' : 'none'"
>
<FuiInput v-model="password" v-bind="passwordAttrs" type="password" />
</FuiField>
<FuiButton type="submit" :disabled="isSubmitting">Register</FuiButton>
</FuiStack>
</form>
</template>Validation states
Validation states
Password must be at least 8 characters
vue
<script setup lang="ts">
import { ref } from 'vue'
const message = ref('Password must be at least 8 characters')
const state = ref<'success' | 'warning' | 'error'>('error')
</script>
<template>
<FuiStack spacing="m">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<FuiButton size="small" @click="state='error';message='Password must be at least 8 characters'">Error</FuiButton>
<FuiButton size="small" @click="state='warning';message='Password strength is weak'">Warning</FuiButton>
<FuiButton size="small" @click="state='success';message='Password strength is strong'">Success</FuiButton>
</div>
<FuiField label="Password" :validation-message="message" :validation-state="state">
<FuiInput type="password" />
</FuiField>
</FuiStack>
</template>All control types
Form with all control types
vue
<script setup lang="ts">
import { reactive } from 'vue'
const form = reactive({
name: '', bio: '', country: '', fruit: '', newsletter: false,
planet: '', notifications: false, volume: 50, age: 25, stars: 3,
})
const errors = reactive({ name: '', bio: '', country: '', fruit: '' })
const countries = [
{ value: 'cn', label: 'China' }, { value: 'us', label: 'United States' },
{ value: 'jp', label: 'Japan' },
]
const fruits = [
{ value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' },
{ value: 'orange', label: 'Orange' },
]
const planets = [
{ value: 'earth', label: 'Earth' }, { value: 'mars', label: 'Mars' },
{ value: 'venus', label: 'Venus' },
]
function submit() {
errors.name = form.name ? '' : 'Required'
errors.bio = form.bio ? '' : 'Required'
errors.country = form.country ? '' : 'Required'
errors.fruit = form.fruit ? '' : 'Required'
}
</script>
<template>
<form @submit.prevent="submit">
<FuiStack spacing="m">
<FuiField label="Name" :validation-message="errors.name" :validation-state="errors.name ? 'error' : 'none'">
<FuiInput v-model="form.name" />
</FuiField>
<FuiField label="Bio" :validation-message="errors.bio" :validation-state="errors.bio ? 'error' : 'none'">
<FuiTextarea v-model="form.bio" />
</FuiField>
<FuiField label="Country" :validation-message="errors.country" :validation-state="errors.country ? 'error' : 'none'">
<FuiSelect v-model="form.country" :options="countries" />
</FuiField>
<FuiField label="Fruit" :validation-message="errors.fruit" :validation-state="errors.fruit ? 'error' : 'none'">
<FuiCombobox v-model="form.fruit" :options="fruits" />
</FuiField>
<FuiField label="Newsletter">
<FuiCheckbox v-model="form.newsletter" />
</FuiField>
<FuiField label="Favorite planet">
<FuiRadio v-model="form.planet" :options="planets" />
</FuiField>
<FuiField label="Notifications">
<FuiSwitch v-model="form.notifications" />
</FuiField>
<FuiField label="Volume">
<FuiSlider v-model="form.volume" :min="0" :max="100" />
</FuiField>
<FuiField label="Age">
<FuiSpinButton v-model="form.age" :min="0" :max="120" />
</FuiField>
<FuiField label="Rating">
<FuiRating v-model="form.stars" :max="5" />
</FuiField>
<FuiButton type="submit">Submit</FuiButton>
</FuiStack>
</form>
</template>Required fields
Required fields
vue
<script setup lang="ts">
import { reactive } from 'vue'
const form = reactive({ name: '', email: '', phone: '' })
const errors = reactive({ name: '', email: '', phone: '' })
function validate() {
errors.name = form.name ? '' : 'Required'
errors.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) ? '' : 'Valid email required'
errors.phone = /^\d{6,}$/.test(form.phone) ? '' : 'At least 6 digits'
}
</script>
<template>
<form @submit.prevent="validate">
<FuiStack spacing="m">
<FuiField label="Name" required :validation-message="errors.name" :validation-state="errors.name ? 'error' : 'none'">
<FuiInput v-model="form.name" />
</FuiField>
<FuiField label="Email" required :validation-message="errors.email" :validation-state="errors.email ? 'error' : 'none'">
<FuiInput v-model="form.email" />
</FuiField>
<FuiField label="Phone" required :validation-message="errors.phone" :validation-state="errors.phone ? 'error' : 'none'">
<FuiInput v-model="form.phone" />
</FuiField>
<FuiButton type="submit">Validate</FuiButton>
</FuiStack>
</form>
</template>Horizontal layout
Horizontal form
vue
<script setup lang="ts">
import { reactive } from 'vue'
const form = reactive({ name: '', email: '', dept: '' })
const departments = [
{ value: 'eng', label: 'Engineering' },
{ value: 'design', label: 'Design' },
{ value: 'pm', label: 'Product' },
]
</script>
<template>
<form>
<FuiStack spacing="m">
<FuiField label="Name" orientation="horizontal">
<FuiInput v-model="form.name" />
</FuiField>
<FuiField label="Email" orientation="horizontal">
<FuiInput v-model="form.email" />
</FuiField>
<FuiField label="Department" orientation="horizontal">
<FuiSelect v-model="form.dept" :options="departments" />
</FuiField>
</FuiStack>
</form>
</template>Disabled form
Disabled form
State: enabled
vue
<script setup lang="ts">
import { reactive } from 'vue'
const state = reactive({ disabled: false })
const form = reactive({
name: 'John Doe', email: 'john@example.com', country: 'us',
})
const countries = [
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
]
</script>
<template>
<FuiStack spacing="m">
<div style="display:flex;align-items:center;gap:8px">
<FuiButton size="small" appearance="outline" @click="state.disabled = !state.disabled">
{{ state.disabled ? 'Enable form' : 'Disable form' }}
</FuiButton>
<span style="font-size:var(--fui-font-size-base-200);color:var(--fui-color-neutral-foreground-3)">State: {{ state.disabled ? 'disabled' : 'enabled' }}</span>
</div>
<FuiField label="Name">
<FuiInput v-model="form.name" :disabled="state.disabled" />
</FuiField>
<FuiField label="Email">
<FuiInput v-model="form.email" :disabled="state.disabled" />
</FuiField>
<FuiField label="Country">
<FuiSelect v-model="form.country" :options="countries" :disabled="state.disabled" />
</FuiField>
<FuiButton :disabled="state.disabled">Submit</FuiButton>
</FuiStack>
</template>Dynamic validation rules
Dynamic validation rules
vue
<script setup lang="ts">
import { reactive, watch } from 'vue'
const form = reactive({ channel: '', email: '', phone: '' })
const errors = reactive({ email: '', phone: '' })
const channels = [
{ value: 'email', label: 'Email' },
{ value: 'sms', label: 'SMS' },
{ value: 'both', label: 'Both' },
{ value: 'none', label: 'None' },
]
watch(() => form.channel, () => {
errors.email = ''
errors.phone = ''
})
function validate(): boolean {
let ok = true
if (form.channel === 'email' || form.channel === 'both') {
errors.email = form.email ? '' : 'Email is required when email notifications are enabled'
if (errors.email) ok = false
}
if (form.channel === 'sms' || form.channel === 'both') {
errors.phone = form.phone ? '' : 'Phone is required when SMS notifications are enabled'
if (errors.phone) ok = false
}
return ok
}
function submit() {
if (validate()) alert(JSON.stringify(form))
}
</script>
<template>
<form @submit.prevent="submit">
<FuiStack spacing="m">
<FuiField label="Notification channel">
<FuiRadio v-model="form.channel" :options="channels" />
</FuiField>
<FuiField
label="Email"
:required="form.channel === 'email' || form.channel === 'both'"
:validation-message="errors.email"
:validation-state="errors.email ? 'error' : 'none'"
>
<FuiInput v-model="form.email" :disabled="form.channel !== 'email' && form.channel !== 'both' && form.channel !== ''" />
</FuiField>
<FuiField
label="Phone"
:required="form.channel === 'sms' || form.channel === 'both'"
:validation-message="errors.phone"
:validation-state="errors.phone ? 'error' : 'none'"
>
<FuiInput v-model="form.phone" :disabled="form.channel !== 'sms' && form.channel !== 'both' && form.channel !== ''" />
</FuiField>
<FuiButton type="submit">Submit</FuiButton>
</FuiStack>
</form>
</template>Dynamic form
Dynamic form (add / remove rows)
vue
<script setup lang="ts">
import { reactive } from 'vue'
const members = reactive([{ name: '', email: '', role: '' }])
const errors = reactive([{ name: '', email: '', role: '' }])
const roles = [
{ value: 'fe', label: 'Frontend' },
{ value: 'be', label: 'Backend' },
{ value: 'design', label: 'Design' },
]
function addMember() {
members.push({ name: '', email: '', role: '' })
errors.push({ name: '', email: '', role: '' })
}
function removeMember(i: number) {
members.splice(i, 1)
errors.splice(i, 1)
}
function validate(): boolean {
let ok = true
errors.forEach((e, i) => {
e.name = members[i].name ? '' : 'Required'
e.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(members[i].email) ? '' : 'Valid email required'
e.role = members[i].role ? '' : 'Required'
if (e.name || e.email || e.role) ok = false
})
return ok
}
function submit() {
if (validate()) alert(JSON.stringify(members))
}
</script>
<template>
<form @submit.prevent="submit">
<FuiStack spacing="m">
<div
v-for="(m, i) in members"
:key="i"
style="border:1px solid var(--fui-color-neutral-stroke-2);border-radius:var(--fui-border-radius-medium);padding:var(--fui-spacing-m)"
>
<FuiStack spacing="m">
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-weight:var(--fui-font-weight-semibold);font-size:var(--fui-font-size-base-200)">Member {{ i + 1 }}</span>
<FuiButton size="small" appearance="outline" @click="removeMember(i)" :disabled="members.length <= 1">Remove</FuiButton>
</div>
<FuiField label="Name" :validation-message="errors[i].name" :validation-state="errors[i].name ? 'error' : 'none'">
<FuiInput v-model="m.name" />
</FuiField>
<FuiField label="Email" :validation-message="errors[i].email" :validation-state="errors[i].email ? 'error' : 'none'">
<FuiInput v-model="m.email" />
</FuiField>
<FuiField label="Role" :validation-message="errors[i].role" :validation-state="errors[i].role ? 'error' : 'none'">
<FuiSelect v-model="m.role" :options="roles" />
</FuiField>
</FuiStack>
</div>
<FuiButton appearance="outline" @click="addMember">Add member</FuiButton>
<FuiButton type="submit">Submit team</FuiButton>
</FuiStack>
</form>
</template>Installation
bash
pnpm add vee-validate @vee-validate/zod zodHow it works
- VeeValidate manages form state and validation
- Zod schemas define validation rules with type-safe error messages
errors.fieldNameprovides validation messages per field- Pass
:validation-message="errors.field"and:validation-state="errors.field ? 'error' : 'none'"toFuiField FuiFieldprovides context to children viaprovide/inject- Child components automatically apply
invalidstyling andaria-invalid - The
invalidprop on a child still takes precedence when explicitly set - Dynamic rules: use
watchto clear errors when a dependent field changes; validate conditionally based on other field values