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.
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 Vueplayv1.0.0Updated 2 hours ago
The classic site footer: your logo and a sentence about the product on the left, four columns of links on the right, and a bottom bar with the copyright line, legal links and an optional status indicator.
Everything is data-driven. Columns are arrays of links, and any link can carry a small badge such as "New" or "Hiring". Social links use a built-in set of neutral glyphs (forum, video, code, RSS, email, camera, globe) and always get an accessible label, so icon-only buttons still make sense to screen readers.
The layout goes from two columns on phones to four next to the brand block on large screens. Replace the logo with the brand slot or the whole bottom bar with the bottom slot.
<script setup lang="ts">
import { useId } from 'vue'
export interface FooterLink {
label: string
href: string
/** Small pill shown after the label, e.g. "New" or "Hiring". */
badge?: string
}
export interface FooterColumn {
title: string
links: FooterLink[]
}
export type SocialIcon = 'chat' | 'video' | 'rss' | 'mail' | 'code' | 'camera' | 'globe'
export interface SocialLink {
/** Accessible name, e.g. "Community forum". */
label: string
href: string
icon: SocialIcon
}
withDefaults(defineProps<{
brand?: string
blurb?: string
columns?: FooterColumn[]
socials?: SocialLink[]
legal?: FooterLink[]
/** Copyright holder. Defaults to the brand name. */
copyright?: string
/** Optional status line with a green dot in the bottom bar. Empty string hides it. */
status?: string
}>(), {
brand: 'Fernway',
blurb: 'Fernway helps small teams plan, ship and measure product work in one calm place. Built in Oslo, used in 40 countries.',
columns: () => [
{ title: 'Product', links: [
{ label: 'Overview', href: '#overview' },
{ label: 'Roadmaps', href: '#roadmaps' },
{ label: 'Insights', href: '#insights', badge: 'New' },
{ label: 'Integrations', href: '#integrations' },
{ label: 'Pricing', href: '#pricing' },
] },
{ title: 'Resources', links: [
{ label: 'Documentation', href: '#docs' },
{ label: 'Guides', href: '#guides' },
{ label: 'Changelog', href: '#changelog' },
{ label: 'API reference', href: '#api' },
] },
{ title: 'Company', links: [
{ label: 'About', href: '#about' },
{ label: 'Careers', href: '#careers', badge: 'Hiring' },
{ label: 'Blog', href: '#blog' },
{ label: 'Press kit', href: '#press' },
] },
{ title: 'Support', links: [
{ label: 'Help center', href: '#help' },
{ label: 'Contact us', href: '#contact' },
{ label: 'Security', href: '#security' },
{ label: 'Accessibility', href: '#a11y' },
] },
],
socials: () => [
{ label: 'Community forum', href: '#forum', icon: 'chat' },
{ label: 'Video channel', href: '#videos', icon: 'video' },
{ label: 'Source code', href: '#source', icon: 'code' },
{ label: 'RSS feed', href: '#rss', icon: 'rss' },
{ label: 'Email us', href: '#email', icon: 'mail' },
],
legal: () => [
{ label: 'Privacy', href: '#privacy' },
{ label: 'Terms', href: '#terms' },
{ label: 'Cookies', href: '#cookies' },
],
copyright: '',
status: 'All systems operational',
})
const icons: Record<SocialIcon, string> = {
chat: 'M5 4h14a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 19 16h-8.5l-4.7 3.8A.5.5 0 0 1 5 19.4V16a1.5 1.5 0 0 1-1.5-1.5v-9A1.5 1.5 0 0 1 5 4Z',
video: 'M3.5 7A2.5 2.5 0 0 1 6 4.5h12A2.5 2.5 0 0 1 20.5 7v10a2.5 2.5 0 0 1-2.5 2.5H6A2.5 2.5 0 0 1 3.5 17V7Zm7 1.8v6.4l5-3.2-5-3.2Z',
rss: 'M5 4.5a14.5 14.5 0 0 1 14.5 14.5M5 10a9 9 0 0 1 9 9M6 19a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z',
mail: 'M3.5 6.5h17v11h-17v-11Zm0 .5 8.5 6 8.5-6',
code: 'm8.5 8-4 4 4 4m7-8 4 4-4 4M13.5 5l-3 14',
camera: 'M4 8h3l1.5-2.5h7L17 8h3v11H4V8Zm8 8.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm-9-9h18M12 3c2.5 2.5 3.5 5.5 3.5 9s-1 6.5-3.5 9c-2.5-2.5-3.5-5.5-3.5-9s1-6.5 3.5-9Z',
}
// Filled glyphs read better at 20px; the rest are drawn as strokes.
const filled: SocialIcon[] = ['chat', 'video']
const headingId = useId()
const year = new Date().getFullYear()
const focusRing = 'rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400'
</script>
<template>
<footer class="border-t border-gray-200 bg-white dark:border-white/10 dark:bg-gray-950" :aria-labelledby="headingId">
<h2 :id="headingId" class="sr-only">Footer</h2>
<div class="mx-auto max-w-7xl px-4 pt-16 pb-8 sm:px-6 lg:px-8 lg:pt-20">
<div class="grid gap-12 lg:grid-cols-[minmax(0,20rem)_1fr] lg:gap-16">
<div>
<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-emerald-600 dark:fill-emerald-500" />
<path d="M16 25c0-6 0-10 5-15M16 25c0-4-1.5-7-6-9M21 10c-3 0-5 .5-6 3m6-3c.3 3-.5 5-3 6" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" />
</svg>
<span class="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">{{ brand }}</span>
</a>
</slot>
<p class="mt-5 text-sm/6 text-pretty text-gray-600 dark:text-gray-400">{{ blurb }}</p>
<ul v-if="socials.length" class="mt-6 flex flex-wrap gap-2" aria-label="Social links">
<li v-for="social in socials" :key="social.href">
<a
:href="social.href"
:aria-label="social.label"
:title="social.label"
class="grid size-9 place-items-center rounded-lg text-gray-500 ring-1 ring-gray-200 transition hover:bg-emerald-50 hover:text-emerald-700 hover:ring-emerald-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:text-gray-400 dark:ring-white/10 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-300 dark:hover:ring-emerald-500/30"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
class="size-5"
:fill="filled.includes(social.icon) ? 'currentColor' : 'none'"
:stroke="filled.includes(social.icon) ? 'none' : 'currentColor'"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path :d="icons[social.icon]" :fill-rule="filled.includes(social.icon) ? 'evenodd' : undefined" />
</svg>
</a>
</li>
</ul>
</div>
<nav aria-label="Footer" class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-4">
<div v-for="column in columns" :key="column.title">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ column.title }}</h3>
<ul class="mt-4 space-y-3">
<li v-for="link in column.links" :key="link.href">
<a
:href="link.href"
class="inline-flex items-center gap-2 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
:class="focusRing"
>
{{ link.label }}
<span
v-if="link.badge"
class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20"
>{{ link.badge }}</span>
</a>
</li>
</ul>
</div>
</nav>
</div>
<div class="mt-14 flex flex-col gap-4 border-t border-gray-200 pt-8 text-sm sm:flex-row sm:items-center sm:justify-between dark:border-white/10">
<slot name="bottom">
<p class="text-gray-500 dark:text-gray-400">© {{ year }} {{ copyright || brand }}. All rights reserved.</p>
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
<p v-if="status" class="inline-flex items-center gap-2 text-gray-600 dark:text-gray-300">
<span class="relative flex size-2">
<span class="absolute inset-0 animate-ping rounded-full bg-emerald-400 opacity-60 motion-reduce:hidden" />
<span class="relative size-2 rounded-full bg-emerald-500" />
</span>
{{ status }}
</p>
<ul class="flex gap-5">
<li v-for="link in legal" :key="link.href">
<a :href="link.href" class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" :class="focusRing">{{ link.label }}</a>
</li>
</ul>
</div>
</slot>
</div>
</div>
</footer>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| brand | string | 'Fernway' | Brand name next to the default logo mark and in the copyright line. |
| blurb | string | 'Fernway helps small teams plan…' | Short description under the logo. |
| columns | FooterColumn[] | Product, Resources, Company, Support | Link columns. Each has a title and links with label, href and an optional badge. |
| socials | SocialLink[] | Forum, video, code, RSS, email | Icon links with label, href and icon ('chat' | 'video' | 'rss' | 'mail' | 'code' | 'camera' | 'globe'). |
| legal | FooterLink[] | Privacy, Terms, Cookies | Links shown in the bottom bar. |
| copyright | string | '' | Copyright holder. Falls back to the brand name. |
| status | string | 'All systems operational' | Status text with a green dot in the bottom bar. Pass an empty string to hide it. |
| Slot | Description |
|---|---|
| brand | Replaces the default logo mark and brand name. |
| bottom | Replaces the whole bottom bar (copyright, status and legal links). |
Component · Footers
Vue 3 footer with a newsletter signup form: email validation, loading and success states, and a subscribe event. Tailwind CSS, dark mode.
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 · Feature sections
Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.
Component · Layout