Blocks

Site Footer

A contract-driven site footer with a brand block, named social links, mobile-collapsible link columns and a legal bottom bar.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowUpRight,
  AtSign,
  Briefcase,
  Camera,
  ChevronDown,
  GitBranch,
  Globe,
  type LucideIcon,
  Mail,
  MessageCircle,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/site-footer.json

Prompt

Build a React + TypeScript + Tailwind "SiteFooter" block (lucide-react icons)
with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    brand: { name, tagline?, href };
    groups: { id, label, links: { id, label, href, external? }[] }[];
    social: { id, label, href, channel }[];
    legal: { copyright, links: { id, label, href }[] } }.
- Every href goes through one shared schema that rejects "#" and the empty
  string, so a dead anchor cannot get past parsing. The host always supplies
  real destinations; the component never invents one.
- `channel` is a semantic enum — chat | code | email | photo | post | rss |
  video | website | work — NOT a brand name. The visible platform name lives
  in `label`; the enum only picks a neutral glyph.
- Component props = z.infer of the schema plus: logo?: ReactNode (defaults to
  a monogram tile derived from brand.name), collapsibleOnMobile = true,
  locales?/activeLocale?/onLocaleChange?, onRetry?, labels? (accessible names
  for non-English hosts), className, and the remaining <footer> props.
- Nothing about the brand is hardcoded in the component — name, tagline, home
  href, copyright string and every label arrive as data.

Behavior
- Only the link columns are fetched, so only they branch on status: loading
  renders three skeleton columns, empty renders a dashed "no link groups yet"
  panel, error renders a message plus a "Try again" button when onRetry
  exists, ready renders the columns. The brand block and the legal bottom bar
  come from static site config and render in all four states — a footer that
  disappears while its CMS is slow is worse than one with an empty middle.
- status "ready" with zero groups resolves to the empty branch instead of
  painting an empty grid.
- Below the md breakpoint (and only when collapsibleOnMobile) each column
  becomes a disclosure: the heading text becomes a <button aria-expanded
  aria-controls> inside the <h3>, and the panel animates with
  grid-template-rows 0fr → 1fr.
  Two traps this must avoid:
  (1) overflow-hidden belongs on the grid ITEM, not the grid container —
      a 0fr track's automatic minimum size only collapses to zero when the
      item's overflow is not visible, otherwise the list's padding leaves a
      ~16px sliver of a "closed" panel;
  (2) a zero-height panel is still tabbable, i.e. an invisible keyboard trap,
      so the collapsed panel gets `inert` (links stay in the DOM for crawlers
      but leave the tab order and the accessibility tree).
- The breakpoint is read with matchMedia through useSyncExternalStore, with a
  server snapshot of "not narrow" — the server HTML therefore ships every link
  expanded, and no browser API is touched during render.
- Optional locale switcher in the bottom bar: a roving-tabindex radiogroup
  (Arrow keys move and select, Home/End jump, only the selected option is in
  the tab order). It renders only when both `locales` and `onLocaleChange`
  are supplied, so it can never appear as a control that does nothing.
- External links get target=_blank + rel=noreferrer, an arrow glyph and an
  sr-only "(opens in a new tab)".

Rendering & styling
- Semantic HTML, not div soup: a single <footer>, one <nav> around the column
  grid and one around the legal links, each column an <h3> plus a <ul> named
  by aria-labelledby pointing at its heading.
- Social links are icon-only, so each carries an sr-only label and an
  aria-hidden glyph — without it a screen reader announces a row of unnamed
  links.
- Semantic tokens only: bg-background + border-t for the shell, text-
  muted-foreground for links and copy, hover/focus lifting to text-foreground,
  bg-muted skeleton bars, bg-primary/text-primary-foreground monogram tile,
  focus-visible:ring-2 ring-ring everywhere. cn() merges className.
- Layout: brand column beside the grid from lg up; the columns run 1 → md:3 →
  xl:4. Column count never caps the data — every group and every link renders,
  with no slice() and no fixed-height clipping.
- Every transition carries motion-reduce:transition-none; with reduced motion
  the disclosure snaps open instead of animating, and stays fully functional.

Customization levers
- Column rhythm: md:grid-cols-3 xl:grid-cols-4 is the only place the column
  count lives; change it (and the brand column's lg:grid-cols-[18rem_1fr]) to
  rebalance for 2 or 8 groups.
- Collapse policy: collapsibleOnMobile={false} keeps plain stacked columns on
  phones; move the NARROW_QUERY constant and the md: prefixes together to
  collapse at a different width.
- Social glyphs: CHANNEL_ICONS maps the channel enum to lucide glyphs — swap
  the whole map for simple-icons brand marks (or your own SVGs) without
  touching the contract, since the accessible name comes from `label`.
- Brand block: pass `logo` to replace the monogram tile with a wordmark or
  <Image>; drop `tagline` for a tighter footer.
- Bottom bar slot: the locale radiogroup sits beside the legal <nav> — the
  same slot takes a theme toggle or a status-page badge; render it only when
  its handler exists so it is never a decorative control.
- Density: py-12 / gap-10 / gap-2.5 are the three knobs for a taller marketing
  footer or a compact app-shell one.

Concepts

  • Static shell, fetched middle — brand and legal live in site config, so they render through all four states; only the link columns are contract data. The footer never vanishes because a CMS call is slow.
  • Disclosure, not just stacking — on phones a six-column sitemap becomes six taps instead of six screens of scrolling; each column is a real aria-expanded / aria-controls disclosure, operable with Enter and Space.
  • inert closes the keyboard trap — a grid-rows-[0fr] panel is zero pixels tall but still tabbable. Marking it inert removes it from the tab order and the accessibility tree while leaving the links in the DOM for crawlers.
  • Overflow on the item, not the container — a 0fr track only collapses to zero when the grid item's overflow is non-visible; put overflow-hidden on the container instead and the list's padding leaves a visible sliver under a "closed" heading.
  • Named icon links — an icon-only anchor with no text is announced as a blank link. The glyph is aria-hidden and the platform name rides along as sr-only text, which also keeps the icon set swappable without touching accessibility.
  • Channel, not brand — the contract stores a channel enum (code, chat, video…) and a free-text label, so the component ships no brand marks and no hardcoded company name.

On This Page