Vue componentFree

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 Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

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.

  • Email validation with accessible error messages
  • Optional async submitHandler with loading state
  • Success state with role="status"
  • Link groups and legal bar
  • Light and dark mode

Code

11 files in the repo
<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>&copy; {{ 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>

Props

PropTypeDefaultDescription
brandstring'Paperkite'Brand name next to the logo and in the copyright line.
taglinestring'A calm writing tool for teams…'Short line under the logo.
headingstring'The Friday digest'Newsletter card heading.
subheadingstring'One short email a week…'Newsletter pitch under the heading.
placeholderstring'you@company.com'Email input placeholder.
buttonLabelstring'Subscribe'Submit button text.
successMessagestring'You are on the list…'Text shown after a successful signup.
submitHandler(email: string) => Promise<void>undefinedOptional async handler. Shows a spinner while pending; a rejection's message becomes the error text.
groupsFooterLinkGroup[]Product, Learn, CompanyLink groups, each with a title and links (label, href).
legalFooterLink[]Privacy, TermsLinks in the bottom bar.

Events

EventPayloadDescription
subscribe(email: string)Fired with the trimmed address when a valid email is submitted.

Slots

SlotDescription
brandReplaces the default logo mark and brand name.
All footers →
Preview of Footer ColumnsFree

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.

by Vueplay
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 Hero Email CaptureFree

Component · Hero sections

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 Vueplay
Preview of Multi-Step FormFree

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.

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