Site Footer
The shared site chrome footer: a shell plus parts, from a one-line strip to a multi-column footer.
Installation
Usage
import {
SiteFooter, SiteFooterAttribution, SiteFooterBrand, SiteFooterColumn,
SiteFooterColumns, SiteFooterRow,
siteFooterNavItemClass, siteFooterIconLinkClass,
} from "@/components/ui/site-footer"Examples
Default strip
Pass no children and the shell renders the original single-row strip: attribution and copyright on one side, links on the other.
Page title
Page content ends here and the footer closes the column.
Multi-column
Brand on one side, the column region on the other, then a bottom bar. SiteFooterColumns puts the columns on equal grid tracks: packed into a flex row they size to their own content, so nothing lines up, and justify-between flings them to the edges. Two columns below sm, one track per column above it.
Page title
Page content ends here and the footer closes the column.
Centred
align="center" stacks the whole footer down the middle: the archetype for a small site or a single project page, where a start-aligned footer with one short link row looks abandoned in the corner. It reaches the parts through a scoped CSS rule rather than React context, so the footer stays a Server Component.
Page title
Page content ends here and the footer closes the column.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenSiteFooter | ReactNode | - | Rows, brand blocks, and columns. Wins over the strip props. |
| nameSiteFooter, SiteFooterAttribution | string | "UI" | The site's wordmark name for the default attribution, e.g. "SKILLS". The "using UI.MD" clause is dropped when the site is UI.MD itself. |
| attributionSiteFooter | ReactNode | - | Replaces the default credit line in the strip. Only for a site that genuinely needs different credits. |
| navSiteFooter | ReactNode | - | Strip links, styled with siteFooterNavItemClass. |
| justifySiteFooterRow | "between" | "start" | "end" | "between" | How the run distributes its children. between is what a footer row nearly always wants; the other two keep it packed to one side. Ignored under align="center", which centres every row. |
| dividerSiteFooterRow | boolean | false | A hairline above the row, for the legal bar under a tall footer. The line lands in the shell's own gap-8 and the row's padding matches it, so the rule sits evenly between the two zones instead of hugging one. |
| alignSiteFooter | "start" | "center" | "start" | center stacks the whole footer down the middle, reaching the parts through a scoped CSS rule rather than React context so the footer stays a Server Component. |
| titleSiteFooterColumn | ReactNode | - | Column heading above the links, rendered as the kit's group label: uppercase, tracking-wide, semibold, in text-foreground over muted links. Omit for an untitled stack. |
| childrenSiteFooterColumns | ReactNode | - | Two or more <SiteFooterColumn />. They land on equal grid tracks, two-up below sm and one track each above it, however many you pass. Packed into a flex row instead they would size to their own content and fail to line up. |
| classNameSiteFooter, SiteFooterRow, SiteFooterBrand, SiteFooterColumn, SiteFooterColumns | string | - | On <SiteFooter /> it merges onto the <footer>, not the inner chrome container. Pass mt-auto there when the page is a flex min-h-dvh flex-col column. |
import * as React from "react"
import { cn } from "@/registry/lib/utils"
import { Brand } from "@/registry/ui/brand"
/**
* Nav link treatment for the footer's links: muted, lifting to foreground on
* hover, matching the header nav. Sites compose their own router links with
* this class so the shell stays router-free.
*
* `inline-flex` is load-bearing, not cosmetic. A plain box puts a trailing
* icon (an external-link arrow) in the inline flow, where the space before it
* is a line-break opportunity, so the item's min-content is the label alone
* and the arrow drops to a second line the moment the row is squeezed. Flex
* also makes a `gap-*` override actually apply.
*/
const siteFooterNavItemClass =
"inline-flex items-center rounded-sm px-2 py-1.5 outline-none transition-colors hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring-accent"
/**
* Inline link treatment for links inside footer prose (the attribution
* line): the design system's inline-link rule, accent underlined.
*/
const siteFooterInlineLinkClass =
"rounded-sm text-accent underline outline-none hover:no-underline focus-visible:ring-1 focus-visible:ring-ring-accent"
/**
* Icon-only link treatment for a social run. Square on the kit's default
* control height, so a row of these lines up with anything else in the chrome.
*
* `pointer-coarse:size-11` is the part that is easy to leave out. 32px is a
* comfortable mouse target and a poor thumb one, and a footer social row is
* exactly the kind of small, tightly packed control cluster that gets tapped on
* a phone. 44px is the floor there.
*
* Every one of these needs its own `aria-label`: the accessible name of an
* icon-only link is whatever you give it, and an unlabelled one announces as
* its href.
*/
const siteFooterIconLinkClass =
"inline-flex size-8 shrink-0 items-center justify-center rounded-md outline-none transition-colors pointer-coarse:size-11 hover:bg-neutral-500/10 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring-accent [&_svg]:size-4 [&_svg]:shrink-0"
/**
* The family attribution: "{SUBDOMAIN}.MD was built by Matt Downey using
* UI.MD". `name` is the site's own wordmark name; when the site IS UI.MD the
* "using" clause is dropped rather than self-referencing. Rendered as a span
* so it composes into the footer's single-line prose. Override via
* SiteFooter's `attribution` prop only when a site genuinely needs
* different credits.
*/
function SiteFooterAttribution({ name = "UI" }: { name?: string }) {
const isUi = name.toUpperCase() === "UI"
return (
<span>
<Brand name={name} /> was built by{" "}
<a
href="https://mattdowney.com"
target="_blank"
rel="noreferrer"
className={siteFooterInlineLinkClass}
>
Matt Downey
</a>
{isUi ? null : (
<>
{" "}
using{" "}
<a
href="https://ui.mattdowney.com"
className={siteFooterInlineLinkClass}
>
<Brand />
</a>
</>
)}
</span>
)
}
/**
* A horizontal run inside the footer: the copyright and links strip, a row of
* social icons, small print, or the top band of a multi-column footer holding
* the brand and its columns.
*
* Wraps by default, so the same row stacks on a narrow viewport without a
* breakpoint. `justify` spreads its children apart by default, which is what a
* footer row nearly always wants; pass `"start"` or `"end"` for a run that
* should stay packed to one side.
*/
function SiteFooterRow({
justify = "between",
divider = false,
className,
children,
}: {
justify?: "between" | "start" | "end"
/**
* A hairline above the row, for the legal bar under a tall footer. The line
* lands in the shell's own `gap-8` and the row's padding matches it, so the
* rule sits evenly between the two zones instead of hugging one of them.
*/
divider?: boolean
className?: string
children?: React.ReactNode
}) {
return (
<div
data-slot="site-footer-row"
className={cn(
"flex flex-wrap items-center gap-x-6 gap-y-2",
justify === "between" && "justify-between",
justify === "start" && "justify-start",
justify === "end" && "justify-end",
divider && "border-t border-border pt-8",
className
)}
>
{children}
</div>
)
}
/**
* The column region: two or more SiteFooterColumns laid on a grid, not packed
* into a flex row.
*
* A wrapping flex row was the first attempt and it is subtly wrong here. Flex
* sizes each column to its own content, so "Product / Components / Tokens" and
* "Company / About" come out different widths and nothing lines up; with
* `justify-between` they also drift to the far edges and leave a hole in the
* middle. Columns are a table of contents, and a table of contents wants equal
* tracks.
*
* Two columns below `sm` so labels are not squeezed to one word per line, then
* `grid-flow-col` with `auto-cols-fr`, which gives one equal track per child
* whatever the count. No column-count prop and no arbitrary track template:
* add a column and the grid absorbs it.
*/
function SiteFooterColumns({
className,
children,
}: {
className?: string
children?: React.ReactNode
}) {
return (
<div
data-slot="site-footer-columns"
className={cn(
"grid grid-cols-2 gap-x-10 gap-y-8 sm:grid-flow-col sm:auto-cols-fr",
className
)}
>
{children}
</div>
)
}
/**
* The brand block: a wordmark or logo, optionally over a line of supporting
* text. Sits at the start of a multi-column footer's top row.
*/
function SiteFooterBrand({
className,
children,
}: {
className?: string
children?: React.ReactNode
}) {
return (
<div
data-slot="site-footer-brand"
className={cn("flex flex-col items-start gap-2", className)}
>
{children}
</div>
)
}
/**
* A titled column of links: the unit a multi-column footer repeats. Children
* are the site's own router links carrying `siteFooterNavItemClass`.
*
* The title reads as the kit's group label, the same treatment NavLabel and the
* table headers use: uppercase, `tracking-wide`, semibold, in `text-foreground`
* over muted links. It was sentence-case `text-1xs` first, which put the
* heading a step SMALLER than the links under it and read as a mistake. A label
* is not small body text; it is differentiated by case, weight, and colour, so
* it can sit below the links in size without losing the hierarchy.
*
* The negative inline margin is the reason the column looks flush. Nav items
* carry `px-2` for their hit area, so without it every link would sit 8px
* indented from everything else in the footer while the title sat flush. The
* title takes the same `px-2` so the two edges agree.
*/
function SiteFooterColumn({
title,
className,
children,
}: {
title?: React.ReactNode
className?: string
children?: React.ReactNode
}) {
return (
<div
data-slot="site-footer-column"
className={cn("-mx-2 flex min-w-0 flex-col items-start", className)}
>
{title ? (
<p className="px-2 pb-2 text-2xs font-semibold tracking-wide text-foreground uppercase">
{title}
</p>
) : null}
{children}
</div>
)
}
/**
* The shared site chrome shell for mattdowney.com subdomain projects: a top
* hairline, the vertical rhythm, and the chrome width and gutter tokens
* (`--container-chrome`, `--spacing-chrome-gutter`) that the matching
* SiteHeader reads too, so the two ends of the page line up by construction
* rather than by both hardcoding the same literals.
*
* Two ways in, and they do not mix. Pass `children` composed from
* SiteFooterRow, SiteFooterBrand, and SiteFooterColumn to build any footer,
* including a multi-column one. Pass nothing and it renders the original
* single-row strip from `name` / `attribution` / `nav`: the attribution and
* copyright on one side, the site's links on the other. `children` wins, and
* the strip props are then ignored.
*
* Server-safe: no state here. Sites with client concerns (auth-aware links,
* dialogs) compose them into `children` from their own client wrapper.
*
* Mounting construct: place inside a `flex min-h-dvh flex-col` page column
* and pass `className="mt-auto"` so short pages pin the footer to the
* viewport bottom rather than letting it float mid-screen. Tall pages are
* indistinguishable from normal flow.
*/
function SiteFooter({
name,
attribution,
nav,
align = "start",
className,
children,
}: {
/** The site's wordmark name for the default attribution, e.g. "SKILLS". */
name?: string
attribution?: React.ReactNode
nav?: React.ReactNode
/**
* `"center"` stacks the whole footer down the middle: the archetype for a
* small site or a single project page, where a start-aligned footer with one
* short link row looks abandoned in the left corner.
*
* It reaches the parts through a `css` rule keyed on this attribute, not
* through React context, because context would make SiteFooter a client
* component and the whole point of this footer is that it renders on the
* server. Alignment is presentation, so CSS is where it belongs anyway.
*/
align?: "start" | "center"
className?: string
children?: React.ReactNode
}) {
const year = new Date().getFullYear()
return (
<footer
data-slot="site-footer"
data-align={align}
className={cn("border-t border-border", className)}
>
<div className="mx-auto flex w-full max-w-chrome flex-col gap-8 px-chrome-gutter py-8 text-sm text-muted-foreground">
{children ?? (
<SiteFooterRow>
<p>
{attribution ?? <SiteFooterAttribution name={name} />} · © {year}
</p>
{nav ? (
<nav className="flex flex-wrap items-center gap-x-5 gap-y-2">
{nav}
</nav>
) : null}
</SiteFooterRow>
)}
</div>
</footer>
)
}
export {
SiteFooter,
SiteFooterAttribution,
SiteFooterBrand,
SiteFooterColumn,
SiteFooterColumns,
SiteFooterRow,
siteFooterNavItemClass,
siteFooterInlineLinkClass,
siteFooterIconLinkClass,
}