sections
Site Footer
A polished multi-column site footer for marketing and product sites. Left region: brand wordmark + one-line blurb + a social-icon row (GitHub, X, Email) using inline SVGs stroked with currentColor. Right: 3–4 link columns (Product / Company / Resources / Legal by default), each with an uppercase heading and a list of links. A hairline border divider separates the top region from a bottom bar showing copyright left and small legal links right, stacking on mobile. On scroll-into-view, the brand block and each link column fade and slide up in a staggered Motion useInView reveal. All content renders fully visible at rest and under reduced-motion. All text, borders, and icon strokes use contract tokens.
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/site-footerUsage
<SiteFooter />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| brand | string | Bottega | Brand wordmark shown top-left. |
| blurb | string | Copy-and-own UI components for modern Next.js applications. | One-line blurb beneath the wordmark. |
| socials | SocialLink[] | [ { label: "GitHub", href: "#", icon: "github" }, { label: "X (formerly Twitter)", href: "#", icon: "x" }, { label: "Email us", href: "#", icon: "email" }, ] | Social icon links. Inline SVGs stroked with currentColor. |
| columns | FooterColumn[] | [ { heading: "Product", links: [ { label: "Components", href: "#" }, { label: "Gallery", href: "#" }, { label: "Changelog", href: "#" }, { label: "Roadmap", href: "#" }, ], }, { heading: "Company", links: [ { label: "About", href: "#" }, { label: "Blog", href: "#" }, { label: "Careers", href: "#" }, ], }, { heading: "Resources", links: [ { label: "Documentation", href: "#" }, { label: "Guides", href: "#" }, { label: "API Reference", href: "#" }, ], }, { heading: "Legal", links: [ { label: "Privacy", href: "#" }, { label: "Terms", href: "#" }, { label: "License", href: "#" }, ], }, ] | Link columns rendered to the right of the brand block. |
| copyright | string | — | Copyright line in the bottom bar. Defaults to the current year + brand. |
| legalLinks | FooterLink[] | [ { label: "Privacy Policy", href: "#" }, { label: "Terms of Use", href: "#" }, ] | Small legal links in the bottom bar, right-aligned. |
| className | string | — | Extra class names merged onto the outer <footer>. |
Source
"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import styles from "./site-footer.module.css";
// Expo-out: snappy arrival, long tail — editorial feel matching the Atelier identity.
const EASE_EXPO_OUT = [0.16, 1, 0.3, 1] as const;
// Column reveal timings in SECONDS (Motion JS API) — deliberately distinct from
// the --duration CSS UI-transition token (≈300ms). Bespoke scroll-reveal cadence.
const COL_DURATION = 0.5;
const COL_STAGGER = 0.07;
// ─── Types ────────────────────────────────────────────────────────────────────
export interface FooterLink {
label: string;
href: string;
}
export interface FooterColumn {
heading: string;
links: FooterLink[];
}
export type SocialIconSlug = "github" | "x" | "email";
export interface SocialLink {
label: string;
href: string;
icon: SocialIconSlug;
}
export interface SiteFooterProps {
/** Brand wordmark shown top-left. */
brand?: string;
/** One-line blurb beneath the wordmark. */
blurb?: string;
/** Social icon links. Inline SVGs stroked with currentColor. */
socials?: SocialLink[];
/** Link columns rendered to the right of the brand block. */
columns?: FooterColumn[];
/** Copyright line in the bottom bar. Defaults to the current year + brand. */
copyright?: string;
/** Small legal links in the bottom bar, right-aligned. */
legalLinks?: FooterLink[];
/** Extra class names merged onto the outer <footer>. */
className?: string;
}
// ─── Defaults ─────────────────────────────────────────────────────────────────
const DEFAULT_COLUMNS: FooterColumn[] = [
{
heading: "Product",
links: [
{ label: "Components", href: "#" },
{ label: "Gallery", href: "#" },
{ label: "Changelog", href: "#" },
{ label: "Roadmap", href: "#" },
],
},
{
heading: "Company",
links: [
{ label: "About", href: "#" },
{ label: "Blog", href: "#" },
{ label: "Careers", href: "#" },
],
},
{
heading: "Resources",
links: [
{ label: "Documentation", href: "#" },
{ label: "Guides", href: "#" },
{ label: "API Reference", href: "#" },
],
},
{
heading: "Legal",
links: [
{ label: "Privacy", href: "#" },
{ label: "Terms", href: "#" },
{ label: "License", href: "#" },
],
},
];
const DEFAULT_SOCIALS: SocialLink[] = [
{ label: "GitHub", href: "#", icon: "github" },
{ label: "X (formerly Twitter)", href: "#", icon: "x" },
{ label: "Email us", href: "#", icon: "email" },
];
const DEFAULT_LEGAL_LINKS: FooterLink[] = [
{ label: "Privacy Policy", href: "#" },
{ label: "Terms of Use", href: "#" },
];
// ─── SVG Icons (inline, stroked with currentColor — no external images) ───────
function GitHubIcon() {
return (
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77 5.44 5.44 0 0 0 3.5 8.55c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
</svg>
);
}
function XIcon() {
return (
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="4" x2="20" y2="20" />
<line x1="20" y1="4" x2="4" y2="20" />
</svg>
);
}
function EmailIcon() {
return (
<svg
aria-hidden="true"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<polyline points="2,7 12,14 22,7" />
</svg>
);
}
const ICON_MAP: Record<SocialIconSlug, React.ReactNode> = {
github: <GitHubIcon />,
x: <XIcon />,
email: <EmailIcon />,
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function cx(...parts: Array<string | false | undefined>): string {
return parts.filter(Boolean).join(" ");
}
// ─── Component ────────────────────────────────────────────────────────────────
export function SiteFooter({
brand = "Bottega",
blurb = "Copy-and-own UI components for modern Next.js applications.",
socials = DEFAULT_SOCIALS,
columns = DEFAULT_COLUMNS,
copyright,
legalLinks = DEFAULT_LEGAL_LINKS,
className,
}: SiteFooterProps) {
const ref = useRef<HTMLElement>(null);
// Mount-gate + null-safe reduced-motion check (Bottega contract).
// Server + first client render are byte-identical → no React #418.
// Animation turns on only after hydration for non-reduced-motion users.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null → reduced
const animate = mounted && !reduce;
// Trigger once the footer is 15% visible — it sits at page bottom so a low
// threshold fires as soon as the user scrolls close.
const inView = useInView(ref, { once: true, amount: 0.15 });
const show = animate && inView;
// ponytail: year computed at render — can theoretically differ across a
// year-boundary SSR→hydrate (midnight Dec 31). Negligible for a copyright
// notice; pass `copyright` explicitly to eliminate entirely.
const year = new Date().getFullYear();
const copyrightText = copyright ?? `© ${year} ${brand}. All rights reserved.`;
return (
<footer
ref={ref}
className={cx(
"w-full bg-background text-foreground",
"px-6 pt-16 pb-10 sm:px-10 sm:pt-20",
className,
)}
>
<div className="mx-auto max-w-6xl">
{/* ── Top region: brand block (left) + link columns (right) ── */}
<div className="flex flex-col gap-12 lg:flex-row">
{/* Brand block — index 0 in the stagger sequence */}
<motion.div
initial={animate ? { y: 22, opacity: 0 } : false}
animate={
show
? { y: 0, opacity: 1 }
: animate
? { y: 22, opacity: 0 }
: false
}
transition={{
duration: COL_DURATION,
ease: EASE_EXPO_OUT,
delay: 0,
}}
className="flex shrink-0 flex-col gap-5 lg:w-56 xl:w-64"
>
{/* Wordmark */}
<p className="text-xl font-bold tracking-tight text-foreground">
{brand}
</p>
{/* Blurb */}
<p className={cx("max-w-xs text-sm leading-relaxed", styles.muted)}>
{blurb}
</p>
{/* Social icons row */}
<div
role="list"
aria-label="Social links"
className="flex items-center gap-2"
>
{socials.map((social) => (
<a
key={social.icon}
href={social.href}
aria-label={social.label}
role="listitem"
className={cx(
"flex h-8 w-8 items-center justify-center",
"rounded-[var(--radius)] border border-border",
styles.socialLink,
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
)}
>
{ICON_MAP[social.icon]}
</a>
))}
</div>
</motion.div>
{/* Link columns — each staggered after the brand block */}
<div className="grid flex-1 grid-cols-2 gap-x-8 gap-y-10 sm:grid-cols-4">
{columns.map((col, colIndex) => (
<motion.div
key={col.heading}
initial={animate ? { y: 22, opacity: 0 } : false}
animate={
show
? { y: 0, opacity: 1 }
: animate
? { y: 22, opacity: 0 }
: false
}
transition={{
duration: COL_DURATION,
ease: EASE_EXPO_OUT,
// Brand = index 0; columns start at index 1.
delay: show ? (colIndex + 1) * COL_STAGGER : 0,
}}
>
<h3
className={cx(
"mb-3 text-[0.65rem] font-semibold uppercase tracking-widest",
styles.muted,
)}
>
{col.heading}
</h3>
<ul className="flex flex-col gap-2">
{col.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
className={cx(
"text-sm rounded-sm",
styles.navLink,
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--background)]",
)}
>
{link.label}
</a>
</li>
))}
</ul>
</motion.div>
))}
</div>
</div>
{/* ── Hairline divider ── */}
<div
aria-hidden="true"
className="mt-12 border-t border-border"
/>
{/* ── Bottom bar: copyright left, legal links right — stacks on mobile ── */}
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* suppressHydrationWarning: the default year comes from new Date() and
could differ across a midnight-Dec-31 SSR→hydrate boundary. */}
<p className={cx("text-xs", styles.muted)} suppressHydrationWarning>
{copyrightText}
</p>
<nav aria-label="Legal navigation">
<ul className="flex flex-wrap items-center gap-x-5 gap-y-2">
{legalLinks.map((link) => (
<li key={link.label}>
<a
href={link.href}
className={cx(
"text-xs rounded-sm",
styles.navLink,
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--background)]",
)}
>
{link.label}
</a>
</li>
))}
</ul>
</nav>
</div>
</div>
</footer>
);
}
Dependencies
- motion
- @bottega/tokens