Vue componentFree

Hero Email Capture

Vue 3 waitlist hero with an accessible email form: inline validation, loading and success states, and a typed submit event. Tailwind CSS, dark mode.

by Vueplayv1.0.0Updated 2 hours ago

Install
Full screen ↗

About this component

A hero for launch pages, waitlists and newsletters where the only goal is collecting an email address. It validates as the visitor types after the first attempt, shows a spinner while you save the address, then swaps the form for a confirmation message.

Out of the box it runs uncontrolled and simulates a short request so you can see every state. In production, pass the status prop ('idle' | 'loading' | 'success' | 'error') and drive it from your own API call in the submit handler.

The input has a proper label, aria-invalid and aria-describedby wiring, and the success message is announced through a live region. Customise the confirmation with the success slot.

  • Inline email validation
  • Loading, success and error states
  • Controlled or uncontrolled status
  • Accessible labels and live regions
  • Light and dark mode

Code

11 files in the repo
<script setup lang="ts">
import { computed, ref, useId } from 'vue'

export type CaptureStatus = 'idle' | 'loading' | 'success' | 'error'

const props = withDefaults(defineProps<{
  eyebrow?: string
  heading?: string
  description?: string
  placeholder?: string
  buttonLabel?: string
  successMessage?: string
  /** Small print under the form. */
  finePrint?: string
  /** Controlled status. Leave undefined to let the component manage it (it simulates a short request). */
  status?: CaptureStatus
  /** Error text shown when `status` is 'error'. */
  errorMessage?: string
  /** Count shown in the social-proof line. 0 hides it. */
  subscriberCount?: number
}>(), {
  eyebrow: 'Early access',
  heading: 'Plan meals for the week in five minutes',
  description: 'Pantrywise turns what is already in your fridge into a weekly menu and a tidy shopping list. Join the waitlist and we will send your invite as soon as a spot opens.',
  placeholder: 'you@example.com',
  buttonLabel: 'Join the waitlist',
  successMessage: 'You are on the list. Check your inbox to confirm your email.',
  finePrint: 'One email when your invite is ready. No spam, unsubscribe in one click.',
  status: undefined,
  errorMessage: 'Something went wrong. Please try again.',
  subscriberCount: 4200,
})

const emit = defineEmits<{
  /** Fired with a validated, trimmed email address. */
  submit: [email: string]
}>()

const id = useId()
const email = ref('')
const touched = ref(false)
const internalStatus = ref<CaptureStatus>('idle')
const currentStatus = computed<CaptureStatus>(() => props.status ?? internalStatus.value)

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
const validationError = computed(() => {
  const value = email.value.trim()
  if (!value) return 'Enter your email address.'
  if (!EMAIL_RE.test(value)) return 'That email does not look right. Check for typos.'
  return ''
})
const showError = computed(() => touched.value && validationError.value !== '')

function onSubmit() {
  touched.value = true
  if (validationError.value || currentStatus.value === 'loading') return
  const value = email.value.trim()
  emit('submit', value)
  // Uncontrolled mode: fake a request so the demo shows every state.
  if (props.status === undefined) {
    internalStatus.value = 'loading'
    setTimeout(() => { internalStatus.value = 'success' }, 900)
  }
}

const avatars = [
  { initials: 'AK', bg: 'bg-emerald-600' },
  { initials: 'SL', bg: 'bg-teal-600' },
  { initials: 'RB', bg: 'bg-cyan-700' },
  { initials: 'MN', bg: 'bg-lime-700' },
]
const formattedCount = computed(() => new Intl.NumberFormat('en-US').format(props.subscriberCount))
</script>

