Site footer with a brand, grouped navigation links, free content and a legal line; the groups stack on small screens and sit in columns from the medium breakpoint.

pnpm add @behivetech/organisms.site-footer

registry/behivetech/organisms/site-footer · depends on @behivetech/atoms.nav-item

site-footer.tsx
1import {
2 type AnchorHTMLAttributes,
3 type ElementType,
4 type HTMLAttributes,
5 type ReactNode,
6} from "react";
7import { getClassName } from "@behivetech/get-class-name";
8import { NavItem } from "@behivetech/atoms.nav-item";
9import styles from "./site-footer.module.scss";
10
11/** One link in a footer group. */
12export interface SiteFooterLink {
13 /** Link text */
14 label: string;
15 /** Destination URL */
16 href: string;
17}
18
19/** Props the footer passes to `linkComponent` for every link. */
20export type SiteFooterLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
21 href: string;
22};
23
24/** A titled list of links. */
25export interface SiteFooterGroup {
26 /** Group heading; rendered as an h2 */
27 title: string;
28 /** Links in the group; a group with none renders nothing */
29 links: SiteFooterLink[];
30}
31
32/** Props of the `SiteFooter` component. */
33export interface SiteFooterProps extends HTMLAttributes<HTMLElement> {
34 /** Additional class names to merge with the component root element */
35 className?: string;
36 /** Brand mark, such as a wordmark or logo, shown in the lead column */
37 brand?: ReactNode;
38 /** Link groups. Groups without links, and an empty list, render no heading */
39 groups?: SiteFooterGroup[];
40 /** Legal line shown in its own row under the groups, such as a copyright */
41 legal?: ReactNode;
42 /**
43 * Component that renders each link, so a router link such as Next's `Link`
44 * can be injected. Receives `href`, `children` and NavItem's props (merged
45 * through `asChild`); pass them all through to the anchor, or the link loses
46 * NavItem's styling and hit area. Defaults to `a`.
47 */
48 linkComponent?: ElementType<SiteFooterLinkProps>;
49 /** Free content rendered under the brand, such as contact details */
50 children?: ReactNode;
51}
52
53/**
54 * Site footer with a brand, grouped links, free content and a legal line.
55 * Groups stack on small screens and sit in columns from the medium breakpoint;
56 * the links of a group wrap in a row, each in a 44px-tall tap target.
57 */
58export const SiteFooter = ({
59 className,
60 brand,
61 groups = [],
62 legal,
63 linkComponent: LinkComponent = "a",
64 children,
65 ...rest
66}: SiteFooterProps) => {
67 const [rootClass, getChildClass] = getClassName({
68 className,
69 rootClass: "BHT__SiteFooter",
70 styles,
71 });
72 const visibleGroups = groups.filter((group) => group.links.length > 0);
73 const hasLead = Boolean(brand) || Boolean(children);
74
75 return (
76 <footer {...rest} className={rootClass}>
77 <div className={getChildClass("body")}>
78 {hasLead ? (
79 <div className={getChildClass("lead")}>
80 {brand ? (
81 <div className={getChildClass("brand")}>{brand}</div>
82 ) : null}
83 {children ? (
84 <div className={getChildClass("content")}>{children}</div>
85 ) : null}
86 </div>
87 ) : null}
88 {visibleGroups.map((group, index) => (
89 <section
90 key={`${index}-${group.title}`}
91 className={getChildClass("group")}
92 >
93 <h2 className={getChildClass("heading")}>{group.title}</h2>
94 <ul className={getChildClass("links")}>
95 {group.links.map((link) => (
96 <li
97 key={`${link.href}:${link.label}`}
98 className={getChildClass("item")}
99 >
100 <NavItem asChild>
101 <LinkComponent href={link.href}>{link.label}</LinkComponent>
102 </NavItem>
103 </li>
104 ))}
105 </ul>
106 </section>
107 ))}
108 </div>
109 {legal ? <div className={getChildClass("legal")}>{legal}</div> : null}
110 </footer>
111 );
112};
site-footer.module.scss
1@use "@behivetech/cms.base-styles/breakpoints.scss" as breakpoints;
2
3// Material Design 3 footer band. Mobile first: the lead and the groups stack in
4// one column, and the links of a group flow in a wrapping row, so a short list
5// sits on one line instead of a tall stack. Every link keeps a 2.75rem row
6// (44px at the default size) as its tap target; the spacing between links is
7// small, because the row height already separates the targets. From `md`
8// (`breakpoints.md`, the registry medium breakpoint) the lead and the groups sit
9// side by side, and wrap when there are too many for one line; the links of a
10// group go back to a column.
11.BHT__SiteFooter {
12 display: flex;
13 flex-direction: column;
14 gap: var(--spacing-6, 1.5rem);
15 padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem);
16 background: var(--md-sys-color-surface-container);
17 color: var(--md-sys-color-on-surface-variant);
18
19 &__body {
20 display: flex;
21 flex-direction: column;
22 gap: var(--spacing-4, 1rem);
23 }
24
25 &__lead {
26 display: flex;
27 flex-direction: column;
28 gap: var(--spacing-3, 0.75rem);
29 }
30
31 &__group {
32 display: flex;
33 flex-direction: column;
34 gap: var(--spacing-1, 0.25rem);
35 min-width: 0;
36 }
37
38 &__heading {
39 margin: 0;
40 color: var(--md-sys-color-secondary);
41 font-family: var(--md-sys-typescale-title-small-font);
42 font-size: var(--md-sys-typescale-title-small-size);
43 font-weight: var(--md-sys-typescale-title-small-weight);
44 line-height: var(--md-sys-typescale-title-small-line-height);
45 letter-spacing: var(--md-sys-typescale-title-small-tracking);
46 }
47
48 // The list bleeds one spacing step into the gutter so NavItem labels line up
49 // with the brand, headings and legal line on a single text edge.
50 &__links {
51 display: flex;
52 flex-flow: row wrap;
53 gap: 0 var(--spacing-1, 0.25rem);
54 margin: 0 calc(-1 * var(--spacing-3, 0.75rem));
55 padding: 0;
56 list-style: none;
57 }
58
59 // The row is the tap target: NavItem draws 2.25rem, and this centres it in a
60 // 2.75rem row, so the gap between links is the row's own padding.
61 &__item {
62 display: flex;
63 align-items: center;
64 min-height: 2.75rem;
65 min-width: 0;
66 }
67
68 &__legal {
69 padding-top: var(--spacing-4, 1rem);
70 border-top: 1px solid var(--md-sys-color-outline-variant);
71 font-family: var(--md-sys-typescale-body-small-font);
72 font-size: var(--md-sys-typescale-body-small-size);
73 font-weight: var(--md-sys-typescale-body-small-weight);
74 line-height: var(--md-sys-typescale-body-small-line-height);
75 letter-spacing: var(--md-sys-typescale-body-small-tracking);
76 }
77
78 @include breakpoints.md {
79 padding-inline: var(--spacing-8, 2rem);
80
81 &__body {
82 flex-flow: row wrap;
83 align-items: flex-start;
84 gap: var(--spacing-6, 1.5rem) var(--spacing-8, 2rem);
85 }
86
87 // Shares the row with the groups but gives way first, and never grows past
88 // a readable measure.
89 &__lead {
90 flex: 1 1 14rem;
91 max-width: 20rem;
92 }
93
94 &__group {
95 flex: 1 1 10rem;
96 }
97
98 // From `md` the links of a group stack again, each in its 2.75rem row.
99 &__links {
100 flex-direction: column;
101 align-items: flex-start;
102 }
103 }
104}
index.ts
1export { SiteFooter } from "./site-footer.js";
2export type {
3 SiteFooterGroup,
4 SiteFooterLink,
5 SiteFooterLinkProps,
6 SiteFooterProps,
7} from "./site-footer.js";
site-footer.example.tsx
1import { SiteFooter } from "./site-footer.js";
2
3/** A footer with a brand, three link groups, contact content and a legal line. */
4export const BasicSiteFooter = () => (
5 <SiteFooter
6 brand="The BEhive"
7 legal="© 2026 The BEhive"
8 groups={[
9 {
10 title: "Your stay",
11 links: [
12 { label: "House guide", href: "#guide" },
13 { label: "Wifi", href: "#wifi" },
14 ],
15 },
16 { title: "Art", links: [{ label: "The artists", href: "#artists" }] },
17 ]}
18 >
19 <a href="mailto:hello@example.com">hello@example.com</a>
20 </SiteFooter>
21);
22
23/** Without groups the footer shows only the brand and the legal line. */
24export const BrandOnlySiteFooter = () => (
25 <SiteFooter brand="The BEhive" legal="© 2026 The BEhive" />
26);
site-footer.spec.tsx
1import { type ReactNode } from "react";
2import { render, screen } from "@testing-library/react";
3import { SiteFooter } from "./site-footer.js";
4
5const GROUPS = [
6 {
7 title: "Your stay",
8 links: [
9 { label: "House guide", href: "/guide" },
10 { label: "Wifi", href: "/wifi" },
11 ],
12 },
13 { title: "Art", links: [{ label: "The artists", href: "/artists" }] },
14];
15
16describe("SiteFooter", () => {
17 it("exposes the contentinfo landmark", () => {
18 render(<SiteFooter groups={GROUPS} />);
19 expect(screen.getByRole("contentinfo")).toBeTruthy();
20 });
21
22 it("renders a heading and links for each group", () => {
23 render(<SiteFooter groups={GROUPS} />);
24 expect(screen.getByRole("heading", { name: "Your stay" })).toBeTruthy();
25 expect(screen.getByRole("heading", { name: "Art" })).toBeTruthy();
26 expect(
27 screen.getByRole("link", { name: "Wifi" }).getAttribute("href"),
28 ).toBe("/wifi");
29 });
30
31 it("renders brand, children and legal", () => {
32 render(
33 <SiteFooter brand="BEhive" legal="© 2026">
34 hello@example.com
35 </SiteFooter>,
36 );
37 expect(screen.getByText("BEhive")).toBeTruthy();
38 expect(screen.getByText("hello@example.com")).toBeTruthy();
39 expect(screen.getByText("© 2026")).toBeTruthy();
40 });
41
42 it("renders no heading when groups is empty", () => {
43 render(<SiteFooter groups={[]} brand="BEhive" />);
44 expect(screen.queryByRole("heading")).toBeNull();
45 });
46
47 it("renders no heading for a group with no links", () => {
48 render(<SiteFooter groups={[{ title: "Empty", links: [] }, ...GROUPS]} />);
49 expect(screen.queryByRole("heading", { name: "Empty" })).toBeNull();
50 expect(screen.getAllByRole("heading")).toHaveLength(2);
51 });
52
53 it("applies additional className", () => {
54 render(<SiteFooter className="custom" />);
55 expect(screen.getByRole("contentinfo").className).toContain("custom");
56 });
57
58 it("forwards linkComponent to every link", () => {
59 const CustomLink = ({
60 href,
61 className,
62 children,
63 }: {
64 href?: string;
65 className?: string;
66 children?: ReactNode;
67 }) => (
68 <a data-custom="true" href={href} className={className}>
69 {children}
70 </a>
71 );
72 render(<SiteFooter groups={GROUPS} linkComponent={CustomLink} />);
73 const links = screen.getAllByRole("link");
74 expect(links).toHaveLength(3);
75 links.forEach((link) => {
76 expect(link).toHaveAttribute("data-custom", "true");
77 expect(link.className).toContain("BHT__NavItem");
78 });
79 });
80});