bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/site-footer

Usage

Usagetsx
<SiteFooter />

Props

PropTypeDefaultDescription
brandstringBottegaBrand wordmark shown top-left.
blurbstringCopy-and-own UI components for modern Next.js applications.One-line blurb beneath the wordmark.
socialsSocialLink[][ { 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.
columnsFooterColumn[][ { 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.
copyrightstringCopyright line in the bottom bar. Defaults to the current year + brand.
legalLinksFooterLink[][ { label: "Privacy Policy", href: "#" }, { label: "Terms of Use", href: "#" }, ]Small legal links in the bottom bar, right-aligned.
classNamestringExtra class names merged onto the outer <footer>.

Source

site-footer.tsxtsx
"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