<template>
  <section class="relative isolate overflow-hidden">
    <div aria-hidden="true" class="absolute top-2/3 left-1/2 -z-10 h-80 w-[48rem] max-w-none -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-200/40 blur-3xl dark:bg-emerald-500/10" />
    <div class="mx-auto max-w-3xl px-6 py-24 text-center sm:py-32 lg:px-8">
      <p v-if="eyebrow" class="text-sm font-semibold tracking-wide text-emerald-700 uppercase dark:text-emerald-400">{{ eyebrow }}</p>
      <h1 class="mt-4 text-4xl font-bold tracking-tight text-balance text-gray-900 sm:text-6xl dark:text-white">{{ heading }}</h1>
      <p class="mx-auto mt-6 max-w-2xl text-lg leading-8 text-pretty text-gray-600 dark:text-gray-300">{{ description }}</p>

      <div class="mx-auto mt-10 max-w-lg">
        <Transition
          mode="out-in"
          enter-active-class="transition duration-300 ease-out"
          enter-from-class="opacity-0 translate-y-1"
          leave-active-class="transition duration-150 ease-in"
          leave-to-class="opacity-0"
        >
          <div
            v-if="currentStatus === 'success'"
            key="success"
            role="status"
            class="flex items-center gap-3 rounded-2xl bg-emerald-50 px-5 py-4 text-left text-sm font-medium text-emerald-800 ring-1 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20"
          >
            <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-6 shrink-0"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.8a.75.75 0 0 0-1.22-.87l-3.24 4.53-1.6-1.6a.75.75 0 1 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.1l3.73-5.27Z" clip-rule="evenodd" /></svg>
            <slot name="success" :email="email.trim()">{{ successMessage }}</slot>
          </div>

          <form v-else key="form" novalidate class="text-left" @submit.prevent="onSubmit">
            <div class="flex flex-col gap-3 sm:flex-row">
              <label :for="`${id}-email`" class="sr-only">Email address</label>
              <input
                :id="`${id}-email`"
                v-model="email"
                type="email"
                name="email"
                autocomplete="email"
                inputmode="email"
                required
                :placeholder="placeholder"
                :aria-invalid="showError"
                :aria-describedby="showError ? `${id}-error` : `${id}-hint`"
                :disabled="currentStatus === 'loading'"
                class="min-w-0 flex-1 rounded-xl border-0 bg-white px-4 py-3 text-base text-gray-900 shadow-sm ring-1 transition ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-emerald-600 focus:outline-none disabled:opacity-60 sm:text-sm dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500 dark:focus:ring-emerald-500"
                :class="showError ? 'ring-rose-500' : 'ring-gray-300 dark:ring-gray-700'"
                @blur="touched = email.length > 0 || touched"
              >
              <button
                type="submit"
                :disabled="currentStatus === 'loading'"
                :aria-busy="currentStatus === 'loading'"
                class="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-5 py-3 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:cursor-wait disabled:opacity-80 dark:bg-emerald-500 dark:text-gray-950 dark:hover:bg-emerald-400"
              >
                <svg v-if="currentStatus === 'loading'" viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4 animate-spin">
                  <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity="0.3" stroke-width="3" />
                  <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
                </svg>
                {{ currentStatus === 'loading' ? 'Joining…' : buttonLabel }}
              </button>
            </div>
            <p v-if="showError" :id="`${id}-error`" class="mt-2 text-sm text-rose-600 dark:text-rose-400">{{ validationError }}</p>
            <p v-else-if="currentStatus === 'error'" role="alert" class="mt-2 text-sm text-rose-600 dark:text-rose-400">{{ errorMessage }}</p>
            <p v-else :id="`${id}-hint`" class="mt-3 text-center text-xs text-gray-500 sm:text-left dark:text-gray-400">{{ finePrint }}</p>
          </form>
        </Transition>
      </div>

      <div v-if="subscriberCount > 0" class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row">
        <div class="flex -space-x-1" aria-hidden="true">
          <span
            v-for="a in avatars"
            :key="a.initials"
            class="grid size-8 place-items-center rounded-full text-[11px] font-semibold text-white ring-2 ring-white dark:ring-gray-950"
            :class="a.bg"
          >{{ a.initials }}</span>
        </div>
        <p class="text-sm text-gray-600 dark:text-gray-400">
          Join <span class="font-semibold text-gray-900 dark:text-white">{{ formattedCount }}+</span> home cooks already on the list
        </p>
      </div>
    </div>
  </section>
</template>

Props

PropTypeDefaultDescription
eyebrowstring'Early access'Small uppercase label above the heading.
headingstring'Plan meals for the week in five minutes'Main headline.
descriptionstring'Pantrywise turns what is already in your fridge…'Supporting paragraph.
placeholderstring'you@example.com'Input placeholder.
buttonLabelstring'Join the waitlist'Submit button text.
successMessagestring'You are on the list. Check your inbox…'Message shown after a successful submit.
finePrintstring'One email when your invite is ready…'Small print under the form.
status'idle' | 'loading' | 'success' | 'error'undefinedControlled status. Leave undefined and the component manages it itself with a simulated request.
errorMessagestring'Something went wrong. Please try again.'Text shown when status is 'error'.
subscriberCountnumber4200Number in the social-proof line under the form. 0 hides the line.

Events

EventPayloadDescription
submit(email: string)Fired with the trimmed address once it passes validation.

Slots

SlotDescription
successCustom confirmation content. Receives { email }.
All hero sections →
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
Preview of Hero CenteredFree

Component · Hero sections

Hero Centered

Centered Vue 3 hero with an announcement pill, gradient headline, grid background, two CTAs and a row of wordmarks. Tailwind CSS with 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

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