Vue componentFree

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.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

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.

  • Steps and fields defined as data
  • Per-step validation with accessible errors
  • Clickable step indicator, compact on mobile
  • Review step with edit links
  • Loading and success states

Code

11 files in the repo
<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>

Props

PropTypeDefaultDescription
stepsWizardStep[]Account, Profile, PreferencesSteps to show. Each has id, title, description and fields (name, label, type, placeholder, help, required, minLength, pattern, patternMessage, autocomplete, options, full, validate).
initialDataRecord<string, string | boolean>{ productUpdates: true }Starting values keyed by field name.
reviewbooleantrueAdd a final Review step summarising every answer with Edit links.
submitLabelstring'Create account'Label for the button on the last step.
loadingbooleanfalseDisables the submit button and shows a spinner. When it switches back to false after submit, the success panel appears.

Events

EventPayloadDescription
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.

Slots

SlotDescription
step-<id>Replace the generated fields for one step. Receives { data, errors, touch }.
successReplace the success panel. Receives { data, reset }.
All forms →
Preview of Contact FormFree

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.

by Vueplay
Preview of File DropzoneFree

Component · Forms

File Dropzone

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.

by Vueplay
Preview of Newsletter FooterFree

Component · Footers

Newsletter Footer

Vue 3 footer with a newsletter signup form: email validation, loading and success states, and a subscribe event. Tailwind CSS, dark mode.

by Vueplay
Preview of Hero SplitFree

Component · Hero sections

Hero Split

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.

by Vueplay1 installs

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings