Navigation
Footer
A site footer with brand, columns of links and a legal row that stacks by its own width, not the screen.
- Built on
<footer><nav>Container queries:has()- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.8 kB
- Built from
- Icon
Code
import { Footer } from "@/components/equip/footer";
export default function Demo() {
return (
<Footer
style={{ width: 216 }}
brand={<a href="#home">Larder</a>}
legal="© 2026 Larder Labs"
social={[
{ label: "Larder on RSS", href: "#rss", icon: "rss" },
{ label: "Larder newsletter", href: "#newsletter", icon: "mail" },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<footer class="eq-footer" style="width:216px">
<div class="eq-footer-inner">
<div class="eq-footer-main">
<div class="eq-footer-lead">
<div class="eq-footer-brand">
<a href="#home">Larder</a>
</div>
</div>
</div>
<div class="eq-footer-bottom">
<div class="eq-footer-legal">© 2026 Larder Labs</div>
<div class="eq-footer-social">
<a class="eq-footer-social-link" href="#rss" aria-label="Larder on RSS" title="Larder on RSS">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 18.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M4.5 11.5a8 8 0 0 1 8 8" />
<path d="M4.5 4.5a15 15 0 0 1 15 15" />
</svg>
</a>
<a class="eq-footer-social-link" href="#newsletter" aria-label="Larder newsletter" title="Larder newsletter">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2z" />
<path d="M4 7l8 5.5L20 7" />
</svg>
</a>
</div>
</div>
</div>
</footer>Examples
Product site
Below 560px of its own width the brand stacks over the columns; the columns fill as many tracks as fit.
Code
import { Footer } from "@/components/equip/footer";
export default function Example() {
return (
<Footer
style={{ width: "100%" }}
brand={<a href="#home">Larder</a>}
blurb="Deploy previews, logs and rollbacks for teams that ship every day."
columns={[
{
title: "Product",
links: [
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
{ label: "Changelog", href: "#changelog" },
{ label: "Status", href: "#status" },
],
},
{
title: "Developers",
links: [
{ label: "Documentation", href: "#docs" },
{ label: "API reference", href: "#api" },
{ label: "Command line", href: "#cli" },
],
},
{
title: "Company",
links: [
{ label: "About", href: "#about" },
{ label: "Careers", href: "#careers" },
{ label: "Contact", href: "#contact" },
],
},
]}
legal={
<>
<span>© 2026 Larder Labs</span>
<a href="#privacy">Privacy</a>
<a href="#terms">Terms</a>
</>
}
social={[
{ label: "Larder blog feed", href: "#rss", icon: "rss" },
{ label: "Email the Larder team", href: "#contact", icon: "mail" },
{ label: "Larder community", href: "#community", icon: "messages" },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<footer class="eq-footer" style="width:100%">
<div class="eq-footer-inner">
<div class="eq-footer-main">
<div class="eq-footer-lead">
<div class="eq-footer-brand">
<a href="#home">Larder</a>
</div>
<p class="eq-footer-blurb">Deploy previews, logs and rollbacks for teams that ship every day.</p>
</div>
<nav class="eq-footer-nav" aria-label="Footer">
<div class="eq-footer-column">
<h2 class="eq-footer-heading">Product</h2>
<ul class="eq-footer-links">
<li>
<a href="#features" class="eq-footer-link">Features</a>
</li>
<li>
<a href="#pricing" class="eq-footer-link">Pricing</a>
</li>
<li>
<a href="#changelog" class="eq-footer-link">Changelog</a>
</li>
<li>
<a href="#status" class="eq-footer-link">Status</a>
</li>
</ul>
</div>
<div class="eq-footer-column">
<h2 class="eq-footer-heading">Developers</h2>
<ul class="eq-footer-links">
<li>
<a href="#docs" class="eq-footer-link">Documentation</a>
</li>
<li>
<a href="#api" class="eq-footer-link">API reference</a>
</li>
<li>
<a href="#cli" class="eq-footer-link">Command line</a>
</li>
</ul>
</div>
<div class="eq-footer-column">
<h2 class="eq-footer-heading">Company</h2>
<ul class="eq-footer-links">
<li>
<a href="#about" class="eq-footer-link">About</a>
</li>
<li>
<a href="#careers" class="eq-footer-link">Careers</a>
</li>
<li>
<a href="#contact" class="eq-footer-link">Contact</a>
</li>
</ul>
</div>
</nav>
</div>
<div class="eq-footer-bottom">
<div class="eq-footer-legal"><span>© 2026 Larder Labs</span><a href="#privacy">Privacy</a><a href="#terms">Terms</a></div>
<div class="eq-footer-social">
<a class="eq-footer-social-link" href="#rss" aria-label="Larder blog feed" title="Larder blog feed">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 18.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M4.5 11.5a8 8 0 0 1 8 8" />
<path d="M4.5 4.5a15 15 0 0 1 15 15" />
</svg>
</a>
<a class="eq-footer-social-link" href="#contact" aria-label="Email the Larder team" title="Email the Larder team">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2z" />
<path d="M4 7l8 5.5L20 7" />
</svg>
</a>
<a class="eq-footer-social-link" href="#community" aria-label="Larder community" title="Larder community">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="m" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 9h9a2 2 0 0 1 2 2v5.5a2 2 0 0 1-2 2h-.5V21L15 18.5h-5a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z" />
<path d="M15.5 5.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2v2.5L8 13" />
</svg>
</a>
</div>
</div>
</div>
</footer>With a sign-up form
Children go under the blurb, for a newsletter form or a language picker.
Code
"use client";
import { Button } from "@/components/equip/button";
import { Footer } from "@/components/equip/footer";
export default function Example() {
return (
<Footer
style={{ width: "100%" }}
brand={<a href="#home">Field notes</a>}
blurb="A weekly letter on typography and interface craft."
columns={[
{
title: "Read",
links: [
{ label: "Latest issue", href: "#latest" },
{ label: "Archive", href: "#archive" },
],
},
{
title: "About",
links: [
{ label: "Authors", href: "#authors" },
{ label: "Write for us", href: "#write" },
],
},
]}
legal="© 2026 Field notes"
>
<form style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 4 }} onSubmit={(event) => event.preventDefault()}>
<input
type="email"
required
placeholder="you@example.com"
aria-label="Email address"
style={{
flex: "1 1 160px",
minWidth: 0,
height: "var(--eq-h-m)",
boxSizing: "border-box",
padding: "0 var(--eq-space-3)",
border: "1px solid var(--eq-line-strong)",
borderRadius: "var(--eq-radius-m)",
background: "var(--eq-raised)",
color: "var(--eq-ink)",
font: "inherit",
}}
/>
<Button type="submit">Subscribe</Button>
</form>
</Footer>
);
}Legal row only
With nothing but legal text the footer is a single wrapping row, for app screens and sign-in pages.
Code
import { Footer } from "@/components/equip/footer";
export default function Example() {
return (
<Footer
style={{ width: "100%" }}
legal={
<>
<span>© 2026 Larder Labs</span>
<a href="#privacy">Privacy</a>
<a href="#terms">Terms</a>
<a href="#cookies">Cookie settings</a>
</>
}
/>
);
}HTMLworks in any framework, with the part's CSS
<footer class="eq-footer" style="width:100%">
<div class="eq-footer-inner">
<div class="eq-footer-bottom">
<div class="eq-footer-legal"><span>© 2026 Larder Labs</span><a href="#privacy">Privacy</a><a href="#terms">Terms</a><a href="#cookies">Cookie settings</a></div>
</div>
</div>
</footer>Edge to edge, on a page column
The border and background span the page while maxWidth and inset line the contents up with a 56rem column; leadWidth gives the blurb a narrower measure.
Code
import { Footer } from "@/components/equip/footer";
export default function Example() {
return (
<Footer
style={{ width: "100%" }}
maxWidth="56rem"
inset="l"
leadWidth="22rem"
brand={<a href="#home">Larder</a>}
blurb="Preview deployments, Postgres branches and instant rollback for teams that ship every day."
columns={[
{
title: "Product",
links: [
{ label: "Previews", href: "#previews" },
{ label: "Postgres", href: "#postgres" },
{ label: "Pricing", href: "#pricing" },
],
},
{
title: "Company",
links: [
{ label: "About", href: "#about" },
{ label: "Careers", href: "#careers" },
{ label: "Status", href: "#status" },
],
},
]}
legal={
<>
<span>© 2026 Larder Labs</span>
<a href="#privacy">Privacy</a>
<a href="#terms">Terms</a>
</>
}
/>
);
}HTMLworks in any framework, with the part's CSS
<footer class="eq-footer" data-inset="l" style="--eq-footer-max-width:56rem;--eq-footer-lead-width:22rem;width:100%">
<div class="eq-footer-inner">
<div class="eq-footer-main">
<div class="eq-footer-lead">
<div class="eq-footer-brand">
<a href="#home">Larder</a>
</div>
<p class="eq-footer-blurb">Preview deployments, Postgres branches and instant rollback for teams that ship every day.</p>
</div>
<nav class="eq-footer-nav" aria-label="Footer">
<div class="eq-footer-column">
<h2 class="eq-footer-heading">Product</h2>
<ul class="eq-footer-links">
<li>
<a href="#previews" class="eq-footer-link">Previews</a>
</li>
<li>
<a href="#postgres" class="eq-footer-link">Postgres</a>
</li>
<li>
<a href="#pricing" class="eq-footer-link">Pricing</a>
</li>
</ul>
</div>
<div class="eq-footer-column">
<h2 class="eq-footer-heading">Company</h2>
<ul class="eq-footer-links">
<li>
<a href="#about" class="eq-footer-link">About</a>
</li>
<li>
<a href="#careers" class="eq-footer-link">Careers</a>
</li>
<li>
<a href="#status" class="eq-footer-link">Status</a>
</li>
</ul>
</div>
</nav>
</div>
<div class="eq-footer-bottom">
<div class="eq-footer-legal"><span>© 2026 Larder Labs</span><a href="#privacy">Privacy</a><a href="#terms">Terms</a></div>
</div>
</div>
</footer>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
brand | ReactNode | none | A logo or wordmark, usually a link home. |
blurb | ReactNode | none | One or two sentences under the brand. |
columns | { title: string; links: { label: ReactNode; href: string }[] }[] | none | Columns of links, all inside one nav landmark. Any other anchor attribute on a link goes to it. |
legal | ReactNode | none | Copyright and legal links for the bottom row. Several children wrap as a row. |
social | { label: string; href: string; icon: IconName | ReactElement }[] | ReactNode | none | Icon links drawn from data, each named by its label, or your own nodes, at the end of the bottom row. |
navLabel | string | "Footer" | Names the navigation landmark around the columns. |
headingLevel | 2 | 3 | 4 | 2 | Heading level of the column titles. |
maxWidth | string | number | none | Caps the width of the contents, padding included, and centres them, to line up with the page column. The footer and its border still span. Sets --eq-footer-max-width. |
inset | "none" | "m" | "l" | "none" | Inline padding inside the footer: m is 16px; l is 16px, then 24px from 640px of the footer's own width. Use it when the footer runs edge to edge. |
leadWidth | string | number | "36ch" | The widest the brand, blurb and children column grows. Sets --eq-footer-lead-width. |
children | ReactNode | none | Placed under the blurb, for a sign-up form or a region picker. |
...rest | ComponentProps<"footer"> | none | Every native footer attribute. |
Anatomy
| root | .eq-footer | The footer, and a size container named eq-footer. data-inset when inset is set. |
|---|---|---|
| inner | .eq-footer-inner | Capped by maxWidth and padded by inset. |
| main | .eq-footer-main | Brand beside the columns from 560px, stacked below. |
| lead | .eq-footer-lead | Holds brand, blurb and children. |
| brand | .eq-footer-brand | |
| blurb | .eq-footer-blurb | |
| nav | .eq-footer-nav | The nav landmark; a grid of columns at least 112px wide. |
| column | .eq-footer-column | |
| heading | .eq-footer-heading | |
| links | .eq-footer-links | |
| link | .eq-footer-link | Rows at least 28px tall. |
| bottom | .eq-footer-bottom | The legal row, wrapping as it narrows. |
| legal | .eq-footer-legal | |
| social | .eq-footer-social | |
| social link | .eq-footer-social-link | An icon link named by aria-label, with the same text as a tooltip. |
Keyboard
| Tab | Moves through the links in reading order. |
|---|
Notes
- A footer at the top level of the page is the contentinfo landmark; inside article or main it is not, which is what you want for a card or section footer.
- All the columns share one nav landmark, named Footer, so a page with a header nav has two clearly named landmarks.
- Column titles are real headings so the footer can be browsed by heading; set headingLevel to fit the page outline.
- It stacks on its own width with a container query, so it works in a narrow column or a side panel as well as across a page.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/footer.tsx128 lines
import type { ComponentProps, CSSProperties, ReactElement, ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import "./footer.css";
export type FooterLink = Omit<ComponentProps<"a">, "children"> & { label: ReactNode; href: string };
export type FooterColumn = {
/** A short heading for the column. */
title: string;
links: FooterLink[];
};
export type FooterSocialLink = {
/** The accessible name, such as "Larder on GitHub". */
label: string;
href: string;
icon: IconName | ReactElement;
};
export type FooterProps = ComponentProps<"footer"> & {
/** A logo or wordmark, usually a link home. */
brand?: ReactNode;
/** One or two sentences under the brand. */
blurb?: ReactNode;
/** Columns of links, read as one navigation landmark. */
columns?: FooterColumn[];
/** Copyright and legal text for the bottom row. */
legal?: ReactNode;
/** Icon links drawn from data, or your own nodes, at the end of the bottom row. */
social?: FooterSocialLink[] | ReactNode;
/** Names the navigation landmark that holds the columns. */
navLabel?: string;
/** Heading level of the column titles. */
headingLevel?: 2 | 3 | 4;
/** Caps the width of the contents and centres them, as a CSS length or pixels. The footer itself still spans. */
maxWidth?: string | number;
/** Inline padding inside the footer: m is 16px, l is 16px then 24px from 640px of its own width. */
inset?: "none" | "m" | "l";
/** The widest the brand, blurb and children column grows, as a CSS length or pixels. */
leadWidth?: string | number;
};
const length = (value: string | number) => (typeof value === "number" ? `${value}px` : value);
/** children are placed under the blurb, for a newsletter form or a region picker. */
export function Footer({
brand,
blurb,
columns = [],
legal,
social,
navLabel = "Footer",
headingLevel = 2,
maxWidth,
inset = "none",
leadWidth,
className,
style,
children,
...rest
}: FooterProps) {
const Heading = `h${headingLevel}` as "h2";
const hasLead = brand != null || blurb != null || children != null;
return (
<footer
{...rest}
className={["eq-footer", className].filter(Boolean).join(" ")}
data-inset={inset === "none" ? undefined : inset}
style={
maxWidth === undefined && leadWidth === undefined
? style
: ({
...(maxWidth === undefined ? {} : { "--eq-footer-max-width": length(maxWidth) }),
...(leadWidth === undefined ? {} : { "--eq-footer-lead-width": length(leadWidth) }),
...style,
} as CSSProperties)
}
>
<div className="eq-footer-inner">
{(hasLead || columns.length > 0) && (
<div className="eq-footer-main">
{hasLead && (
<div className="eq-footer-lead">
{brand != null && <div className="eq-footer-brand">{brand}</div>}
{blurb != null && <p className="eq-footer-blurb">{blurb}</p>}
{children}
</div>
)}
{columns.length > 0 && (
<nav className="eq-footer-nav" aria-label={navLabel}>
{columns.map((column) => (
<div key={column.title} className="eq-footer-column">
<Heading className="eq-footer-heading">{column.title}</Heading>
<ul className="eq-footer-links">
{column.links.map(({ label, href, className: linkClass, ...link }) => (
<li key={href}>
<a {...link} href={href} className={["eq-footer-link", linkClass].filter(Boolean).join(" ")}>
{label}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
)}
</div>
)}
{(legal != null || social != null) && (
<div className="eq-footer-bottom">
{legal != null && <div className="eq-footer-legal">{legal}</div>}
{social != null && (
<div className="eq-footer-social">
{Array.isArray(social)
? (social as FooterSocialLink[]).map((item) => (
<a key={item.href} className="eq-footer-social-link" href={item.href} aria-label={item.label} title={item.label}>
{typeof item.icon === "string" ? <Icon name={item.icon} size="m" /> : item.icon}
</a>
))
: social}
</div>
)}
</div>
)}
</div>
</footer>
);
}components/equip/footer.css210 lines
@layer equip {
/* The footer measures itself, so it stacks in a narrow column even on a wide screen. */
.eq-footer {
container: eq-footer / inline-size;
box-sizing: border-box;
min-width: 0;
padding-block: var(--eq-space-4);
border-top: 1px solid var(--eq-line);
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.5 var(--eq-font);
}
.eq-footer-inner {
--_inset: 0px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--eq-space-5);
max-width: var(--eq-footer-max-width, none);
margin-inline: auto;
padding-inline: var(--_inset);
}
.eq-footer:is([data-inset="m"], [data-inset="l"]) > .eq-footer-inner {
--_inset: var(--eq-space-4);
}
.eq-footer-main {
display: grid;
gap: var(--eq-space-5);
}
.eq-footer-lead {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--eq-space-2);
min-width: 0;
max-width: var(--eq-footer-lead-width, 36ch);
}
.eq-footer-brand {
color: var(--eq-ink);
font-size: var(--eq-text-l);
font-weight: var(--eq-weight-strong);
}
.eq-footer-brand a {
color: inherit;
text-decoration: none;
}
.eq-footer-blurb {
margin: 0;
}
/* A sign-up field passed as children gets the contract placeholder colour. */
.eq-footer-lead :is(input, textarea)::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-footer-nav {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 112px), 1fr));
gap: var(--eq-space-5) var(--eq-space-4);
min-width: 0;
}
.eq-footer-column {
min-width: 0;
}
.eq-footer-heading {
margin: 0 0 var(--eq-space-1);
color: var(--eq-ink);
font-size: var(--eq-text-s);
font-weight: var(--eq-weight-strong);
line-height: 24px;
}
.eq-footer-links {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
}
/* Rows of 28px so stacked links keep a target well over 24px. */
.eq-footer-link {
display: inline-flex;
align-items: center;
min-height: var(--eq-h-s);
border-radius: var(--eq-radius-s);
color: var(--eq-ink-2);
text-decoration: none;
overflow-wrap: anywhere;
transition: color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-footer-link:hover {
color: var(--eq-ink);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 42%, transparent);
text-underline-offset: 0.22em;
}
}
.eq-footer-link:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-footer-link[aria-current="page"] {
color: var(--eq-ink);
}
.eq-footer-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-2) var(--eq-space-4);
padding-top: var(--eq-space-3);
border-top: 1px solid var(--eq-line);
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
}
.eq-footer-main:empty + .eq-footer-bottom,
.eq-footer-bottom:only-child {
padding-top: 0;
border-top: 0;
}
.eq-footer-legal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-1) var(--eq-space-3);
min-width: 0;
}
.eq-footer-legal a {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: var(--eq-radius-s);
color: var(--eq-ink-2);
}
.eq-footer-legal a:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-footer-social {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-1);
color: var(--eq-ink-2);
}
.eq-footer-social-link {
display: grid;
place-items: center;
width: var(--eq-h-s);
height: var(--eq-h-s);
border-radius: var(--eq-radius-s);
color: var(--eq-ink-2);
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-footer-social-link:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-footer-social-link:active {
scale: var(--eq-press);
}
.eq-footer-social-link:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
@container eq-footer (width >= 640px) {
.eq-footer[data-inset="l"] > .eq-footer-inner {
--_inset: var(--eq-space-5);
}
}
/* From 560px the brand takes a column of its own beside the links. */
@container eq-footer (width >= 560px) {
.eq-footer-main:has(.eq-footer-lead):has(.eq-footer-nav) {
grid-template-columns: minmax(180px, 1fr) 2fr;
gap: var(--eq-space-6);
}
}
}