Vue componentFree

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

Install
Full screen ↗

About this component

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.

  • Data-driven link columns with optional badges
  • Labelled icon-only social links
  • Status line with a pulsing dot (respects reduced motion)
  • Responsive 2 to 4 column grid
  • Light and dark mode

Code

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

Props

PropTypeDefaultDescription
brandstring'Fernway'Brand name next to the default logo mark and in the copyright line.
blurbstring'Fernway helps small teams plan…'Short description under the logo.
columnsFooterColumn[]Product, Resources, Company, SupportLink columns. Each has a title and links with label, href and an optional badge.
socialsSocialLink[]Forum, video, code, RSS, emailIcon links with label, href and icon ('chat' | 'video' | 'rss' | 'mail' | 'code' | 'camera' | 'globe').
legalFooterLink[]Privacy, Terms, CookiesLinks shown in the bottom bar.
copyrightstring''Copyright holder. Falls back to the brand name.
statusstring'All systems operational'Status text with a green dot in the bottom bar. Pass an empty string to hide it.

Slots

SlotDescription
brandReplaces the default logo mark and brand name.
bottomReplaces the whole bottom bar (copyright, status and legal links).
All footers →
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
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 Bento GridFree

Component · Feature sections

Bento Grid

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.

by Vueplay
Preview of BlockFree

Component · Layout

Block

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