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.
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
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.
<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>
| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | string | 'Early access' | Small uppercase label above the heading. |
| heading | string | 'Plan meals for the week in five minutes' | Main headline. |
| description | string | 'Pantrywise turns what is already in your fridge…' | Supporting paragraph. |
| placeholder | string | 'you@example.com' | Input placeholder. |
| buttonLabel | string | 'Join the waitlist' | Submit button text. |
| successMessage | string | 'You are on the list. Check your inbox…' | Message shown after a successful submit. |
| finePrint | string | 'One email when your invite is ready…' | Small print under the form. |
| status | 'idle' | 'loading' | 'success' | 'error' | undefined | Controlled status. Leave undefined and the component manages it itself with a simulated request. |
| errorMessage | string | 'Something went wrong. Please try again.' | Text shown when status is 'error'. |
| subscriberCount | number | 4200 | Number in the social-proof line under the form. 0 hides the line. |
| Event | Payload | Description |
|---|---|---|
| submit | (email: string) | Fired with the trimmed address once it passes validation. |
| Slot | Description |
|---|---|
| success | Custom confirmation content. Receives { email }. |
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.
Component · Hero sections
Centered Vue 3 hero with an announcement pill, gradient headline, grid background, two CTAs and a row of wordmarks. Tailwind CSS with 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.