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.
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";1718/** Loads the Drawer module; repeat calls share the one request. */19const loadDrawer = () =>20 import("@behivetech/atoms.drawer").then((module) => ({21 default: module.Drawer,22 }));2324// The Drawer (Radix Dialog and its focus handling) is about 10 KB gzipped and25// opens only after a tap at phone width, so it is fetched on demand: ahead of26// the tap when the reader's pointer or focus reaches the menu button, and at27// the latest when the button is pressed.28const Drawer = lazy(loadDrawer);2930/** Starts the request ahead of the tap; a failure here is retried by the tap itself. */31const prefetchDrawer = () => {32 loadDrawer().catch(() => undefined);33};3435/** 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}4445/** Props the header passes to `linkComponent` for every link. */46export type SiteHeaderLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {47 href: string;48};4950/** 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}7172interface HeaderLinksProps {73 links: SiteHeaderLink[];74 linkComponent: ElementType<SiteHeaderLinkProps>;75 onNavigate?: () => void;76}7778const HeaderLinks = ({79 links,80 linkComponent: LinkComponent,81 onNavigate,82}: HeaderLinksProps) => (83 <>84 {links.map(({ href, label, current }) => (85 <NavItem86 asChild87 key={`${href}:${label}`}88 active={current}89 onClick={onNavigate}90 >91 <LinkComponent href={href}>{label}</LinkComponent>92 </NavItem>93 ))}94 </>95);9697interface MenuDrawerProps {98 links: SiteHeaderLink[];99 linkComponent: ElementType<SiteHeaderLinkProps>;100 menuLabel: string;101 navClassName: string;102 onClose: () => void;103}104105/** 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 <Drawer115 placement="right"116 title={menuLabel}117 headingLevel={2}118 padding="sm"119 onClose={onClose}120 >121 <nav aria-label={menuLabel} className={navClassName}>122 <HeaderLinks123 links={links}124 linkComponent={linkComponent}125 onNavigate={onClose}126 />127 </nav>128 </Drawer>129 </Suspense>130);131132interface HeaderMenuProps {133 links: SiteHeaderLink[];134 linkComponent: ElementType<SiteHeaderLinkProps>;135 menuLabel: string;136}137138/**139 * Returns a ref for the menu button and puts focus back on it once the Drawer140 * closes. The Drawer unmounts with its Radix Dialog, so Radix has no trigger141 * to hand focus back to.142 */143const useReturnFocus = (isOpen: boolean) => {144 const menuButtonRef = useRef<HTMLElement | null>(null);145146 useEffect(() => {147 if (!isOpen) {148 menuButtonRef.current?.focus();149 }150 }, [isOpen]);151152 return menuButtonRef;153};154155/** 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);163164 const closeMenu = () => setIsMenuOpen(false);165166 return (167 <div className={getChildClass("menu")}>168 <Button169 variant="ghost"170 size="lg"171 iconOnly172 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;179180 // The menu counts as open (and `aria-expanded` turns true) only once181 // the Drawer's code is here, so assistive technology is never told182 // about a dialog that is not there yet. A failed request leaves the183 // 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 <MenuDrawer196 links={links}197 linkComponent={linkComponent}198 menuLabel={menuLabel}199 navClassName={getChildClass("drawer-nav")}200 onClose={closeMenu}201 />202 ) : null}203 </div>204 );205};206207/**208 * Site header with a brand slot, navigation links and optional actions. While209 * the header is at least `64rem` wide the links sit inline; narrower, a menu210 * button opens them in a right-hand `Drawer`, which keeps the links in reach of211 * one thumb on a phone. The switch follows the header's own width (a container212 * query), not the viewport.213 */214export const SiteHeader = ({215 className,216 brand,217 links,218 actions,219 linkComponent = "a",220 menuLabel,221 ...rest222}: SiteHeaderProps) => {223 const [rootClass, getChildClass] = getClassName({224 className,225 rootClass: "BHT__SiteHeader",226 styles,227 });228229 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 <HeaderMenu241 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;23// Surface bar with a hairline divider. The root is a named inline-size4// container, so the links go inline once the header itself is 64rem wide and5// the menu button opens them in a Drawer below that. A container cannot style6// 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);1213 &__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 }2122 &__brand {23 display: flex;24 align-items: center;25 min-width: 0;26 }2728 &__end {29 display: flex;30 align-items: center;31 gap: var(--spacing-2, 0.5rem);32 }3334 &__nav {35 display: none;36 }3738 &__actions {39 display: flex;40 align-items: center;41 }4243 &__menu {44 display: flex;45 }4647 // 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 }5354 // 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. The56 // threshold is the shared `$breakpoint-lg`. It sits last so it wins over the57 // 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 }6263 &__end {64 gap: var(--spacing-6, 1.5rem);65 }6667 &__nav {68 display: flex;69 align-items: center;70 gap: var(--spacing-1, 0.25rem);71 }7273 &__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";34const LINKS = [5 { href: "/guide", label: "Guide", current: true },6 { href: "/gallery", label: "Gallery" },7 { href: "/art", label: "Art" },8 { href: "/artists", label: "Artists" },9];1011/** Brand, four links and no actions. Narrow the header below 64rem to get the menu button and Drawer. */12export const BasicSiteHeader = () => (13 <SiteHeader14 brand={<a href="/">The BEhive</a>}15 links={LINKS}16 menuLabel="Menu"17 />18);1920/** An action stays in the bar at every breakpoint. */21export const SiteHeaderWithActions = () => (22 <SiteHeader23 brand={<a href="/">The BEhive</a>}24 links={LINKS}25 menuLabel="Menu"26 actions={<Button size="sm">Book direct</Button>}27 />28);