Component · Forms
Contact Form
Accessible Vue 3 contact form with inline validation, a live character counter and loading, success and error states. Tailwind CSS, dark mode.
Config-driven Vue 3 multi-step form wizard with a step indicator, per-step validation, back/next, a review step and a submit event. Tailwind CSS.
by Vueplayv1.0.0Updated 3 hours ago
A wizard for sign-up, onboarding, checkout and long applications. You describe the steps and their fields as data; the component renders labelled inputs, selects, radio cards and checkboxes, validates each step before moving on, and finishes with a review screen where every answer can be edited.
Validation covers required, email format, minLength and pattern, plus a custom validate(value, data) hook for anything else. Errors appear after a field is touched or when Continue is pressed, are linked to their inputs with aria-describedby, and focus moves to the first invalid field. On step change, focus moves to the step heading so screen reader users know where they are.
The step indicator collapses to a compact progress bar on phones. Need a bespoke step? Use the step-<id> slot and keep the navigation and review. Set loading while you save and the success panel appears once it turns off.
<script lang="ts">
export type FieldValue = string | boolean
export type WizardData = Record<string, FieldValue>
export interface WizardOption { value: string; label: string; description?: string }
export interface WizardField {
name: string
label: string
type?: 'text' | 'email' | 'password' | 'textarea' | 'select' | 'radio' | 'checkbox'
placeholder?: string
help?: string
required?: boolean
minLength?: number
pattern?: string
patternMessage?: string
autocomplete?: string
options?: WizardOption[]
/** Span both columns on wide screens. Textareas, radios and checkboxes always do. */
full?: boolean
/** Custom rule. Return an error message, or nothing when valid. */
validate?: (value: FieldValue, data: WizardData) => string | undefined
}
export interface WizardStep {
id: string
title: string
description?: string
fields: WizardField[]
}
function defaultSteps(): WizardStep[] {
return [
{
id: 'account', title: 'Account', description: 'Your sign-in details.',
fields: [
{ name: 'email', label: 'Work email', type: 'email', required: true, autocomplete: 'email', placeholder: 'you@company.com', full: true },
{ name: 'password', label: 'Password', type: 'password', required: true, minLength: 8, autocomplete: 'new-password', help: 'At least 8 characters, with one number.', pattern: '.*\\d.*', patternMessage: 'Include at least one number.', full: true },
],
},
{
id: 'profile', title: 'Profile', description: 'Tell your teammates who you are.',
fields: [
{ name: 'name', label: 'Full name', required: true, autocomplete: 'name', placeholder: 'Rowan Hale' },
{ name: 'company', label: 'Company', autocomplete: 'organization', placeholder: 'Optional' },
{ name: 'role', label: 'Role', type: 'select', required: true, options: [
{ value: 'engineering', label: 'Engineering' }, { value: 'design', label: 'Design' }, { value: 'product', label: 'Product' },
{ value: 'marketing', label: 'Marketing' }, { value: 'other', label: 'Something else' },
] },
{ name: 'teamSize', label: 'Team size', type: 'select', options: [
{ value: '1', label: 'Just me' }, { value: '2-10', label: '2–10' }, { value: '11-50', label: '11–50' }, { value: '51+', label: '51 or more' },
] },
],
},
{
id: 'preferences', title: 'Preferences', description: 'You can change these later in settings.',
fields: [
{ name: 'usage', label: 'How will you use Lumen?', type: 'radio', required: true, options: [
{ value: 'personal', label: 'Personal projects', description: 'Notes, plans and side projects.' },
{ value: 'team', label: 'With my team', description: 'Shared docs and weekly planning.' },
{ value: 'clients', label: 'Client work', description: 'Proposals and project handoffs.' },
] },
{ name: 'productUpdates', label: 'Product updates', type: 'checkbox', help: 'One email a month about new features.' },
{ name: 'weeklyDigest', label: 'Weekly digest', type: 'checkbox', help: 'A Monday summary of activity in your workspace.' },
],
},
]
}
</script>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, useId, watch } from 'vue'
const props = withDefaults(defineProps<{
steps?: WizardStep[]
/** Starting values, keyed by field name. */
initialData?: WizardData
/** Add a final step that summarises every answer. */
review?: boolean
submitLabel?: string
/** Disable the submit button and show a spinner while you save. */
loading?: boolean
}>(), {
steps: () => defaultSteps(),
initialData: () => ({ productUpdates: true }),
review: true,
submitLabel: 'Create account',
loading: false,
})
const emit = defineEmits<{
submit: [data: WizardData]
'step-change': [index: number, stepId: string]
}>()
defineSlots<{
/** Replace the fields of a step. Slot name is `step-<id>`. */
[name: `step-${string}`]: (props: { data: WizardData; errors: Record<string, string>; touch: (name: string) => void }) => unknown
/** Shown instead of the default success panel after submit. */
success?: (props: { data: WizardData; reset: () => void }) => unknown
}>()
const uid = useId()
const REVIEW_ID = '__review'
const allSteps = computed(() => props.review
? [...props.steps, { id: REVIEW_ID, title: 'Review', description: 'Check everything looks right.', fields: [] }]
: props.steps)
function blankData(): WizardData {
const d: WizardData = {}
for (const s of props.steps) for (const f of s.fields) d[f.name] = f.type === 'checkbox' ? false : ''
return { ...d, ...props.initialData }
}
const data = reactive<WizardData>(blankData())
const current = ref(0)
const furthest = ref(0)
const touched = reactive(new Set<string>())
const showPassword = reactive(new Set<string>())
const submitted = ref(false)
const headingEl = ref<HTMLElement | null>(null)
const formEl = ref<HTMLFormElement | null>(null)
const step = computed(() => allSteps.value[current.value]!)
const isReview = computed(() => step.value.id === REVIEW_ID)
const isLast = computed(() => current.value === allSteps.value.length - 1)
function fieldError(f: WizardField): string | undefined {
const v = data[f.name]
if (f.type === 'checkbox') return f.required && !v ? `${f.label} is required.` : f.validate?.(v ?? false, data)
const s = typeof v === 'string' ? v.trim() : ''
if (!s) return f.required ? (f.type === 'select' || f.type === 'radio' ? 'Please choose an option.' : `${f.label} is required.`) : undefined
if (f.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) return 'Enter a valid email address.'
if (f.minLength && s.length < f.minLength) return `Use at least ${f.minLength} characters.`
if (f.pattern && !new RegExp(`^(?:${f.pattern})$`).test(s)) return f.patternMessage ?? 'This doesn’t look right.'
return f.validate?.(v ?? '', data)
}
const errors = computed<Record<string, string>>(() => {
const out: Record<string, string> = {}
for (const f of step.value.fields) {
const e = fieldError(f)
if (e && touched.has(f.name)) out[f.name] = e
}
return out
})
function stepValid(i: number) {
return (allSteps.value[i]?.fields ?? []).every(f => !fieldError(f))
}
async function goTo(i: number) {
current.value = i
furthest.value = Math.max(furthest.value, i)
emit('step-change', i, allSteps.value[i]!.id)
await nextTick()
headingEl.value?.focus()
}
async function next() {
step.value.fields.forEach(f => touched.add(f.name))
if (!stepValid(current.value)) {
await nextTick()
formEl.value?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
return
}
if (isLast.value) {
const firstInvalid = props.steps.findIndex((_, i) => !stepValid(i))
if (firstInvalid !== -1) { props.steps[firstInvalid]!.fields.forEach(f => touched.add(f.name)); goTo(firstInvalid); return }
emit('submit', { ...data })
// If the parent flips `loading` on in response, wait for it to finish before showing success.
await nextTick()
if (!props.loading) submitted.value = true
else {
const stop = watch(() => props.loading, (busy) => { if (!busy) { submitted.value = true; stop() } })
}
return
}
goTo(current.value + 1)
}
function back() {
if (current.value > 0) goTo(current.value - 1)
}
function reset() {
Object.keys(data).forEach(k => delete data[k])
Object.assign(data, blankData())
touched.clear()
submitted.value = false
furthest.value = 0
goTo(0)
}
function displayValue(f: WizardField): string {
const v = data[f.name]
if (f.type === 'checkbox') return v ? 'Yes' : 'No'
if (!v) return '—'
if (f.type === 'password') return '•'.repeat(Math.min(String(v).length, 12))
return f.options?.find(o => o.value === v)?.label ?? String(v)
}
function onText(f: WizardField, e: Event) {
data[f.name] = (e.target as HTMLInputElement | HTMLTextAreaElement).value
}
const wide = (f: WizardField) => f.full || f.type === 'textarea' || f.type === 'radio' || f.type === 'checkbox'
const fid = (name: string) => `${uid}-${name}`
const inputClass = (f: WizardField) => [
'block w-full rounded-lg border bg-white px-3 py-2.5 text-sm shadow-xs placeholder:text-gray-400 focus:ring-2 focus:outline-none dark:bg-gray-950 dark:placeholder:text-gray-500',
f.type === 'select' && !data[f.name] ? 'text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white',
errors.value[f.name]
? 'border-rose-400 focus:border-rose-500 focus:ring-rose-500/25 dark:border-rose-500/70'
: 'border-gray-300 focus:border-emerald-500 focus:ring-emerald-500/25 dark:border-gray-700',
]
const describedBy = (f: WizardField) => [f.help && `${fid(f.name)}-help`, errors.value[f.name] && `${fid(f.name)}-error`].filter(Boolean).join(' ') || undefined
</script>
<template>
<div class="w-full overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:scheme-dark">
<!-- Step indicator -->
<nav aria-label="Progress" class="border-b border-gray-200 px-5 py-4 sm:px-8 sm:py-5 dark:border-gray-800">
<div class="flex items-center justify-between text-sm sm:hidden">
<span class="font-medium text-emerald-700 dark:text-emerald-400">Step {{ current + 1 }} of {{ allSteps.length }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ step.title }}</span>
</div>
<div class="mt-3 h-1.5 overflow-hidden rounded-full bg-gray-100 sm:hidden dark:bg-gray-800" aria-hidden="true">
<div class="h-full rounded-full bg-emerald-500 transition-all duration-300" :style="{ width: `${((submitted ? allSteps.length : current + 1) / allSteps.length) * 100}%` }" />
</div>
<ol class="hidden items-center sm:flex">
<li v-for="(s, i) in allSteps" :key="s.id" class="flex items-center" :class="i < allSteps.length - 1 && 'flex-1'">
<button
type="button"
class="group flex items-center gap-2.5 rounded-lg text-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-500 disabled:cursor-default"
:disabled="submitted || i > furthest || i === current"
:aria-current="i === current ? 'step' : undefined"
@click="goTo(i)"
>
<span
class="grid size-8 shrink-0 place-items-center rounded-full text-xs font-semibold transition"
:class="i < current || submitted
? 'bg-emerald-600 text-white group-enabled:group-hover:bg-emerald-500'
: i === current
? 'border-2 border-emerald-600 text-emerald-700 dark:border-emerald-400 dark:text-emerald-300'
: 'border-2 border-gray-200 text-gray-500 dark:border-gray-700 dark:text-gray-400'"
>
<svg v-if="i < current || submitted" viewBox="0 0 20 20" class="size-4 fill-current" aria-hidden="true"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.6l7.3-7.3a1 1 0 0 1 1.4 0Z" clip-rule="evenodd" /></svg>
<template v-else>{{ i + 1 }}</template>
</span>
<span class="font-medium" :class="i <= current ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'">
{{ s.title }}<span v-if="i < current" class="sr-only"> (completed)</span>
</span>
</button>
<span v-if="i < allSteps.length - 1" class="mx-3 h-0.5 flex-1 rounded-full transition" :class="i < current ? 'bg-emerald-500' : 'bg-gray-200 dark:bg-gray-700'" aria-hidden="true" />
</li>
</ol>
</nav>
<!-- Success -->
<div v-if="submitted" class="px-5 py-14 text-center sm:px-8" role="status">
<slot name="success" :data="data" :reset="reset">
<span class="mx-auto grid size-14 place-items-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
<svg viewBox="0 0 24 24" class="size-7 fill-none stroke-current" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5" /></svg>
</span>
<h2 class="mt-5 text-xl font-semibold text-gray-900 dark:text-white">You're all set</h2>
<p class="mx-auto mt-2 max-w-sm text-sm text-gray-600 dark:text-gray-400">We've sent a confirmation link to <span class="font-medium text-gray-900 dark:text-white">{{ data.email || 'your inbox' }}</span>.</p>
<button type="button" class="mt-6 text-sm font-semibold text-emerald-700 hover:underline dark:text-emerald-400" @click="reset">Start over</button>
</slot>
</div>
<form v-else ref="formEl" novalidate class="px-5 py-6 sm:px-8 sm:py-8" @submit.prevent="next">
<h2 ref="headingEl" tabindex="-1" class="text-lg font-semibold text-gray-900 focus:outline-none dark:text-white">{{ step.title }}</h2>
<p v-if="step.description" class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ step.description }}</p>
<!-- Review -->
<div v-if="isReview" class="mt-6 space-y-4">
<section v-for="(s, i) in steps" :key="s.id" class="rounded-2xl border border-gray-200 dark:border-gray-800">
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-2.5 dark:border-gray-800">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ s.title }}</h3>
<button type="button" class="rounded text-sm font-medium text-emerald-700 hover:underline focus-visible:outline-2 focus-visible:outline-emerald-500 dark:text-emerald-400" :aria-label="`Edit ${s.title}`" @click="goTo(i)">Edit</button>
</div>
<dl class="divide-y divide-gray-100 px-4 dark:divide-gray-800">
<div v-for="f in s.fields" :key="f.name" class="grid grid-cols-5 gap-3 py-2.5 text-sm">
<dt class="col-span-2 text-gray-500 dark:text-gray-400">{{ f.label }}</dt>
<dd class="col-span-3 font-medium break-words text-gray-900 dark:text-gray-100">{{ displayValue(f) }}</dd>
</div>
</dl>
</section>
</div>
<!-- Fields -->
<div v-else class="mt-6">
<slot :name="`step-${step.id}`" :data="data" :errors="errors" :touch="(n: string) => touched.add(n)">
<div class="grid gap-5 sm:grid-cols-2">
<div v-for="f in step.fields" :key="f.name" :class="wide(f) && 'sm:col-span-2'">
<!-- Radio cards -->
<fieldset v-if="f.type === 'radio'" :aria-describedby="describedBy(f)" :aria-invalid="!!errors[f.name] || undefined">
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ f.label }}<span v-if="f.required" class="text-rose-600 dark:text-rose-400" aria-hidden="true"> *</span></legend>
<div class="mt-2 grid gap-3 sm:grid-cols-3">
<label
v-for="o in f.options"
:key="o.value"
class="relative flex cursor-pointer flex-col rounded-xl border p-3.5 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-emerald-500"
:class="data[f.name] === o.value
? 'border-emerald-500 bg-emerald-50/60 dark:border-emerald-400 dark:bg-emerald-500/10'
: errors[f.name] ? 'border-rose-300 dark:border-rose-500/50' : 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'"
>
<input v-model="data[f.name]" type="radio" :name="fid(f.name)" :value="o.value" class="sr-only" :aria-invalid="!!errors[f.name] || undefined" @change="touched.add(f.name)">
<span class="flex items-center justify-between gap-2 text-sm font-medium text-gray-900 dark:text-white">
{{ o.label }}
<span class="grid size-4 place-items-center rounded-full border" :class="data[f.name] === o.value ? 'border-emerald-600 bg-emerald-600 dark:border-emerald-400 dark:bg-emerald-400' : 'border-gray-300 dark:border-gray-600'" aria-hidden="true">
<span v-if="data[f.name] === o.value" class="size-1.5 rounded-full bg-white dark:bg-gray-900" />
</span>
</span>
<span v-if="o.description" class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ o.description }}</span>
</label>
</div>
</fieldset>
<!-- Checkbox -->
<label v-else-if="f.type === 'checkbox'" :for="fid(f.name)" class="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200 p-3.5 transition hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600">
<input :id="fid(f.name)" v-model="data[f.name]" type="checkbox" class="mt-0.5 size-4 shrink-0 accent-emerald-600" :aria-describedby="describedBy(f)" @change="touched.add(f.name)">
<span>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ f.label }}</span>
<span v-if="f.help" :id="`${fid(f.name)}-help`" class="block text-xs text-gray-500 dark:text-gray-400">{{ f.help }}</span>
</span>
</label>
<template v-else>
<label :for="fid(f.name)" class="block text-sm font-medium text-gray-900 dark:text-white">
{{ f.label }}<span v-if="f.required" class="text-rose-600 dark:text-rose-400" aria-hidden="true"> *</span>
</label>
<select
v-if="f.type === 'select'"
:id="fid(f.name)"
v-model="data[f.name]"
:class="inputClass(f)"
class="mt-1.5"
:required="f.required"
:aria-invalid="!!errors[f.name] || undefined"
:aria-describedby="describedBy(f)"
@blur="touched.add(f.name)"
>
<option value="" disabled>Select…</option>
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<textarea
v-else-if="f.type === 'textarea'"
:id="fid(f.name)"
:value="data[f.name]"
rows="4"
:class="inputClass(f)"
class="mt-1.5"
:placeholder="f.placeholder"
:required="f.required"
:aria-invalid="!!errors[f.name] || undefined"
:aria-describedby="describedBy(f)"
@input="onText(f, $event)"
@blur="touched.add(f.name)"
/>
<div v-else class="relative mt-1.5">
<input
:id="fid(f.name)"
:value="data[f.name]"
:type="f.type === 'password' && showPassword.has(f.name) ? 'text' : (f.type ?? 'text')"
:class="[inputClass(f), f.type === 'password' && 'pr-16']"
:placeholder="f.placeholder"
:autocomplete="f.autocomplete"
:required="f.required"
:aria-invalid="!!errors[f.name] || undefined"
:aria-describedby="describedBy(f)"
@input="onText(f, $event)"
@blur="touched.add(f.name)"
>
<button
v-if="f.type === 'password'"
type="button"
class="absolute inset-y-1 right-1 rounded-md px-2.5 text-xs font-medium text-gray-600 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-emerald-500 dark:text-gray-300 dark:hover:bg-gray-800"
:aria-pressed="showPassword.has(f.name)"
@click="showPassword.has(f.name) ? showPassword.delete(f.name) : showPassword.add(f.name)"
>
{{ showPassword.has(f.name) ? 'Hide' : 'Show' }}
</button>
</div>
<p v-if="f.help && !errors[f.name]" :id="`${fid(f.name)}-help`" class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ f.help }}</p>
</template>
<p v-if="errors[f.name]" :id="`${fid(f.name)}-error`" class="mt-1.5 flex items-center gap-1 text-xs font-medium text-rose-600 dark:text-rose-400">
<svg viewBox="0 0 20 20" class="size-3.5 shrink-0 fill-current" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg>
{{ errors[f.name] }}
</p>
</div>
</div>
</slot>
</div>
<div class="mt-8 flex items-center justify-between gap-3 border-t border-gray-100 pt-6 dark:border-gray-800">
<button
type="button"
class="rounded-lg px-4 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-emerald-500 disabled:invisible dark:text-gray-200 dark:hover:bg-gray-800"
:disabled="current === 0"
@click="back"
>
Back
</button>
<button
type="submit"
:disabled="loading"
class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 disabled:opacity-60"
>
<svg v-if="loading" viewBox="0 0 24 24" class="size-4 animate-spin" aria-hidden="true"><circle cx="12" cy="12" r="9" class="fill-none stroke-white/30" stroke-width="3" /><path d="M21 12a9 9 0 0 0-9-9" class="fill-none stroke-white" stroke-width="3" stroke-linecap="round" /></svg>
{{ isLast ? submitLabel : 'Continue' }}
<svg v-if="!isLast" viewBox="0 0 20 20" class="size-4 fill-current" aria-hidden="true"><path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.64l-3.22-3.22a.75.75 0 1 1 1.06-1.06l4.5 4.5a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 1 1-1.06-1.06l3.22-3.22H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd" /></svg>
</button>
</div>
</form>
</div>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | WizardStep[] | Account, Profile, Preferences | Steps to show. Each has id, title, description and fields (name, label, type, placeholder, help, required, minLength, pattern, patternMessage, autocomplete, options, full, validate). |
| initialData | Record<string, string | boolean> | { productUpdates: true } | Starting values keyed by field name. |
| review | boolean | true | Add a final Review step summarising every answer with Edit links. |
| submitLabel | string | 'Create account' | Label for the button on the last step. |
| loading | boolean | false | Disables the submit button and shows a spinner. When it switches back to false after submit, the success panel appears. |
| Event | Payload | Description |
|---|---|---|
| submit | (data: Record<string, string | boolean>) | All steps are valid and the user confirmed. Payload is every field value keyed by name. |
| step-change | (index: number, stepId: string) | The visible step changed. |
| Slot | Description |
|---|---|
| step-<id> | Replace the generated fields for one step. Receives { data, errors, touch }. |
| success | Replace the success panel. Receives { data, reset }. |
Component · Forms
Accessible Vue 3 contact form with inline validation, a live character counter and loading, success and error states. Tailwind CSS, dark mode.
Component · Forms
Drag-and-drop file upload zone for Vue 3 with click to browse, type and size validation, image previews and progress bars. Tailwind CSS, dark mode.
Component · Footers
Vue 3 footer with a newsletter signup form: email validation, loading and success states, and a subscribe event. Tailwind CSS, dark mode.
Component · Hero sections
Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.