A site header with a brand slot, navigation links and optional actions; once the header itself is at least `64rem` wide the links sit inline, and narrower a menu button opens them in a right-hand Drawer.

pnpm add @behivetech/organisms.site-header

This is the snapshot of v0.1.6, the current version, as recorded on 2026-11-10. The live page runs its examples →

registry/behivetech/organisms/site-header at v0.1.6, snapshotted 2026-11-10

site-header.tsx
1import {
2 type AnchorHTMLAttributes,
3 type ElementType,
4 type HTMLAttributes,
5 type ReactNode,
6 Suspense,
7 lazy,
8 useEffect,
9 useRef,
10 useState,
11} from "react";
12import { getClassName } from "@behivetech/get-class-name";
13import { Button } from "@behivetech/atoms.button";
14import { MenuIcon } from "@behivetech/icons.menu";
15import { NavItem } from "@behivetech/atoms.nav-item";
16import styles from "./site-header.module.scss";
17
18/** Loads the Drawer module; repeat calls share the one request. */
19const loadDrawer = () =>
20 import("@behivetech/atoms.drawer").then((module) => ({
21 default: module.Drawer,
22 }));
23
24// The Drawer (Radix Dialog and its focus handling) is about 10 KB gzipped and
25// opens only after a tap at phone width, so it is fetched on demand: ahead of
26// the tap when the reader's pointer or focus reaches the menu button, and at
27// the latest when the button is pressed.
28const Drawer = lazy(loadDrawer);
29
30/** Starts the request ahead of the tap; a failure here is retried by the tap itself. */
31const prefetchDrawer = () => {
32 loadDrawer().catch(() => undefined);
33};
34
35/** One navigation link of the header. */
36export interface SiteHeaderLink {
37 /** Destination of the link */
38 href: string;
39 /** Visible text of the link, already translated */
40 label: string;
41 /** Marks the link for the current page: renders `aria-current="page"` */
42 current?: boolean;
43}
44
45/** Props the header passes to `linkComponent` for every link. */
46export type SiteHeaderLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
47 href: string;
48};
49
50/** Props of the `SiteHeader` component. */
51export interface SiteHeaderProps extends Omit<
52 HTMLAttributes<HTMLElement>,
53 "children"
54> {
55 /** Additional class names to merge with the component root element */
56 className?: string;
57 /** Brand slot at the start of the bar; rendered as given, so pass your own home link */
58 brand: ReactNode;
59 /** Navigation links: inline once the header itself is at least `64rem` wide (a container query), in a Drawer when narrower */
60 links: SiteHeaderLink[];
61 /** Optional actions that stay in the bar at every breakpoint, e.g. a booking button */
62 actions?: ReactNode;
63 /**
64 * Component that renders each link, so a router link such as Next's `Link`
65 * can be injected. Receives `href`, `children` and the item's props. Defaults to `a`.
66 */
67 linkComponent?: ElementType<SiteHeaderLinkProps>;
68 /** Accessible name of the menu button, the navigation landmarks and the Drawer title, already translated */
69 menuLabel: string;
70}
71
72interface HeaderLinksProps {
73 links: SiteHeaderLink[];
74 linkComponent: ElementType<SiteHeaderLinkProps>;
75 onNavigate?: () => void;
76}
77
78const HeaderLinks = ({
79 links,
80 linkComponent: LinkComponent,
81 onNavigate,
82}: HeaderLinksProps) => (
83 <>
84 {links.map(({ href, label, current }) => (
85 <NavItem
86 asChild
87 key={`${href}:${label}`}
88 active={current}
89 onClick={onNavigate}
90 >
91 <LinkComponent href={href}>{label}</LinkComponent>
92 </NavItem>
93 ))}
94 </>
95);
96
97interface MenuDrawerProps {
98 links: SiteHeaderLink[];
99 linkComponent: ElementType<SiteHeaderLinkProps>;
100 menuLabel: string;
101 navClassName: string;
102 onClose: () => void;
103}
104
105/** The Drawer that holds the links while the header is narrow; nothing shows while its code loads. */
106const MenuDrawer = ({
107 links,
108 linkComponent,
109 menuLabel,
110 navClassName,
111 onClose,
112}: MenuDrawerProps) => (
113 <Suspense fallback={null}>
114 <Drawer
115 placement="right"
116 title={menuLabel}
117 headingLevel={2}
118 padding="sm"
119 onClose={onClose}
120 >
121 <nav aria-label={menuLabel} className={navClassName}>
122 <HeaderLinks
123 links={links}
124 linkComponent={linkComponent}
125 onNavigate={onClose}
126 />
127 </nav>
128 </Drawer>
129 </Suspense>
130);
131
132interface HeaderMenuProps {
133 links: SiteHeaderLink[];
134 linkComponent: ElementType<SiteHeaderLinkProps>;
135 menuLabel: string;
136}
137
138/**
139 * Returns a ref for the menu button and puts focus back on it once the Drawer
140 * closes. The Drawer unmounts with its Radix Dialog, so Radix has no trigger
141 * to hand focus back to.
142 */
143const useReturnFocus = (isOpen: boolean) => {
144 const menuButtonRef = useRef<HTMLElement | null>(null);
145
146 useEffect(() => {
147 if (!isOpen) {
148 menuButtonRef.current?.focus();
149 }
150 }, [isOpen]);
151
152 return menuButtonRef;
153};
154
155/** Menu button and the Drawer it opens; shown only while the header is narrow. */
156const HeaderMenu = ({ links, linkComponent, menuLabel }: HeaderMenuProps) => {
157 const [isMenuOpen, setIsMenuOpen] = useState(false);
158 const [, getChildClass] = getClassName({
159 rootClass: "BHT__SiteHeader",
160 styles,
161 });
162 const menuButtonRef = useReturnFocus(isMenuOpen);
163
164 const closeMenu = () => setIsMenuOpen(false);
165
166 return (
167 <div className={getChildClass("menu")}>
168 <Button
169 variant="ghost"
170 size="lg"
171 iconOnly
172 aria-label={menuLabel}
173 aria-haspopup="dialog"
174 aria-expanded={isMenuOpen}
175 onPointerEnter={prefetchDrawer}
176 onFocus={prefetchDrawer}
177 onClick={(event) => {
178 const button = event.currentTarget;
179
180 // The menu counts as open (and `aria-expanded` turns true) only once
181 // the Drawer's code is here, so assistive technology is never told
182 // about a dialog that is not there yet. A failed request leaves the
183 // menu closed, and the next tap asks again.
184 loadDrawer()
185 .then(() => {
186 menuButtonRef.current = button;
187 setIsMenuOpen(true);
188 })
189 .catch(() => undefined);
190 }}
191 >
192 <MenuIcon />
193 </Button>
194 {isMenuOpen ? (
195 <MenuDrawer
196 links={links}
197 linkComponent={linkComponent}
198 menuLabel={menuLabel}
199 navClassName={getChildClass("drawer-nav")}
200 onClose={closeMenu}
201 />
202 ) : null}
203 </div>
204 );
205};
206
207/**
208 * Site header with a brand slot, navigation links and optional actions. While
209 * the header is at least `64rem` wide the links sit inline; narrower, a menu
210 * button opens them in a right-hand `Drawer`, which keeps the links in reach of
211 * one thumb on a phone. The switch follows the header's own width (a container
212 * query), not the viewport.
213 */
214export const SiteHeader = ({
215 className,
216 brand,
217 links,
218 actions,
219 linkComponent = "a",
220 menuLabel,
221 ...rest
222}: SiteHeaderProps) => {
223 const [rootClass, getChildClass] = getClassName({
224 className,
225 rootClass: "BHT__SiteHeader",
226 styles,
227 });
228
229 return (
230 <header {...rest} className={rootClass}>
231 <div className={getChildClass("bar")}>
232 <div className={getChildClass("brand")}>{brand}</div>
233 <div className={getChildClass("end")}>
234 <nav aria-label={menuLabel} className={getChildClass("nav")}>
235 <HeaderLinks links={links} linkComponent={linkComponent} />
236 </nav>
237 {actions ? (
238 <div className={getChildClass("actions")}>{actions}</div>
239 ) : null}
240 <HeaderMenu
241 links={links}
242 linkComponent={linkComponent}
243 menuLabel={menuLabel}
244 />
245 </div>
246 </div>
247 </header>
248 );
249};
site-header.module.scss
1@use "@behivetech/cms.base-styles/breakpoints.scss" as breakpoints;
2
3// Surface bar with a hairline divider. The root is a named inline-size
4// container, so the links go inline once the header itself is 64rem wide and
5// the menu button opens them in a Drawer below that. A container cannot style
6// itself, so the layout lives on the inner `__bar`.
7.BHT__SiteHeader {
8 container: site-header / inline-size;
9 background: var(--md-sys-color-surface);
10 color: var(--md-sys-color-on-surface);
11 border-bottom: 1px solid var(--md-sys-color-outline-variant);
12
13 &__bar {
14 display: flex;
15 align-items: center;
16 justify-content: space-between;
17 gap: var(--spacing-4, 1rem);
18 min-height: 4rem;
19 padding: var(--spacing-2, 0.5rem) var(--spacing-4, 1rem);
20 }
21
22 &__brand {
23 display: flex;
24 align-items: center;
25 min-width: 0;
26 }
27
28 &__end {
29 display: flex;
30 align-items: center;
31 gap: var(--spacing-2, 0.5rem);
32 }
33
34 &__nav {
35 display: none;
36 }
37
38 &__actions {
39 display: flex;
40 align-items: center;
41 }
42
43 &__menu {
44 display: flex;
45 }
46
47 // Full-width stacked items; the gap keeps their 48px touch targets apart.
48 &__drawer-nav {
49 display: flex;
50 flex-direction: column;
51 gap: var(--spacing-2, 0.5rem);
52 }
53
54 // A container query on the header's own width, not a media query (base-styles'
55 // `lg` mixin): the header can sit in a narrow column of a wide page. The
56 // threshold is the shared `$breakpoint-lg`. It sits last so it wins over the
57 // narrow rules of the same elements.
58 @container site-header (min-width: #{breakpoints.$breakpoint-lg}) {
59 &__bar {
60 padding-inline: var(--spacing-8, 2rem);
61 }
62
63 &__end {
64 gap: var(--spacing-6, 1.5rem);
65 }
66
67 &__nav {
68 display: flex;
69 align-items: center;
70 gap: var(--spacing-1, 0.25rem);
71 }
72
73 &__menu {
74 display: none;
75 }
76 }
77}
index.ts
1export { SiteHeader } from "./site-header.js";
2export type {
3 SiteHeaderLink,
4 SiteHeaderLinkProps,
5 SiteHeaderProps,
6} from "./site-header.js";
site-header.example.tsx
1import { Button } from "@behivetech/atoms.button";
2import { SiteHeader } from "./site-header.js";
3
4const LINKS = [
5 { href: "/guide", label: "Guide", current: true },
6 { href: "/gallery", label: "Gallery" },
7 { href: "/art", label: "Art" },
8 { href: "/artists", label: "Artists" },
9];
10
11/** Brand, four links and no actions. Narrow the header below 64rem to get the menu button and Drawer. */
12export const BasicSiteHeader = () => (
13 <SiteHeader
14 brand={<a href="/">The BEhive</a>}
15 links={LINKS}
16 menuLabel="Menu"
17 />
18);
19
20/** An action stays in the bar at every breakpoint. */
21export const SiteHeaderWithActions = () => (
22 <SiteHeader
23 brand={<a href="/">The BEhive</a>}
24 links={LINKS}
25 menuLabel="Menu"
26 actions={<Button size="sm">Book direct</Button>}
27 />
28);