Component · Footers
Footer Columns
Multi-column Vue 3 footer with a brand blurb, social icon links and a bottom bar for legal links and system status. Tailwind CSS, dark mode.
Vue 3 footer with a newsletter signup form: email validation, loading and success states, and a subscribe event. Tailwind CSS, dark mode.
by Vueplayv1.0.0Updated 3 hours ago
A footer that doubles as your mailing list signup. A tinted card at the top pitches the newsletter and holds the email form; below it sit the brand, three link groups and a bottom bar with legal links.
The form validates on submit, marks the input with aria-invalid, links the error through aria-describedby and moves focus back to the field. The error clears as soon as the address becomes valid. On success the form is replaced by a confirmation announced to screen readers, with a way to use a different address.
Listen to the subscribe event for fire-and-forget signups, or pass an async submitHandler: the button shows a spinner while it runs and a rejected promise's message is shown as the error.
<script setup lang="ts">
import { computed, ref, useId } from 'vue'
export interface FooterLink {
label: string
href: string
}
export interface FooterLinkGroup {
title: string
links: FooterLink[]
}
const props = withDefaults(defineProps<{
brand?: string
tagline?: string
heading?: string
subheading?: string
placeholder?: string
buttonLabel?: string
successMessage?: string
/** Optional async handler. While it runs the button shows a spinner; a rejection shows its message as an error. */
submitHandler?: (email: string) => Promise<void>
groups?: FooterLinkGroup[]
legal?: FooterLink[]
}>(), {
brand: 'Paperkite',
tagline: 'A calm writing tool for teams who publish every week.',
heading: 'The Friday digest',
subheading: 'One short email a week with product updates, a practical writing tip and nothing else. Join 18,000 readers.',
placeholder: 'you@company.com',
buttonLabel: 'Subscribe',
successMessage: 'You are on the list. Check your inbox to confirm your address.',
submitHandler: undefined,
groups: () => [
{ title: 'Product', links: [
{ label: 'Editor', href: '#editor' },
{ label: 'Templates', href: '#templates' },
{ label: 'Pricing', href: '#pricing' },
{ label: 'What\'s new', href: '#changelog' },
] },
{ title: 'Learn', links: [
{ label: 'Style guide', href: '#style' },
{ label: 'Blog', href: '#blog' },
{ label: 'Workshops', href: '#workshops' },
] },
{ title: 'Company', links: [
{ label: 'About', href: '#about' },
{ label: 'Careers', href: '#careers' },
{ label: 'Contact', href: '#contact' },
] },
],
legal: () => [
{ label: 'Privacy', href: '#privacy' },
{ label: 'Terms', href: '#terms' },
],
})
const emit = defineEmits<{
subscribe: [email: string]
}>()
const id = useId()
const email = ref('')
const error = ref('')
const state = ref<'idle' | 'loading' | 'success'>('idle')
const inputRef = ref<HTMLInputElement | null>(null)
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
function validate(value: string): string {
if (!value.trim()) return 'Please enter your email address.'
if (!EMAIL_RE.test(value.trim())) return 'That doesn\'t look like a valid email address.'
return ''
}
// Clear the error as soon as the value becomes valid, but don't nag while the user is still typing.
function onInput() {
if (error.value && !validate(email.value)) error.value = ''
}
async function onSubmit() {
error.value = validate(email.value)
if (error.value) {
inputRef.value?.focus()
return
}
const value = email.value.trim()
emit('subscribe', value)
if (props.submitHandler) {
state.value = 'loading'
try {
await props.submitHandler(value)
} catch (e) {
state.value = 'idle'
error.value = e instanceof Error && e.message ? e.message : 'Something went wrong. Please try again.'
return
}
}
state.value = 'success'
}
function reset() {
email.value = ''
state.value = 'idle'
}
const year = new Date().getFullYear()
const describedBy = computed(() => [`${id}-hint`, error.value ? `${id}-error` : ''].filter(Boolean).join(' '))
const focusRing = 'rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600 dark:focus-visible:outline-rose-400'
</script>
<template>
<footer class="bg-white dark:bg-gray-950">
<div class="mx-auto max-w-7xl px-4 pt-16 pb-8 sm:px-6 lg:px-8">
<section
class="relative overflow-hidden rounded-3xl bg-rose-50 px-6 py-10 ring-1 ring-rose-100 sm:px-10 lg:flex lg:items-center lg:justify-between lg:gap-12 lg:py-12 dark:bg-rose-500/[0.07] dark:ring-rose-400/15"
:aria-labelledby="`${id}-heading`"
>
<svg viewBox="0 0 200 200" aria-hidden="true" class="pointer-events-none absolute -top-16 -right-16 size-64 text-rose-200/70 dark:text-rose-400/10">
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="100" cy="100" r="85" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="100" cy="100" r="35" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
<div class="relative max-w-lg">
<h2 :id="`${id}-heading`" class="text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl dark:text-white">{{ heading }}</h2>
<p class="mt-3 text-pretty text-gray-600 dark:text-gray-300">{{ subheading }}</p>
</div>
<div class="relative mt-8 w-full lg:mt-0 lg:max-w-md">
<div
v-if="state === 'success'"
role="status"
class="flex items-start gap-3 rounded-2xl bg-white p-4 ring-1 ring-rose-100 dark:bg-gray-900 dark:ring-white/10"
>
<span class="grid size-8 shrink-0 place-items-center rounded-full bg-rose-600 text-white dark:bg-rose-500">
<svg viewBox="0 0 20 20" aria-hidden="true" class="size-4 fill-current">
<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>
</span>
<div class="text-sm">
<p class="font-semibold text-gray-900 dark:text-white">Thanks for subscribing</p>
<p class="mt-1 text-gray-600 dark:text-gray-400">{{ successMessage }}</p>
<button type="button" class="mt-2 font-medium text-rose-700 hover:text-rose-600 dark:text-rose-300 dark:hover:text-rose-200" :class="focusRing" @click="reset">
Use a different address
</button>
</div>
</div>
<form v-else novalidate class="w-full" @submit.prevent="onSubmit">
<label :for="`${id}-email`" class="sr-only">Email address</label>
<div class="flex flex-col gap-3 sm:flex-row">
<input
:id="`${id}-email`"
ref="inputRef"
v-model="email"
type="email"
name="email"
autocomplete="email"
inputmode="email"
required
:placeholder="placeholder"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:disabled="state === '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 placeholder:text-gray-400 focus:ring-2 focus:outline-none disabled:opacity-60 sm:text-sm dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500"
:class="error
? 'ring-red-400 focus:ring-red-500 dark:ring-red-400/60'
: 'ring-gray-300 focus:ring-rose-500 dark:ring-white/15 dark:focus:ring-rose-400'"
@input="onInput"
>
<button
type="submit"
:disabled="state === 'loading'"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-rose-600 px-5 py-3 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-80 dark:bg-rose-500 dark:hover:bg-rose-400"
>
<svg v-if="state === 'loading'" viewBox="0 0 24 24" aria-hidden="true" class="size-4 animate-spin" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity=".3" stroke-width="3" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
</svg>
{{ state === 'loading' ? 'Subscribing…' : buttonLabel }}
</button>
</div>
<p v-if="error" :id="`${id}-error`" class="mt-2 flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
<svg viewBox="0 0 20 20" aria-hidden="true" class="size-4 shrink-0 fill-current">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM9.25 6a.75.75 0 0 1 1.5 0v4.5a.75.75 0 0 1-1.5 0V6ZM10 14.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
{{ error }}
</p>
<p :id="`${id}-hint`" class="mt-3 text-xs text-gray-500 dark:text-gray-400">
No spam, unsubscribe in one click. Read our <a href="#privacy" class="font-medium text-gray-700 underline decoration-gray-300 underline-offset-2 hover:text-gray-900 dark:text-gray-300 dark:decoration-gray-600 dark:hover:text-white" :class="focusRing">privacy policy</a>.
</p>
</form>
</div>
</section>
<div class="mt-14 grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-3 lg:grid-cols-5">
<div class="col-span-2 sm:col-span-3 lg:col-span-2">
<slot name="brand">
<a href="#" class="inline-flex items-center gap-2" :class="focusRing">
<svg viewBox="0 0 32 32" aria-hidden="true" class="size-8">
<rect width="32" height="32" rx="9" class="fill-gray-900 dark:fill-white" />
<path d="M9 22 23 10l-4 13-4-5-6 4Z" class="fill-rose-500" />
</svg>
<span class="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">{{ brand }}</span>
</a>
</slot>
<p class="mt-4 max-w-xs text-sm/6 text-gray-600 dark:text-gray-400">{{ tagline }}</p>
</div>
<nav aria-label="Footer" class="contents">
<div v-for="group in groups" :key="group.title">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ group.title }}</h3>
<ul class="mt-4 space-y-3">
<li v-for="link in group.links" :key="link.href">
<a :href="link.href" class="text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" :class="focusRing">{{ link.label }}</a>
</li>
</ul>
</div>
</nav>
</div>
<div class="mt-12 flex flex-col gap-3 border-t border-gray-200 pt-8 text-sm text-gray-500 sm:flex-row sm:items-center sm:justify-between dark:border-white/10 dark:text-gray-400">
<p>© {{ year }} {{ brand }}. Made with care.</p>
<ul class="flex gap-5">
<li v-for="link in legal" :key="link.href">
<a :href="link.href" class="hover:text-gray-900 dark:hover:text-white" :class="focusRing">{{ link.label }}</a>
</li>
</ul>
</div>
</div>
</footer>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| brand | string | 'Paperkite' | Brand name next to the logo and in the copyright line. |
| tagline | string | 'A calm writing tool for teams…' | Short line under the logo. |
| heading | string | 'The Friday digest' | Newsletter card heading. |
| subheading | string | 'One short email a week…' | Newsletter pitch under the heading. |
| placeholder | string | 'you@company.com' | Email input placeholder. |
| buttonLabel | string | 'Subscribe' | Submit button text. |
| successMessage | string | 'You are on the list…' | Text shown after a successful signup. |
| submitHandler | (email: string) => Promise<void> | undefined | Optional async handler. Shows a spinner while pending; a rejection's message becomes the error text. |
| groups | FooterLinkGroup[] | Product, Learn, Company | Link groups, each with a title and links (label, href). |
| legal | FooterLink[] | Privacy, Terms | Links in the bottom bar. |
| Event | Payload | Description |
|---|---|---|
| subscribe | (email: string) | Fired with the trimmed address when a valid email is submitted. |
| Slot | Description |
|---|---|
| brand | Replaces the default logo mark and brand name. |
Component · Footers
Multi-column Vue 3 footer with a brand blurb, social icon links and a bottom bar for legal links and system status. Tailwind CSS, dark mode.
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 · Hero sections
Vue 3 waitlist hero with an accessible email form: inline validation, loading and success states, and a typed submit event. Tailwind CSS, dark mode.
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.