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, SiteFooterBrand, SiteFooterColumn, SiteFooterColumns,
SiteFooterFinePrint, 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.
Inside a padded page container
The two props for a footer that is not mounted full width. bleed moves the top rule onto a full-width child that escapes the page container's padding, so it meets the page's real edges. Extra height goes in contentClassName: className lands on the outer box and never reaches the padding that sets it.
Page title
The page column pads itself, and the footer sits inside it.
Without the kit's credit
attribution={false} drops the "built by Matt Downey using UI.MD" line and keeps the copyright, which is what a product with its own company name wants. The separator goes with the credit, so the line never starts with a stray one. Pass a node instead of false to substitute your own credits and keep the year.
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, SiteFooterFinePrint, 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, SiteFooterFinePrint | ReactNode | false | - | Replaces the default credits. The copyright still follows it, so restating credits never drops the year. Pass false to drop the credits entirely and leave the copyright on its own, which is what a product outside the mattdowney.com family wants: the separator goes with them, so you never get a line starting with a stray ·. |
| 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. |
| bleedSiteFooter | boolean | string | false | Where the top rule is drawn. The default is the full-width mount: the border sits on the <footer>, which is correct when the footer already spans the viewport. Set it for a footer inside a page container that pads itself, and the rule moves to a full-width child that escapes the padding by a negative inline margin, so it meets the page's real edges while nothing in the footer moves. It stays inside the <footer> either way. true reads the distance from --spacing-chrome-bleed (which a responsive container redeclares at its own breakpoint); a string states it directly. |
| containerSiteFooter | boolean | true | Whether the shell builds its own centred container. On by default, which is right when the footer spans the viewport. Set it to false when the footer already sits inside a page container that centres and pads, or the two nest and the footer's contents sit inboard of the rest of the page by a full gutter. Doing it by neutralising --container-chrome and --spacing-chrome-gutter instead is global and moves every shell on the site; this is per-instance. The shell still owns the surface, the edge, and the content box's padding and gap. |
| classNameSiteFooter, SiteFooterRow, SiteFooterBrand, SiteFooterColumn, SiteFooterColumns | string | - | On <SiteFooter /> it is the outer box: border, background, page-level margin. Pass mt-auto there when the page is a flex min-h-dvh flex-col column. Padding and type are contentClassName, not this. |
| contentClassNameSiteFooter | string | - | The inner content box: padding, gap, type. This is where a footer taller than the default py-8 goes, since className never reaches it. Merged after the built-in classes, so it wins. |
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"
/* The current page, the DEFAULT active treatment: quiet accent text, no fill.
A footer is a low-emphasis, terminal zone, so the "you are here" marker is
the accent label alone (text-accent, the 500 rung, in both modes) plus a
touch of weight. It deliberately does NOT lift to accent-400 on dark the way
the header and mobile nav do for accent text: those sit in a bright,
high-attention bar, while the footer wants the deeper, quieter rung so the
marker sits back. A filled pill here reads as the loudest thing in a quiet
footer and mimics a CTA, so the fill is not the default; reach for
siteFooterNavItemChipClass below only where a louder footer is wanted. No
hover:text-foreground: the current page should not dim to the rest colour
under the pointer. */
const siteFooterNavItemActiveClass =
"inline-flex items-center rounded-sm px-2 py-1.5 font-medium text-accent outline-none transition-colors focus-visible:ring-1 focus-visible:ring-ring-accent"
/* The current page as an accent CHIP: the header Nav's active item exactly, the
same tint, label, and pill radius (bg-accent-100/800, text-accent/accent-400,
rounded-action). Opt in only where a footer is meant to carry the header's
emphasis; the quiet class above is the right default for most footers. */
const siteFooterNavItemChipClass =
"inline-flex items-center rounded-action px-2 py-1.5 font-medium text-accent dark:text-accent-400 bg-accent-100 dark:bg-accent-800 outline-none transition-colors 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>
)
}
/**
* The credits line: attribution, then the copyright. This is a PART rather
* than markup inlined in the default footer, because the copyright is the
* thing a composed footer forgets. It used to live only inside the default
* branch below, so the moment a site passed `children` it had to retype
* "· © {year}" by hand, and of this repo's own two composed demos one forgot
* it entirely while the other retyped it into a flex row, where the row's
* `gap-x-6` opened 24px in front of the separator.
*
* It renders a `<p>`, which is the other half of that bug. This is prose, so
* the separator has to be a text node in a text flow. Dropped straight into a
* `SiteFooterRow`, the attribution span and the " · © 2026" text become two
* flex items and the row spaces them apart.
*
* `attribution` overrides the credits half only; the copyright always follows,
* which is why a site can restate its credits without losing the year.
*
* `attribution={false}` DROPS the credits entirely and leaves the copyright
* alone. The default line names Matt Downey and UI.MD, which is right for the
* mattdowney.com family and wrong for anyone else, and a product with its own
* company name previously had to delete this function to be rid of it: that
* forks the file, so the next `shadcn add` either reverts the deletion or
* conflicts with it. The separator goes with it, because " · © 2026" with a
* leading separator is the bug you get from rendering an empty credits half.
*/
function SiteFooterFinePrint({
name,
attribution,
className,
}: {
/** The site's wordmark name, passed through to the default attribution. */
name?: string
/**
* Replaces the default credits. The copyright still follows it.
* Pass `false` to drop the credits and show the copyright on its own.
*/
attribution?: React.ReactNode | false
className?: string
}) {
const year = new Date().getFullYear()
const credits =
attribution === false ? null : (attribution ?? <SiteFooterAttribution name={name} />)
return (
<p data-slot="site-footer-fine-print" className={className}>
{credits}
{credits ? " · " : null}© {year}
</p>
)
}
/**
* 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 `peer` gap and the row's padding reads the SAME
* rung, so the rule sits evenly between the two zones instead of hugging one
* of them. Re-derive this if the shell's gap ever changes rung: the two are
* one measurement written twice, and they only look right while they match.
*/
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-(--spacing-rhythm-peer)",
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-(--spacing-rhythm-peer) 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.
*
* Two className props, and guessing wrong is quiet rather than loud.
* `className` is the OUTER box: the border, the background, page-level margin.
* `contentClassName` is the CONTENT box: padding, gap, type. A footer taller
* than the default `py-8` is a `contentClassName`.
*/
// @use-when the shared site chrome footer, including its current-page marker.
function SiteFooter({
name,
attribution,
nav,
align = "start",
bleed = false,
container = true,
className,
contentClassName,
children,
}: {
/** The site's wordmark name for the default attribution, e.g. "SKILLS". */
name?: string
/**
* Replaces the default "built by Matt Downey using UI.MD" credits. Pass
* `false` to drop them and keep only the copyright, which is what a product
* outside the mattdowney.com family wants.
*/
attribution?: React.ReactNode | false
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"
/**
* For a footer mounted INSIDE a page container that already applies its own
* horizontal padding, where the top rule must still meet the page's real
* edges. The default `false` is the full-width mount: the border is drawn on
* the `<footer>`, which is correct when the footer already spans the
* viewport.
*
* Set it and the rule moves to a full-width child that escapes the container
* with a negative inline margin, so it runs past the padding while nothing
* in the footer moves. `true` reads the distance from
* `--spacing-chrome-bleed`; pass a length (`"2rem"`,
* `"var(--page-gutter)"`) to state it directly.
*
* The rule stays inside the `<footer>` either way. It is part of the footer,
* so moving it to a sibling element would take it out of the landmark to buy
* nothing.
*/
bleed?: boolean | string
/**
* Whether the shell builds its own centred container. On by default, which
* is right when the footer spans the viewport.
*
* Set it to `false` when the footer is already inside a page container that
* centres and pads, or you get two containers nested and the footer's
* contents sit inboard of the rest of the page by a full gutter. Doing it by
* neutralising `--container-chrome` and `--spacing-chrome-gutter` instead is
* GLOBAL and moves every shell on the site; this is per-instance.
*
* Off, the shell still owns the surface, the edge, and the content box's
* padding and gap, and still spans its parent. The caller owns width and
* gutter. Orthogonal to `bleed`, which moves the EDGE rather than declining
* the container.
*/
container?: boolean
/** The outer `<footer>`: border, background, page-level margin. */
className?: string
/** The inner content box: padding, gap, type. Wins over the defaults. */
contentClassName?: string
children?: React.ReactNode
}) {
const bleedWidth =
typeof bleed === "string" ? bleed : "var(--spacing-chrome-bleed, 0rem)"
const bledEdge = Boolean(bleed)
return (
<footer
data-slot="site-footer"
data-align={align}
className={cn(
bledEdge ? "relative" : "border-t border-border",
className
)}
>
{bledEdge ? (
// The edge, drawn on its own box instead of on the `<footer>`, and
// still inside it. Absolutely positioned so it takes no height and
// cannot shift the content, then pulled out past the page container's
// padding by a negative inline margin: `inset-x-0` pins it to both
// edges and the margins move those pins outward.
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-border"
style={{ marginInline: `calc(-1 * ${bleedWidth})` }}
/>
) : null}
<div
className={cn(
"flex w-full flex-col gap-(--spacing-rhythm-peer) py-8 text-sm text-muted-foreground",
container && "mx-auto max-w-chrome px-chrome-gutter",
contentClassName
)}
>
{children ?? (
<SiteFooterRow>
<SiteFooterFinePrint name={name} attribution={attribution} />
{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,
SiteFooterFinePrint,
SiteFooterRow,
siteFooterNavItemClass,
siteFooterNavItemActiveClass,
siteFooterNavItemChipClass,
siteFooterInlineLinkClass,
siteFooterIconLinkClass,
}