Some people believe the future of form development lies in headless components—powerful, logic-driven components that handle all the complexity of form state management while giving you complete control over the visual presentation.
If you've ever felt constrained by the styling limitations of traditional form libraries, or struggled to maintain consistent forms across different UI frameworks, headless components are the solution you've been looking for.
In this comprehensive guide, we'll explore how to build flexible, reusable form components using Enforma's headless architecture that work with any design system or UI framework.
Introduction to Headless Components
What Are Headless Components?
Headless components are UI-agnostic components that provide all the functionality and state management without any predefined styling or markup. Think of them as the "brain" of your forms—they handle validation, state management, user interactions, and accessibility, while leaving the visual presentation entirely up to you.
<!-- Traditional approach: Locked into specific styling -->
<FormInput
label="Email"
type="email"
class="cant-change-internal-structure"
/>
<!-- Headless approach: Complete control -->
<HeadlessField name="email">
<template #default="{ value, error, events, attrs }">
<!-- Your custom markup and styling -->
<div class="my-custom-field-wrapper">
<label class="my-label-style">Email</label>
<input
class="my-input-style"
:value="value"
v-bind="attrs"
v-on="events"
/>
<span v-if="error" class="my-error-style">{{ error }}</span>
</div>
</template>
</HeadlessField>
Benefits of Headless Forms
1. Complete UI Freedom
Build forms that perfectly match your design system without fighting against pre-built styles.
2. UI Framework Agnostic
Use the same form logic with PrimeVue, Vuetify, Quasar, or pure HTML—switch UI frameworks without rewriting form logic.
3. Reduced Bundle Size
No UI code included by default means smaller bundle sizes and faster load times.
4. Maximum Flexibility
Handle complex requirements like custom validation feedback, conditional fields, and unique user interactions.
5. Enhanced Maintainability
Separate form logic from presentation concerns, making both easier to test and maintain.
Enforma's Headless Architecture
Enforma provides three core headless components that form the foundation of any form system:
1. HeadlessForm - The Form Container
The main form component that manages overall form state, validation, and submission:
<HeadlessForm
:data="formData"
:rules="validationRules"
:submit-handler="handleSubmit"
>
<template #default="form">
<!-- Access to complete form state -->
<div>
<span v-if="form.$isSubmitting.value">Submitting...</span>
<!-- Your form fields here -->
</div>
</template>
</HeadlessForm>
2. HeadlessField - Individual Field Management
Manages individual field state, validation, and user interactions:
<HeadlessField name="email">
<template #default="field">
<!-- Complete field control -->
<input
:id="field.id"
:value="field.value"
:aria-invalid="!!field.error"
v-bind="field.attrs"
v-on="field.events"
/>
<div v-if="field.error">{{ field.error }}</div>
</template>
</HeadlessField>
3. HeadlessRepeatable - Dynamic Array Management
Handles arrays of fields with add, remove, and reorder functionality:
<HeadlessRepeatable name="skills">
<template #default="{ value, add, remove, canAdd, canRemove }">
<div v-for="(skill, index) in value" :key="index">
<HeadlessField :name="`skills.${index}.name`">
<!-- Field template -->
</HeadlessField>
<button @click="remove(index)" :disabled="!canRemove">
Remove
</button>
</div>
<button @click="add({ name: '' })" :disabled="!canAdd">
Add Skill
</button>
</template>
</HeadlessRepeatable>
Building Your First Headless Form
Let's start with a complete example that demonstrates the power of headless components:
<template>
<div class="max-w-md mx-auto p-6">
<h2 class="text-2xl font-bold mb-6">Contact Form</h2>
<HeadlessForm
:data="formData"
:rules="validationRules"
:submit-handler="handleSubmit"
>
<template #default="form">
<form @submit.prevent="form.submit()" class="space-y-4">
<!-- Name Field -->
<HeadlessField name="name">
<template #default="{ value, error, events, attrs, id }">
<div>
<label
:for="id"
class="block text-sm font-medium text-gray-700 mb-1"
>
Full Name
</label>
<input
:id="id"
:value="value"
v-bind="attrs"
v-on="events"
class="w-full p-2 border rounded-md"
:class="{ 'border-red-500': error, 'border-gray-300': !error }"
/>
<div v-if="error" class="text-red-500 text-sm mt-1">
{{ error }}
</div>
</div>
</template>
</HeadlessField>
<!-- Email Field -->
<HeadlessField name="email">
<template #default="{ value, error, events, attrs, id }">
<div>
<label
:for="id"
class="block text-sm font-medium text-gray-700 mb-1"
>
Email Address
</label>
<input
:id="id"
:value="value"
type="email"
v-bind="attrs"
v-on="events"
class="w-full p-2 border rounded-md"
:class="{ 'border-red-500': error, 'border-gray-300': !error }"
/>
<div v-if="error" class="text-red-500 text-sm mt-1">
{{ error }}
</div>
</div>
</template>
</HeadlessField>
<!-- Message Field -->
<HeadlessField name="message">
<template #default="{ value, error, events, attrs, id }">
<div>
<label
:for="id"
class="block text-sm font-medium text-gray-700 mb-1"
>
Message
</label>
<textarea
:id="id"
:value="value"
rows="4"
v-bind="attrs"
v-on="events"
class="w-full p-2 border rounded-md"
:class="{ 'border-red-500': error, 'border-gray-300': !error }"
></textarea>
<div v-if="error" class="text-red-500 text-sm mt-1">
{{ error }}
</div>
</div>
</template>
</HeadlessField>
<!-- Submit Button -->
<button
type="submit"
:disabled="form.$isSubmitting.value"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md disabled:bg-gray-400"
>
{{ form.$isSubmitting.value ? 'Sending...' : 'Send Message' }}
</button>
</form>
</template>
</HeadlessForm>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { HeadlessForm, HeadlessField } from '@encolajs/enforma'
const formData = {
name: '',
email: '',
message: ''
}
const validationRules = {
name: 'required',
email: 'required|email',
message: 'required|min:10'
}
const handleSubmit = async (data) => {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('Form submitted:', data)
alert('Message sent successfully!')
return true
}
</script>
Creating Reusable Components
Building a Base Field Component
The first step in creating reusable form components is building a base wrapper that reduces repetition:
<!-- components/AppFormField.vue -->
<template>
<div class="form-field">
<HeadlessField :name="name">
<template #default="{ value, error, events, attrs, id }">
<label
v-if="label"
:for="id"
class="field-label"
:class="{ 'required': required }"
>
{{ label }}
<span v-if="required" class="text-red-500">*</span>
</label>
<slot
:value="value"
:error="error"
:events="events"
:attrs="attrs"
:id="id"
/>
<div
v-if="error"
:id="attrs['aria-errormessage']"
class="field-error"
role="alert"
>
{{ error }}
</div>
<div
v-if="help && !error"
:id="attrs['aria-describedby']"
class="field-help"
>
{{ help }}
</div>
</template>
</HeadlessField>
</div>
</template>
<script setup>
import { HeadlessField } from '@encolajs/enforma'
defineProps({
name: {
type: String,
required: true
},
label: {
type: String,
default: null
},
help: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
}
})
</script>
<style scoped>
.form-field {
margin-bottom: 1rem;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.field-error {
margin-top: 0.25rem;
color: #dc2626;
font-size: 0.875rem;
}
.field-help {
margin-top: 0.25rem;
color: #6b7280;
font-size: 0.875rem;
}
</style>
Custom Input Components
Now build specific input types using your base component:
<!-- components/TextInput.vue -->
<template>
<AppFormField
:name="name"
:label="label"
:help="help"
:required="required"
>
<template #default="{ value, error, events, attrs, id }">
<input
:id="id"
:type="type"
:value="value"
:placeholder="placeholder"
v-bind="attrs"
v-on="events"
class="form-input"
:class="{ 'input-error': error }"
/>
</template>
</AppFormField>
</template>
<script setup>
import AppFormField from './AppFormField.vue'
defineProps({
name: { type: String, required: true },
label: { type: String, default: null },
type: { type: String, default: 'text' },
placeholder: { type: String, default: null },
help: { type: String, default: null },
required: { type: Boolean, default: false }
})
</script>
<style scoped>
.form-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.15s ease-in-out;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.input-error {
border-color: #dc2626;
}
</style>
Select Component
<!-- components/SelectInput.vue -->
<template>
<AppFormField
:name="name"
:label="label"
:help="help"
:required="required"
>
<template #default="{ value, error, events, attrs, id }">
<select
:id="id"
:value="value"
v-bind="attrs"
v-on="events"
class="form-select"
:class="{ 'input-error': error }"
>
<option value="" v-if="placeholder">{{ placeholder }}</option>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
</template>
</AppFormField>
</template>
<script setup>
import AppFormField from './AppFormField.vue'
defineProps({
name: { type: String, required: true },
label: { type: String, default: null },
options: { type: Array, required: true },
placeholder: { type: String, default: 'Select an option' },
help: { type: String, default: null },
required: { type: Boolean, default: false }
})
</script>
<style scoped>
.form-select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
background-color: white;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
}
</style>
Advanced Component Examples
Date Range Component
Here's a more complex component that handles two related date inputs:
<!-- components/DateRangeField.vue -->
<template>
<div class="date-range-field">
<label v-if="label" class="field-label">{{ label }}</label>
<div class="date-inputs">
<HeadlessField :name="`${name}.start`">
<template #default="startField">
<div class="date-input-wrapper">
<label :for="startField.id" class="sr-only">Start Date</label>
<input
:id="startField.id"
type="date"
:value="startField.value"
v-bind="startField.attrs"
v-on="startField.events"
:max="endValue"
class="date-input"
:class="{ 'input-error': startField.error }"
/>
<span class="date-label">From</span>
</div>
</template>
</HeadlessField>
<div class="date-separator">—</div>
<HeadlessField :name="`${name}.end`">
<template #default="endField">
<div class="date-input-wrapper">
<label :for="endField.id" class="sr-only">End Date</label>
<input
:id="endField.id"
type="date"
:value="endField.value"
v-bind="endField.attrs"
v-on="endField.events"
:min="startValue"
class="date-input"
:class="{ 'input-error': endField.error }"
/>
<span class="date-label">To</span>
</div>
</template>
</HeadlessField>
</div>
<div v-if="hasErrors" class="field-error">
{{ firstError }}
</div>
</div>
</template>
<script setup>
import { computed, inject } from 'vue'
import { HeadlessField } from '@encolajs/enforma'
const props = defineProps({
name: { type: String, required: true },
label: { type: String, required: true }
})
// Access form context to get values for min/max constraints
const form = inject('form')
const startValue = computed(() => form.getFieldValue(`${props.name}.start`))
const endValue = computed(() => form.getFieldValue(`${props.name}.end`))
const startField = computed(() => form.getField(`${props.name}.start`))
const endField = computed(() => form.getField(`${props.name}.end`))
const hasErrors = computed(() =>
startField.value?.error || endField.value?.error
)
const firstError = computed(() =>
startField.value?.error || endField.value?.error
)
</script>
<style scoped>
.date-range-field {
margin-bottom: 1rem;
}
.date-inputs {
display: flex;
align-items: center;
gap: 0.75rem;
}
.date-input-wrapper {
flex: 1;
position: relative;
}
.date-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
}
.date-label {
position: absolute;
top: -0.5rem;
left: 0.75rem;
background: white;
padding: 0 0.25rem;
font-size: 0.75rem;
color: #6b7280;
font-weight: 500;
}
.date-separator {
color: #6b7280;
font-weight: 500;
margin: 0 0.5rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
Working with Dynamic Arrays
Skills Repeatable Component
<!-- components/SkillsField.vue -->
<template>
<div class="skills-field">
<h3 class="field-label">{{ label }}</h3>
<HeadlessRepeatable
:name="name"
:min="minItems"
:max="maxItems"
>
<template #default="{ value, add, remove, canAdd, canRemove, moveUp, moveDown }">
<div class="skills-list">
<div
v-for="(skill, index) in value"
:key="index"
class="skill-item"
>
<div class="skill-fields">
<TextInput
:name="`${name}.${index}.name`"
label="Skill Name"
placeholder="e.g., JavaScript, Design, Project Management"
class="skill-name"
/>
<SelectInput
:name="`${name}.${index}.level`"
label="Proficiency"
:options="levelOptions"
class="skill-level"
/>
<TextInput
:name="`${name}.${index}.years`"
label="Years"
type="number"
placeholder="0"
class="skill-years"
/>
</div>
<div class="skill-actions">
<button
type="button"
@click="moveUp(index)"
:disabled="index === 0"
class="move-button"
title="Move up"
>
↑
</button>
<button
type="button"
@click="moveDown(index)"
:disabled="index === value.length - 1"
class="move-button"
title="Move down"
>
↓
</button>
<button
type="button"
@click="remove(index)"
:disabled="!canRemove"
class="remove-button"
title="Remove skill"
>
×
</button>
</div>
</div>
</div>
<button
type="button"
@click="add({ name: '', level: '', years: 0 })"
:disabled="!canAdd"
class="add-button"
>
+ Add Skill
</button>
<div v-if="!canAdd" class="max-items-notice">
Maximum {{ maxItems }} skills allowed
</div>
</template>
</HeadlessRepeatable>
</div>
</template>
<script setup>
import { HeadlessRepeatable } from '@encolajs/enforma'
import TextInput from './TextInput.vue'
import SelectInput from './SelectInput.vue'
defineProps({
name: { type: String, required: true },
label: { type: String, default: 'Skills' },
minItems: { type: Number, default: 0 },
maxItems: { type: Number, default: 10 }
})
const levelOptions = [
{ value: 'beginner', label: 'Beginner' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'advanced', label: 'Advanced' },
{ value: 'expert', label: 'Expert' }
]
</script>
<style scoped>
.skills-field {
margin-bottom: 2rem;
}
.skills-list {
space-y: 1rem;
}
.skill-item {
display: flex;
gap: 1rem;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background-color: #f9fafb;
}
.skill-fields {
flex: 1;
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 1rem;
align-items: end;
}
.skill-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.move-button,
.remove-button {
width: 2rem;
height: 2rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
background: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
cursor: pointer;
}
.move-button:hover,
.remove-button:hover {
background-color: #f3f4f6;
}
.remove-button {
color: #dc2626;
border-color: #fca5a5;
}
.remove-button:hover {
background-color: #fef2f2;
}
.add-button {
padding: 0.75rem 1rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
}
.add-button:hover:not(:disabled) {
background-color: #2563eb;
}
.add-button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.max-items-notice {
color: #6b7280;
font-size: 0.875rem;
font-style: italic;
}
</style>
Accessibility and Best Practices
Automatic ARIA Support
Enforma's headless components automatically provide comprehensive accessibility features:
<template>
<HeadlessField name="email">
<template #default="{ attrs, events, id, error }">
<!-- All ARIA attributes are automatically provided -->
<input
:id="id"
v-bind="attrs" <!-- Includes aria-invalid, aria-required, etc. -->
v-on="events"
type="email"
/>
<!-- Error message properly linked via aria-errormessage -->
<div
v-if="error"
:id="attrs['aria-errormessage']"
role="alert"
>
{{ error }}
</div>
</template>
</HeadlessField>
</template>
Enhanced Accessibility Example
<template>
<HeadlessField name="password">
<template #default="{ value, error, events, attrs, id }">
<div class="password-field">
<label :for="id" class="field-label">
Password
<span class="required">*</span>
</label>
<div class="password-input-wrapper">
<input
:id="id"
:type="showPassword ? 'text' : 'password'"
:value="value"
v-bind="attrs"
v-on="events"
class="password-input"
:aria-describedby="`${id}-requirements`"
/>
<button
type="button"
@click="showPassword = !showPassword"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
class="password-toggle"
>
{{ showPassword ? '👁️' : '👁️🗨️' }}
</button>
</div>
<!-- Password requirements -->
<div
:id="`${id}-requirements`"
class="password-requirements"
:aria-live="value ? 'polite' : 'off'"
>
<div
class="requirement"
:class="{ 'met': value.length >= 8 }"
>
✓ At least 8 characters
</div>
<div
class="requirement"
:class="{ 'met': /[A-Z]/.test(value) }"
>
✓ One uppercase letter
</div>
<div
class="requirement"
:class="{ 'met': /[0-9]/.test(value) }"
>
✓ One number
</div>
</div>
<div v-if="error" class="field-error" role="alert">
{{ error }}
</div>
</div>
</template>
</HeadlessField>
</template>
<script setup>
import { ref } from 'vue'
const showPassword = ref(false)
</script>
Complete Example: Registration Form
Here's a comprehensive example that brings together all the concepts we've covered:
<template>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-8 text-center">Create Your Account</h1>
<HeadlessForm
:data="formData"
:rules="validationRules"
:custom-messages="customMessages"
:submit-handler="handleSubmit"
>
<template #default="form">
<div class="space-y-8">
<!-- Personal Information -->
<section>
<h2 class="text-xl font-semibold mb-4">Personal Information</h2>
<div class="grid grid-cols-2 gap-4">
<TextInput
name="personal.firstName"
label="First Name"
required
/>
<TextInput
name="personal.lastName"
label="Last Name"
required
/>
</div>
<TextInput
name="personal.email"
label="Email Address"
type="email"
required
/>
<TextInput
name="personal.password"
label="Password"
type="password"
required
/>
</section>
<!-- Professional Information -->
<section>
<h2 class="text-xl font-semibold mb-4">Professional Background</h2>
<TextInput
name="professional.jobTitle"
label="Current Job Title"
placeholder="e.g., Frontend Developer"
/>
<SelectInput
name="professional.experience"
label="Years of Experience"
:options="experienceOptions"
required
/>
<SkillsField
name="professional.skills"
label="Technical Skills"
:min-items="1"
:max-items="10"
/>
</section>
<!-- Contact Preferences -->
<section>
<h2 class="text-xl font-semibold mb-4">Contact Preferences</h2>
<HeadlessField name="preferences.newsletter">
<template #default="{ value, events, id }">
<div class="flex items-center">
<input
:id="id"
type="checkbox"
:checked="value"
@change="events.input($event.target.checked)"
class="rounded border-gray-300"
/>
<label :for="id" class="ml-2">
Subscribe to our newsletter
</label>
</div>
</template>
</HeadlessField>
</section>
<!-- Form Actions -->
<div class="flex gap-4 pt-4">
<button
type="button"
@click="form.reset()"
class="flex-1 py-2 px-4 border border-gray-300 rounded-md hover:bg-gray-50"
>
Reset Form
</button>
<button
type="submit"
:disabled="form.$isSubmitting.value"
class="flex-1 py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{{ form.$isSubmitting.value ? 'Creating Account...' : 'Create Account' }}
</button>
</div>
<!-- Form State Display (for demo) -->
<div v-if="showDebug" class="mt-8 p-4 bg-gray-100 rounded-md">
<h3 class="font-semibold mb-2">Form State (Debug)</h3>
<div class="text-sm space-y-1">
<div>Is Dirty: {{ form.$isDirty.value }}</div>
<div>Is Valid: {{ form.$isValid.value }}</div>
<div>Is Submitting: {{ form.$isSubmitting.value }}</div>
</div>
</div>
</div>
</template>
</HeadlessForm>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { HeadlessForm, HeadlessField } from '@encolajs/enforma'
import TextInput from './components/TextInput.vue'
import SelectInput from './components/SelectInput.vue'
import SkillsField from './components/SkillsField.vue'
const showDebug = ref(false)
const formData = {
personal: {
firstName: '',
lastName: '',
email: '',
password: ''
},
professional: {
jobTitle: '',
experience: '',
skills: [{ name: '', level: '', years: 0 }]
},
preferences: {
newsletter: false
}
}
const validationRules = {
'personal.firstName': 'required',
'personal.lastName': 'required',
'personal.email': 'required|email',
'personal.password': 'required|min:8',
'professional.experience': 'required',
'professional.skills.*.name': 'required',
'professional.skills.*.level': 'required'
}
const customMessages = {
'personal.password.min': 'Password must be at least 8 characters long',
'professional.skills.*.name.required': 'Skill name is required',
'professional.skills.*.level.required': 'Please select a proficiency level'
}
const experienceOptions = [
{ value: '0-1', label: '0-1 years' },
{ value: '2-3', label: '2-3 years' },
{ value: '4-6', label: '4-6 years' },
{ value: '7-10', label: '7-10 years' },
{ value: '10+', label: '10+ years' }
]
const handleSubmit = async (data) => {
console.log('Submitting registration:', data)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000))
alert('Account created successfully!')
return true
}
</script>
Final Thoughts: Reusability and Maintainability
Benefits You've Gained
By using Enforma's headless approach, you've achieved:
- Complete UI Control: Build forms that perfectly match your design system
- Framework Independence: Switch UI libraries without rewriting form logic
- Enhanced Accessibility: Automatic ARIA support with full customization
- Maximum Reusability: Components work across projects and teams
- Better Testing: Separate logic from presentation for easier unit testing
- Improved Performance: Only include the UI code you actually need
Best Practices Summary
- Start with base components that handle common patterns
- Use composition to build complex fields from simple parts
- Leverage Enforma's automatic accessibility features
- Test form logic separately from UI presentation
- Create a component library for your organization
- Document your custom components with clear examples
The headless approach transforms form development from a tedious, repetitive task into a flexible, creative process. Whether you're building simple contact forms or complex multi-step wizards, Enforma's headless components provide the foundation for beautiful, accessible, and maintainable forms.
Ready to get started? Install Enforma and begin building your first headless form component today.