Component · Forms
Multi-Step Form
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.
Accessible Vue 3 contact form with inline validation, a live character counter and loading, success and error states. Tailwind CSS, dark mode.
by Vueplayv1.0.0Updated 2 hours ago
A contact section for marketing sites, support pages and portfolios: contact details on the left, and a form with name, email, topic and message on the right. It stacks into a single column on phones.
Every field has a visible label. Errors appear inline once a field has been touched (or on submit), are tied to the input with aria-describedby, and focus jumps to the first problem. The message box has a live counter that turns amber near the limit and red past it, with a screen-reader friendly announcement.
Pass submitHandler, an async function that sends the data wherever you like. While it runs the form shows a spinner and disables inputs; resolve it to show the success panel or throw to show a retryable error. The submit, success and error events fire along the way if you prefer events.
<script lang="ts">
export interface ContactFormData {
name: string
email: string
topic: string
message: string
}
export interface ContactDetail {
icon: 'mail' | 'phone' | 'pin' | 'clock'
label: string
value: string
href?: string
}
const ICONS: Record<ContactDetail['icon'], string> = {
mail: 'M3 6.75A2.75 2.75 0 0 1 5.75 4h12.5A2.75 2.75 0 0 1 21 6.75v10.5A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25V6.75Zm2.1-.5 6.9 5.18 6.9-5.18M3.5 7l8.5 6.25L20.5 7',
phone: 'M2.25 6.75c0 8.28 6.72 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.37c0-.52-.35-.97-.85-1.09l-4.42-1.1c-.44-.11-.9.06-1.17.42l-.97 1.29a1.13 1.13 0 0 1-1.21.38 12.04 12.04 0 0 1-7.14-7.14 1.13 1.13 0 0 1 .38-1.21l1.3-.97c.36-.27.52-.73.41-1.17l-1.1-4.42a1.13 1.13 0 0 0-1.1-.85H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z',
pin: 'M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM19.5 10.5c0 7.14-7.5 11.25-7.5 11.25S4.5 17.64 4.5 10.5a7.5 7.5 0 1 1 15 0Z',
clock: 'M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
}
</script>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, useId } from 'vue'
type Status = 'idle' | 'loading' | 'success' | 'error'
const props = withDefaults(defineProps<{
heading?: string
subheading?: string
topics?: string[]
/** Maximum message length; a live counter is shown. */
maxLength?: number
minLength?: number
details?: ContactDetail[]
/** Called on a valid submit. Return a promise: pending shows loading, resolve shows success, reject shows the error state. */
submitHandler?: (data: ContactFormData) => Promise<unknown>
successMessage?: string
errorMessage?: string
}>(), {
heading: 'Get in touch',
subheading: 'Questions about pricing, a partnership idea or a bug that’s bugging you? Send us a note and a real person will reply.',
topics: () => ['General question', 'Sales and pricing', 'Technical support', 'Partnerships', 'Press'],
maxLength: 1000,
minLength: 20,
details: () => [
{ icon: 'mail', label: 'Email', value: 'hello@larkmoor.test', href: 'mailto:hello@larkmoor.test' },
{ icon: 'phone', label: 'Phone', value: '+1 (555) 014-2290', href: 'tel:+15550142290' },
{ icon: 'pin', label: 'Studio', value: '48 Harbor Street, Portland' },
{ icon: 'clock', label: 'Response time', value: 'Within one business day' },
],
submitHandler: undefined,
successMessage: 'Thanks! Your message is on its way. We usually reply within one business day.',
errorMessage: 'We couldn’t send your message. Check your connection and try again.',
})
const emit = defineEmits<{
submit: [data: ContactFormData]
success: [data: ContactFormData]
error: [error: unknown]
}>()
defineSlots<{
/** Replace the contact details column. */
aside?: () => unknown
/** Extra fields or consent text above the submit button. */
extra?: () => unknown
}>()
const uid = useId()
const id = (name: string) => `${uid}-${name}`
const form = reactive<ContactFormData>({ name: '', email: '', topic: '', message: '' })
const touched = reactive(new Set<keyof ContactFormData>())
const status = ref<Status>('idle')
const formEl = ref<HTMLFormElement | null>(null)
const statusEl = ref<HTMLElement | null>(null)
const validation = computed<Partial<Record<keyof ContactFormData, string>>>(() => {
const e: Partial<Record<keyof ContactFormData, string>> = {}
if (!form.name.trim()) e.name = 'Please tell us your name.'
if (!form.email.trim()) e.email = 'We need an email address to reply to.'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) e.email = 'That email address doesn’t look complete.'
if (!form.topic) e.topic = 'Pick the topic that fits best.'
const len = form.message.trim().length
if (!len) e.message = 'Write a short message.'
else if (len < props.minLength) e.message = `A little more detail helps: at least ${props.minLength} characters.`
else if (form.message.length > props.maxLength) e.message = `Please keep it under ${props.maxLength} characters.`
return e
})
const errors = computed(() => {
const out: Partial<Record<keyof ContactFormData, string>> = {}
for (const k of touched) if (validation.value[k]) out[k] = validation.value[k]
return out
})
const remaining = computed(() => props.maxLength - form.message.length)
const counterClass = computed(() => remaining.value < 0
? 'text-rose-600 dark:text-rose-400'
: remaining.value < props.maxLength * 0.1 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500 dark:text-gray-400')
async function onSubmit() {
(['name', 'email', 'topic', 'message'] as const).forEach(k => touched.add(k))
if (Object.keys(validation.value).length) {
await nextTick()
formEl.value?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
return
}
const data: ContactFormData = { name: form.name.trim(), email: form.email.trim(), topic: form.topic, message: form.message.trim() }
emit('submit', data)
status.value = 'loading'
try {
await props.submitHandler?.(data)
status.value = 'success'
emit('success', data)
}
catch (err) {
status.value = 'error'
emit('error', err)
}
await nextTick()
statusEl.value?.focus()
}
function reset() {
Object.assign(form, { name: '', email: '', topic: '', message: '' })
touched.clear()
status.value = 'idle'
}
const inputClass = (key: keyof ContactFormData) => [
'mt-1.5 block w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm shadow-xs placeholder:text-gray-400 focus:ring-2 focus:outline-none disabled:opacity-60 dark:bg-gray-950 dark:placeholder:text-gray-500',
key === 'topic' && !form.topic ? 'text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white',
errors.value[key]
? 'border-rose-400 focus:border-rose-500 focus:ring-rose-500/25 dark:border-rose-500/70'
: 'border-gray-300 focus:border-rose-500 focus:ring-rose-500/20 dark:border-gray-700 dark:focus:border-rose-400',
]
const describedBy = (key: keyof ContactFormData, extra?: string) => [errors.value[key] && id(`${key}-error`), extra].filter(Boolean).join(' ') || undefined
</script>
<template>
<section class="mx-auto grid max-w-6xl gap-10 px-4 py-16 sm:px-6 sm:py-24 lg:grid-cols-5 lg:gap-16 lg:px-8 dark:scheme-dark">
<div class="lg:col-span-2">
<slot name="aside">
<h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl dark:text-white">{{ heading }}</h2>
<p class="mt-4 text-base leading-relaxed text-gray-600 dark:text-gray-400">{{ subheading }}</p>
<dl class="mt-10 space-y-6">
<div v-for="d in details" :key="d.label" class="flex gap-4">
<dt class="grid size-10 shrink-0 place-items-center rounded-xl bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400">
<span class="sr-only">{{ d.label }}</span>
<svg viewBox="0 0 24 24" class="size-5 fill-none stroke-current" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="ICONS[d.icon]" /></svg>
</dt>
<dd class="text-sm">
<span class="block text-gray-500 dark:text-gray-400" aria-hidden="true">{{ d.label }}</span>
<a v-if="d.href" :href="d.href" class="font-medium text-gray-900 hover:text-rose-600 dark:text-white dark:hover:text-rose-400">{{ d.value }}</a>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ d.value }}</span>
</dd>
</div>
</dl>
</slot>
</div>
<div class="lg:col-span-3">
<div class="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8 dark:border-gray-800 dark:bg-gray-900">
<div v-if="status === 'success'" ref="statusEl" tabindex="-1" role="status" class="py-10 text-center focus:outline-none">
<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>
<h3 class="mt-5 text-lg font-semibold text-gray-900 dark:text-white">Message sent</h3>
<p class="mx-auto mt-2 max-w-sm text-sm text-gray-600 dark:text-gray-400">{{ successMessage }}</p>
<button type="button" class="mt-6 rounded-lg px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-50 focus-visible:outline-2 focus-visible:outline-rose-500 dark:text-rose-400 dark:hover:bg-rose-500/10" @click="reset">Send another message</button>
</div>
<form v-else ref="formEl" novalidate class="grid gap-5 sm:grid-cols-2" :aria-busy="status === 'loading'" @submit.prevent="onSubmit">
<div v-if="status === 'error'" ref="statusEl" tabindex="-1" role="alert" class="flex gap-3 rounded-xl border border-rose-200 bg-rose-50 p-3.5 text-sm text-rose-800 focus:outline-none sm:col-span-2 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
<svg viewBox="0 0 20 20" class="mt-0.5 size-4 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>
<p>{{ errorMessage }}</p>
</div>
<div>
<label :for="id('name')" class="block text-sm font-medium text-gray-900 dark:text-white">Name</label>
<input :id="id('name')" v-model="form.name" type="text" autocomplete="name" placeholder="Jordan Ellis" :disabled="status === 'loading'" :class="inputClass('name')" :aria-invalid="!!errors.name || undefined" :aria-describedby="describedBy('name')" required @blur="touched.add('name')">
<p v-if="errors.name" :id="id('name-error')" class="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">{{ errors.name }}</p>
</div>
<div>
<label :for="id('email')" class="block text-sm font-medium text-gray-900 dark:text-white">Email</label>
<input :id="id('email')" v-model="form.email" type="email" autocomplete="email" placeholder="jordan@example.com" :disabled="status === 'loading'" :class="inputClass('email')" :aria-invalid="!!errors.email || undefined" :aria-describedby="describedBy('email')" required @blur="touched.add('email')">
<p v-if="errors.email" :id="id('email-error')" class="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">{{ errors.email }}</p>
</div>
<div class="sm:col-span-2">
<label :for="id('topic')" class="block text-sm font-medium text-gray-900 dark:text-white">What’s this about?</label>
<select :id="id('topic')" v-model="form.topic" :disabled="status === 'loading'" :class="inputClass('topic')" :aria-invalid="!!errors.topic || undefined" :aria-describedby="describedBy('topic')" required @blur="touched.add('topic')" @change="touched.add('topic')">
<option value="" disabled>Choose a topic</option>
<option v-for="t in topics" :key="t" :value="t">{{ t }}</option>
</select>
<p v-if="errors.topic" :id="id('topic-error')" class="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">{{ errors.topic }}</p>
</div>
<div class="sm:col-span-2">
<div class="flex items-baseline justify-between">
<label :for="id('message')" class="block text-sm font-medium text-gray-900 dark:text-white">Message</label>
<span :id="id('counter')" class="text-xs tabular-nums" :class="counterClass" aria-live="polite">
<span aria-hidden="true">{{ form.message.length }} / {{ maxLength }}</span>
<span v-if="remaining < maxLength * 0.1" class="sr-only">{{ remaining >= 0 ? `${remaining} characters left` : `${-remaining} characters over the limit` }}</span>
</span>
</div>
<textarea :id="id('message')" v-model="form.message" rows="6" placeholder="How can we help? Links and steps to reproduce are welcome." :disabled="status === 'loading'" :class="inputClass('message')" class="resize-y" :aria-invalid="!!errors.message || undefined" :aria-describedby="describedBy('message', id('counter'))" required @blur="touched.add('message')" />
<p v-if="errors.message" :id="id('message-error')" class="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">{{ errors.message }}</p>
</div>
<div v-if="$slots.extra" class="sm:col-span-2"><slot name="extra" /></div>
<div class="flex flex-col-reverse gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-gray-500 dark:text-gray-400">We only use your details to reply to this message.</p>
<button
type="submit"
:disabled="status === 'loading'"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-rose-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600 disabled:cursor-wait disabled:opacity-70"
>
<svg v-if="status === '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>
{{ status === 'loading' ? 'Sending…' : status === 'error' ? 'Try again' : 'Send message' }}
</button>
</div>
</form>
</div>
</div>
</section>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| heading | string | 'Get in touch' | Heading in the details column. |
| subheading | string | 'Questions about pricing, a partnership idea…' | Supporting text under the heading. |
| topics | string[] | General question, Sales and pricing, Technical support, Partnerships, Press | Options for the topic select. |
| maxLength | number | 1000 | Maximum message length, shown in the counter. |
| minLength | number | 20 | Minimum message length. |
| details | ContactDetail[] | Email, Phone, Studio, Response time | Contact details list. Each has icon ('mail' | 'phone' | 'pin' | 'clock'), label, value and optional href. |
| submitHandler | (data: ContactFormData) => Promise<unknown> | undefined | Async send function. Pending shows loading, resolve shows success, reject shows the error state. Without it the form goes straight to success after emitting submit. |
| successMessage | string | 'Thanks! Your message is on its way…' | Text in the success panel. |
| errorMessage | string | 'We couldn’t send your message…' | Text in the error alert. |
| Event | Payload | Description |
|---|---|---|
| submit | (data: ContactFormData) | A valid form was submitted. Data has name, email, topic and message, trimmed. |
| success | (data: ContactFormData) | submitHandler resolved (or there was none). |
| error | (error: unknown) | submitHandler rejected. |
| Slot | Description |
|---|---|
| aside | Replace the heading and contact details column. |
| extra | Extra content above the submit row, e.g. a consent checkbox. |
Component · Forms
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.
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.