Template · React · HTML
Footer Columns
A site footer with a brand block, three to five link columns, a newsletter tile and a legal row.
Preview
React
Install the template with shadcn. It lands in your project as ordinary components that you edit yourself.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-columns.jsonnpx shadcn@latest add @manniche/footer-columnsfooter-columns.tsx
// The classic site footer in the Tiles language: a brand block, three to five link columns, a newsletter tile and a
// legal row. Signature: the newsletter sits in its own tile beside the columns; on narrow boxes the columns go two-up
// and the newsletter tile comes first. It adapts to the box it sits in (container queries), not the viewport.
// Screen readers get a <footer> landmark, one <nav> with a list per column (each under an h3), a labelled email form
// whose errors are linked to the field, and a status region that announces the sent state. The ©-year is computed
// after mount unless `now` is passed, so the first render is pure. Under reduced motion nothing animates.
import { ArrowRight, Check, Globe } from 'lucide-react'
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type FormEvent, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type FooterLink = {
label: string
href: string
/** Opens in a new tab with rel="noopener noreferrer" and a hidden "(opens in a new tab)". */
external?: boolean
}
export type FooterColumn = {
/** The column heading, e.g. "Product". */
title: string
links: FooterLink[]
}
export type FooterNewsletter = {
/** The tile heading. */
title: string
/** One sentence on what people get and how often. */
description: string
/** Called with the trimmed email when the form is valid. Reject to show the error state. Nothing is sent otherwise. */
onSubmit?: (email: string) => Promise<void> | void
/** Fine print under the form, e.g. a privacy link. */
note?: ReactNode
}
export type FooterRegion = {
/** Read aloud for the select, e.g. "Language". */
label: string
value: string
options: { value: string; label: string }[]
onChange: (value: string) => void
}
export type FooterColumnsLabels = {
/** Hidden heading of the footer. */
heading?: string
navigation?: string
email?: string
subscribe?: string
subscribing?: string
invalidEmail?: string
emptyEmail?: string
failed?: string
sentTitle?: string
sentBody?: string
useAnother?: string
newTab?: string
}
export type FooterColumnsProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {
/** The wordmark or logo. Rendered as given; wrap it in a link yourself if it should go home. */
brand: ReactNode
/** One line on what the company does. */
description?: ReactNode
/** Three to five link columns. */
columns: FooterColumn[]
/** The newsletter tile. Leave out to drop the tile and let the columns take the width. */
newsletter?: FooterNewsletter
/** The name after the © sign, e.g. "Halden Studio". */
owner: string
/** Legal links: privacy, terms, imprint. */
legalLinks?: FooterLink[]
/** A language or region select at the end of the legal row. */
region?: FooterRegion
/** Pins the © year and keeps the first render pure. When absent the year appears after mount. */
now?: Date
/** UI strings, for translation. */
labels?: FooterColumnsLabels
}
const DEFAULT_LABELS: Required<FooterColumnsLabels> = {
heading: 'Site footer',
navigation: 'Footer',
email: 'Email address',
subscribe: 'Subscribe',
subscribing: 'Subscribing',
invalidEmail: 'That email does not look right. Check it for a typo, like name@example.com.',
emptyEmail: 'Enter your email address, like name@example.com.',
failed: 'We could not sign you up just now. Check your connection and try again.',
sentTitle: 'You are on the list.',
sentBody: 'We will write to',
useAnother: 'Use another address',
newTab: '(opens in a new tab)',
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
// The page's ring colour, drawn inside the link's own box so a tile never clips it.
const LINK =
'relative inline-flex min-h-11 items-center rounded-md text-sm text-muted-foreground transition-[color] duration-200 ease-out-quint hover:text-foreground focus-visible:text-foreground focus-visible:outline-offset-0'
function FooterAnchor({ link, newTab, className }: { link: FooterLink; newTab: string; className?: string }) {
return (
<a
href={link.href}
className={cn(LINK, className)}
{...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{link.label}
{link.external && <span className="sr-only"> {newTab}</span>}
</a>
)
}
const noopSubscribe = () => () => {}
// null on the server and in the first client render, the real year once mounted.
function useYear(now?: Date) {
const clientYear = useSyncExternalStore(noopSubscribe, () => new Date().getFullYear(), () => null)
return now ? now.getFullYear() : clientYear
}
type Status = 'idle' | 'sending' | 'sent' | 'error'
function NewsletterTile({ data, labels }: { data: FooterNewsletter; labels: Required<FooterColumnsLabels> }) {
const uid = useId()
const inputRef = useRef<HTMLInputElement>(null)
const sentRef = useRef<HTMLDivElement>(null)
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<Status>('idle')
const [sentTo, setSentTo] = useState('')
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
useEffect(() => {
if (status === 'sent') sentRef.current?.focus()
}, [status])
const check = (value: string) => {
const v = value.trim()
if (!v) return labels.emptyEmail
return EMAIL.test(v) ? null : labels.invalidEmail
}
const submit = async (e: FormEvent) => {
e.preventDefault()
if (status === 'sending') return
const problem = check(email)
setError(problem)
if (problem) {
inputRef.current?.focus()
return
}
const value = email.trim()
setStatus('sending')
try {
await data.onSubmit?.(value)
if (!mounted.current) return
setSentTo(value)
setStatus('sent')
} catch {
if (!mounted.current) return
setStatus('error')
}
}
const sent = status === 'sent'
const errorId = `${uid}-error`
const shown = error ?? (status === 'error' ? labels.failed : null)
return (
<section
aria-labelledby={`${uid}-title`}
className="@container/news flex min-w-0 flex-col rounded-[calc(var(--radius)*2+2px)] bg-card p-6 text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]"
>
<h3
id={`${uid}-title`}
className="text-xl leading-[1.15] font-semibold tracking-[-0.02em] text-balance"
style={{ fontFamily: 'var(--font-display, inherit)' }}
>
{data.title}
</h3>
<p className="mt-2 text-sm leading-normal text-pretty text-muted-foreground">{data.description}</p>
{/* Always mounted, so the sent state is announced when it arrives. */}
<div role="status" ref={sentRef} tabIndex={-1} className="outline-none">
{sent && (
<div className="mt-5 flex items-start gap-3 rounded-[14px] bg-muted p-4">
<span className="mt-0.5 grid size-6 flex-none place-items-center rounded-full bg-foreground text-background">
<Check className="size-3.5" aria-hidden strokeWidth={2.5} />
</span>
<div className="min-w-0 text-sm leading-normal">
<p className="font-medium">{labels.sentTitle}</p>
<p className="text-muted-foreground">
{labels.sentBody} <span className="font-mono text-[13px] break-all text-foreground tabular-nums">{sentTo}</span>.
</p>
</div>
</div>
)}
</div>
{sent ? (
<button
type="button"
onClick={() => {
setEmail('')
setError(null)
setStatus('idle')
requestAnimationFrame(() => inputRef.current?.focus())
}}
className="mt-2 inline-flex min-h-11 w-fit items-center rounded-md text-sm text-muted-foreground underline underline-offset-4 transition-[color] duration-200 ease-out-quint hover:text-foreground"
>
{labels.useAnother}
</button>
) : (
<form noValidate onSubmit={submit} className="mt-5 grid gap-2">
<label htmlFor={`${uid}-email`} className="text-[13px] font-medium">
{labels.email}
</label>
{/* Side by side only when the tile itself has room; it is a narrow column on wide pages. */}
<div className="flex flex-col gap-2 @min-[22rem]/news:flex-row">
<input
ref={inputRef}
id={`${uid}-email`}
name="email"
type="email"
inputMode="email"
autoComplete="email"
spellCheck={false}
value={email}
aria-invalid={shown ? true : undefined}
aria-describedby={shown ? errorId : undefined}
onChange={(e) => {
setEmail(e.target.value)
if (error) setError(null)
if (status === 'error') setStatus('idle')
}}
onBlur={() => {
if (email.trim()) setError(check(email))
}}
className={cn(
// flex-1 only in the row: in a column its zero basis would squash the field below 44 px.
'h-11 w-full min-w-0 rounded-[14px] @min-[22rem]/news:flex-1 border bg-background px-3.5 text-base text-foreground placeholder:text-muted-foreground',
'transition-[opacity] duration-200',
shown ? 'border-destructive' : 'border-foreground/25',
)}
/>
<button
type="submit"
aria-disabled={status === 'sending' || undefined}
className={cn(
'inline-flex h-11 flex-none cursor-pointer items-center justify-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground',
'transition-[opacity,transform] duration-200 ease-out-quint active:scale-[0.97] motion-reduce:transition-none',
status === 'sending' && 'cursor-progress opacity-70',
)}
>
{status === 'sending' ? labels.subscribing : labels.subscribe}
{status !== 'sending' && <ArrowRight className="size-4" aria-hidden />}
</button>
</div>
<p
id={errorId}
className={cn('text-[13px] leading-snug text-destructive', !shown && 'hidden')}
role={status === 'error' ? 'alert' : undefined}
>
{shown}
</p>
</form>
)}
{data.note && <div className="mt-3 text-xs leading-normal text-muted-foreground">{data.note}</div>}
</section>
)
}
/** A site footer with link columns, a newsletter tile and a legal row. */
export function FooterColumns({
brand,
description,
columns,
newsletter,
owner,
legalLinks = [],
region,
now,
labels,
className,
...rest
}: FooterColumnsProps) {
const l = { ...DEFAULT_LABELS, ...labels }
const year = useYear(now)
const regionId = useId()
return (
<footer {...rest} className={cn('@container w-full bg-background text-foreground', className)}>
<h2 className="sr-only">{l.heading}</h2>
<div className="mx-auto w-full max-w-6xl px-4 py-12 @min-[40rem]:py-16 sm:px-6">
<div
className={cn(
'grid gap-x-12 gap-y-10',
newsletter && '@min-[56rem]:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)] @min-[56rem]:items-start',
)}
>
{newsletter && (
<div className="order-first min-w-0 @min-[56rem]:order-last">
<NewsletterTile data={newsletter} labels={l} />
</div>
)}
<div className="grid min-w-0 gap-10">
<div className="max-w-sm">
<div className="text-lg font-semibold tracking-[-0.02em]" style={{ fontFamily: 'var(--font-display, inherit)' }}>
{brand}
</div>
{description && <p className="mt-3 text-sm leading-normal text-pretty text-muted-foreground">{description}</p>}
</div>
<nav aria-label={l.navigation}>
<div className="grid grid-cols-2 gap-x-6 gap-y-8 @min-[40rem]:grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))]">
{columns.map((col) => (
<div key={col.title} className="min-w-0">
<h3 className="font-mono text-[11px] font-medium tracking-[0.06em] text-muted-foreground uppercase">{col.title}</h3>
<ul className="mt-2">
{col.links.map((link) => (
<li key={link.href + link.label}>
<FooterAnchor link={link} newTab={l.newTab} />
</li>
))}
</ul>
</div>
))}
</div>
</nav>
</div>
</div>
<div className="mt-12 flex flex-col gap-x-8 gap-y-2 border-t border-border pt-4 @min-[40rem]:flex-row @min-[40rem]:items-center @min-[40rem]:justify-between">
<div className="flex min-w-0 flex-col gap-x-6 @min-[40rem]:flex-row @min-[40rem]:items-center">
<p className="min-h-11 content-center font-mono text-xs text-muted-foreground tabular-nums">
© {year ?? ''}
{year ? ' ' : ''}
{owner}
</p>
{legalLinks.length > 0 && (
<ul className="flex flex-wrap gap-x-5">
{legalLinks.map((link) => (
<li key={link.href + link.label}>
<FooterAnchor link={link} newTab={l.newTab} className="text-[13px]" />
</li>
))}
</ul>
)}
</div>
{region && (
<div className="relative flex w-fit items-center">
<label htmlFor={regionId} className="sr-only">
{region.label}
</label>
<Globe className="pointer-events-none absolute left-3 size-4 text-muted-foreground" aria-hidden />
<select
id={regionId}
value={region.value}
onChange={(e) => region.onChange(e.target.value)}
className="h-11 cursor-pointer rounded-full border border-foreground/25 bg-background pr-4 pl-9 text-[13px] text-foreground"
>
{region.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
)}
</div>
</div>
</footer>
)
}footer-columns-demo.tsx
import { useState } from 'react'
import { FooterColumns } from '@/components/footer-columns'
// Halden Studio is an invented company. Every link goes to example.com.
const COLUMNS = [
{
title: 'Studio',
links: [
{ label: 'About', href: 'https://example.com/about' },
{ label: 'Work', href: 'https://example.com/work' },
{ label: 'Journal', href: 'https://example.com/journal' },
{ label: 'Careers', href: 'https://example.com/careers' },
],
},
{
title: 'Services',
links: [
{ label: 'Product design', href: 'https://example.com/design' },
{ label: 'Web development', href: 'https://example.com/web' },
{ label: 'Workshops', href: 'https://example.com/workshops' },
{ label: 'Retainers', href: 'https://example.com/retainers' },
],
},
{
title: 'Offices',
links: [
{ label: 'Munich', href: 'https://example.com/munich' },
{ label: 'Aalborg', href: 'https://example.com/aalborg' },
{ label: 'Visit us', href: 'https://example.com/visit' },
],
},
{
title: 'Follow',
links: [
{ label: 'LinkedIn', href: 'https://example.com/linkedin', external: true },
{ label: 'Mastodon', href: 'https://example.com/mastodon', external: true },
{ label: 'RSS feed', href: 'https://example.com/feed.xml' },
],
},
]
export default function FooterColumnsDemo() {
const [language, setLanguage] = useState('en')
return (
<FooterColumns
brand="Halden Studio"
description="A small product studio in Munich and Aalborg. We design and build software people keep using."
columns={COLUMNS}
newsletter={{
title: 'The Halden letter',
description: 'One short note a month on what we are making and learning. No tracking pixels.',
onSubmit: () => new Promise<void>((resolve) => setTimeout(resolve, 900)),
note: 'You can unsubscribe in one click. We keep your address only for this letter.',
}}
owner="Halden Studio"
legalLinks={[
{ label: 'Privacy', href: 'https://example.com/privacy' },
{ label: 'Terms', href: 'https://example.com/terms' },
{ label: 'Imprint', href: 'https://example.com/imprint' },
]}
region={{
label: 'Language',
value: language,
onChange: setLanguage,
options: [
{ value: 'en', label: 'English' },
{ value: 'de', label: 'Deutsch' },
{ value: 'da', label: 'Dansk' },
],
}}
/>
)
}HTML
The whole page as one file with all CSS built in and no JavaScript. Toggles, tabs and filters work with HTML and CSS alone.
The colour in your own app
If you already have shadcn set up, these variables give the same colour. Light in :root, dark in .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-columns.json