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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/site-footer.jsonPrompt
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-controlsdisclosure, operable with Enter and Space. inertcloses the keyboard trap — agrid-rows-[0fr]panel is zero pixels tall but still tabbable. Marking itinertremoves 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
0frtrack only collapses to zero when the grid item's overflow is non-visible; putoverflow-hiddenon 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-hiddenand the platform name rides along assr-onlytext, 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.
Newsletter Signup
An email capture band built around its submit state machine — submitting, confirmed, already-subscribed, and a failure that keeps the address you typed.
Team Grid
A four-state team section — equal-height member cards with initials-fallback avatars, optional bios and real social links, groupable by department.