# Site header

> A responsive site header with a brand link, primary links with one level of dropdowns, a call to action, optional actions and a small-screen drawer.

- ID: `cmp_site_header_01`
- Slug: `site-header-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-17
- Updated: 2026-09-17
- Available versions: `1.0.0`
- Kind: section
- Primary category: `navigation`
- Detail page: https://pagesugar.com/components/site-header-01?variant=inverted
- Preview: https://pagesugar.com/preview/site-header-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-891bf3b5c07f2a6dc4c9fe82848878cbb23d641716851ba383d9be74bd7b57fc` |
| `inverted` | Inverted | no | `sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light, dark
- Mode selection: Dark values apply when an ancestor has the class dark (for example \<html class="dark"\>), matching a class-based Tailwind dark variant. Without that class the light values apply regardless of the system setting.
- Suggested directory: `src/lib/components/site-header-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Supply the brand, items and an optional call to action; pass the current pathname so the matching link is marked. Dropdown and drawer state is local. The header does not read the router, does not handle search, account or cart state (put those in the actions snippet), and supports one level of dropdowns only.

Required props: `brand`, `items`

```svelte
<!-- Illustrative content: replace the links and labels with your own routes. -->
<script lang="ts">
	import { page } from '$app/state';
	import SiteHeader from '$lib/components/site-header-01/SiteHeader.svelte';
	import type { NavItem } from '$lib/components/site-header-01/types';

	const items: NavItem[] = [
		{
			label: 'Product',
			children: [
				{ label: 'Features', href: '/product/features', description: 'What the product does.' },
				{ label: 'Security', href: '/product/security' }
			]
		},
		{ label: 'Pricing', href: '/pricing' },
		{ label: 'Docs', href: '/docs' }
	];
</script>

<SiteHeader
	brand={{ name: 'Example', href: '/' }}
	{items}
	cta={{ label: 'Get started', href: '/signup' }}
	currentPath={page.url.pathname}
>
	{#snippet logo()}
		<img src="/logo.svg" alt="" width="28" height="28" />
		<span>Example</span>
	{/snippet}
	{#snippet actions()}
		<a href="/signin" class="text-sm font-medium">Sign in</a>
	{/snippet}
</SiteHeader>
```

Limitations:

- Before hydration the small-screen menu control is an anchor that reveals a flat list of the navigation links (not the actions snippet); dropdown buttons on wide screens open only once JavaScript has run.
- currentPath is compared with each href as a pathname (query, hash and a trailing slash are ignored); absolute URLs never match, and two items that normalise to the same pathname are both marked.
- A group with no children renders as a plain link when it has an href and is omitted otherwise.
- The breakpoint is the lg: utilities in the source plus one matching media query, not a prop; the actions snippet joins the bar at sm and is always in the drawer footer.
- The actions snippet renders in the header bar from sm up and in the drawer footer at every width below lg, so it is mounted twice at once.
- Dark colours apply inside an ancestor with the class dark; a media-query setup needs its own rule that sets the --site-header-\* variables.

## Usage guide

### Adding the complete file set

Copy all four files into `src/lib/components/site-header-01/`, keeping these relative paths:

```text
SiteHeader.svelte
parts/MobileDrawer.svelte
parts/NavDropdown.svelte
types.ts
```

The entry imports both parts and the types file, so copying it alone does not compile. There are no package dependencies.

### Marking the current page

The header never reads the router. Pass the current pathname yourself, for example `page.url.pathname` from `$app/state` in SvelteKit. The link whose `href` matches it exactly (query, hash and a trailing slash are ignored) gets `aria-current="page"`. Keep destinations unique within the items: two links that normalise to the same pathname would both be marked. In the bar that link, or the dropdown whose group `href` or child matches, carries a 2 px accent rule sitting on the bar's bottom hairline; the dropdown also gets a visually hidden `(current section)` suffix, so the section is announced without a second `aria-current`. Inside the panel and the drawer the current link is marked by a fill and a heavier weight.

### Before JavaScript runs

Below the breakpoint the menu control is server-rendered as an anchor to a hidden list of every navigation link (the items, flattened), which the browser reveals through `:target`. After hydration the anchor becomes the menu button and that list is replaced by the drawer; a visitor who had already opened the list lands in the open drawer. The `actions` snippet is not part of that list, so below `sm` its links are unavailable until JavaScript runs. Above the breakpoint the inline links work immediately, but dropdown buttons only open once the component has hydrated.

### Sticky headers

`sticky` pins the header with a translucent, blurred surface. Because it now covers the top of the viewport, add scroll padding so anchors and focused elements are not hidden under it:

```css
html {
	scroll-padding-top: 4rem;
}
```

The bar is 4rem tall when its links fit on one row; measure it if your labels wrap.

### Moving the breakpoint

Inline links show from `lg` (64rem) and the menu button below it. To change that, replace every `lg:` utility in `SiteHeader.svelte` with the breakpoint you want, and change the `(min-width: 64rem)` query in its `onMount` to match, so the drawer still closes when the viewport grows past the new breakpoint. The `actions` snippet shows in the bar from `sm` (`sm:flex`) and always in the drawer footer, because the open drawer covers the bar. When the brand, the call to action and Menu do not fit on one line below the breakpoint (long labels, a narrow phone), the header measures that and gives the call to action a full-width row of its own under the brand; until JavaScript runs the labels wrap instead.

### Colours and dark mode

Four variables recolour everything; set them on any ancestor:

```css
:root {
	--site-header-surface: #0f172a;
	--site-header-text: #f8fafc;
	--site-header-accent: #f8fafc;
	--site-header-on-accent: #0f172a;
}
```

The hairline, the two muted text tones, the hover fills and the shadow rings are mixed from the text colour, and the primary action's hover tone from the accent, so they all follow the four variables. An ancestor with the class `dark` switches the built-in fallbacks to a dark surface. If your site uses `prefers-color-scheme` instead, set the four variables inside your own media query.

### Other languages

`navLabel` names the nav landmarks and `labels` replaces the menu button text, the drawer's close label and the current-section suffix. Text direction comes from the document: set `dir="rtl"` on `<html>` and the layout, dropdown alignment and drawer side follow.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `brand` | `Brand` | yes |  | { name, href }. The site name is the brand link’s accessible name and its text when no logo snippet is given. |
| `items` | `NavItem[]` | yes |  | Primary items in order: { label, href, description? } for a link, or { label, href?, children: NavLink\[\] } for a dropdown. A group href is listed first inside its dropdown; a group with no children renders as a link or is omitted. |
| `logo` | `Snippet` | no |  | Logo markup rendered inside the brand link (inline SVG, img with empty alt, or text). Decorative: the link is named by brand.name. |
| `currentPath` | `string` | no |  | Current URL pathname, for example page.url.pathname. The exact match gets aria-current="page"; a dropdown containing it gets a visible indicator. |
| `cta` | `HeaderLink` | no |  | { label, href }. Primary action link shown at every width and repeated in the drawer. |
| `actions` | `Snippet` | no |  | Extra actions such as a sign-in link or mini cart. Shown in the bar from sm up and always in the drawer footer. |
| `sticky` | `boolean` | no | `false` | Sticks the header to the top with a translucent, blurred surface. Add scroll-padding-top to html so anchors are not hidden under it. |
| `align` | 'center' \| 'end' | no | `'end'` | Where the link list sits between the brand and the actions on wide screens. |
| `navLabel` | `string` | no | `'Main'` | Accessible name of the nav landmarks (inline list, fallback list and drawer). |
| `labels` | `Partial<HeaderLabels>` | no | `{ menu: 'Menu', close: 'Close menu', currentSection: '(current section)' }` | Text of the menu button, the drawer close button’s accessible name and the hidden suffix on a current dropdown. Override for other languages. |

## Customization

Change content through props and snippets, recolour through four CSS variables on any ancestor, and edit the lg: utilities in the source (with the matching media query) to move the breakpoint.

- Colours: set --site-header-surface, --site-header-text, --site-header-accent and --site-header-on-accent on an ancestor. Keep text on surface and on-accent on accent at 4.5:1 or better; the hairline, the secondary text tone, the hover, selected and pressed fills and the shadow rings are mixed from the text colour, and the accent hover is mixed from the accent.
- Inverted header: the inverted palette swaps the surface and text fallbacks; setting the four variables achieves the same on any page.
- Dark mode: an ancestor with the class dark switches the fallbacks. For a prefers-color-scheme setup, add a media query that sets the four variables.
- Breakpoint: the inline list uses hidden lg:flex and the menu button lg:hidden; change every lg: utility in SiteHeader.svelte together with the (min-width: 64rem) query in its onMount. The actions snippet switches between the bar and the drawer with sm:flex and sm:hidden; keep that below the navigation breakpoint.
- Sticky: pass sticky for a translucent, blurred bar (80% surface over backdrop-blur-md) and add html { scroll-padding-top: 4rem } so focused elements and anchor targets are not hidden under it.
- Width: the bar is capped at max-w-7xl on the inner wrapper; change it there.
- Dropdown panel: width and alignment live in parts/NavDropdown.svelte (w-72, end-0 or start-0); it is the one elevated surface, with the layered popover shadow declared as --\_shadow-popover in SiteHeader.svelte; descriptions render when a child has one.
- Drawer: width and side are the max-w-sm and end-0 utilities on the dialog in parts/MobileDrawer.svelte.
- Links: every href is rendered as given; pass real routes and keep labels short enough for one row at 1280 px, or accept a wrapped second row.

| Token | Public CSS variable |
| --- | --- |
| `surface` | `--site-header-surface` |
| `text` | `--site-header-text` |
| `accent` | `--site-header-accent` |
| `onAccent` | `--site-header-on-accent` |

## Accessibility

- A header landmark containing the brand link and a nav landmark named by navLabel; the drawer holds a second nav with the same name, and the pre-hydration fallback list a third, only one of which is displayed at a time.
- Dropdowns follow the APG disclosure navigation pattern: a button with aria-expanded and aria-controls, a plain link list, no menu roles. Enter or Space toggles, Escape closes and returns focus to the button, Down Arrow opens and enters the list, Up and Down move between links, focus leaving or a press outside closes it. Opening one closes the others.
- The exact currentPath match gets aria-current="page" (once per navigation region when destinations are unique). In the bar the current link, or the dropdown containing the current page, carries a 2 px accent rule on the bar’s bottom hairline plus a visually hidden currentSection suffix on the dropdown; in the panel and the drawer the current link is marked by a fill and a heavier weight.
- The small-screen drawer is a native modal dialog labelled by the menu label: focus moves into it on open, Escape or a press on the scrim closes it, and focus returns to the menu button on close. Activating any link inside it, including links from the actions snippet, closes it.
- The brand link is named by brand.name through aria-label when a logo snippet is supplied, so logo images should carry empty alt text.
- Built-in links and buttons show a 2 px focus outline in the accent colour with an offset, visible on both palettes. Controls you place in the logo and actions snippets need their own focus styles and accessible names, and any ids inside the actions snippet must stay unique across its two mounted copies.
- IDs come from $props.id(), so two headers on one page keep distinct aria-controls targets and dialog ids.

Known limitations:

- On wide screens the dropdown buttons do nothing until JavaScript runs; the fallback list only exists below the breakpoint.
- Hover does not open a dropdown; it opens on click and keyboard only.
- The drawer is not a focus-trapping custom widget; it relies on the browser’s native modal dialog, which needs a browser with \<dialog\> support.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Inverted (`inverted`)
- Entry: `SiteHeader.svelte`
- Suggested directory: `src/lib/components/site-header-01`
- Files: 4
- Artifact digest: `sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `SiteHeader.svelte`

Role: entry · 15954 bytes · SHA-256 `074dd8e622a9729e674f2d33fd5275800cc4e83795c407059c3c6b8d039e2b5b`

```svelte
<script lang="ts">
	import { onMount, type Snippet } from 'svelte';
	import MobileDrawer from './parts/MobileDrawer.svelte';
	import NavDropdown from './parts/NavDropdown.svelte';
	import {
		isCurrentPath,
		isGroup,
		renderableItems,
		type Brand,
		type HeaderLabels,
		type HeaderLink,
		type NavItem
	} from './types';

	interface Props {
		brand: Brand;
		items: NavItem[];
		logo?: Snippet;
		currentPath?: string;
		cta?: HeaderLink;
		actions?: Snippet;
		sticky?: boolean;
		align?: 'center' | 'end';
		navLabel?: string;
		labels?: Partial<HeaderLabels>;
	}

	let {
		brand,
		items,
		logo,
		currentPath,
		cta,
		actions,
		sticky = false,
		align = 'end',
		navLabel = 'Main',
		labels
	}: Props = $props();

	const uid = $props.id();
	const text = $derived<HeaderLabels>({
		menu: 'Menu',
		close: 'Close menu',
		currentSection: '(current section)',
		...labels
	});

	const visibleItems = $derived(renderableItems(items));
	/* The menu, its fallback list and the drawer exist only when they reveal something the bar does not. */
	const hasMenu = $derived(visibleItems.length > 0 || Boolean(actions));

	let openIndex = $state<number | null>(null);
	let menuOpen = $state(false);
	let hydrated = $state(false);
	let menuButton = $state<HTMLButtonElement>();

	/*
	 * When the inline links do not fit beside the brand and the actions, the navigation drops to a
	 * full-width band of its own under them, so the current-location rule stays on the bar's edge
	 * and a dropdown never opens across another row of links. Measured, never guessed: the sum of
	 * the items' natural widths against the room the first row leaves them.
	 */
	let bar = $state<HTMLDivElement>();
	let brandLink = $state<HTMLAnchorElement>();
	let controls = $state<HTMLDivElement>();
	let navList = $state<HTMLUListElement>();
	let ctaLink = $state<HTMLAnchorElement>();
	let banded = $state(false);

	/*
	 * Below the breakpoint the same rule applies to the call to action: when brand, button and Menu
	 * do not fit on one line without wrapping their labels, the button takes a full-width row of its
	 * own and the sign-in action waits in the drawer.
	 */
	let stacked = $state(false);

	function naturalWidth(element: HTMLElement | undefined) {
		if (!element || element.offsetParent === null) return 0;
		// A stacked button carries a full-width basis; measure it without, or it could never unstack.
		const { whiteSpace, width, flexShrink, flexBasis, flexGrow } = element.style;
		element.style.flexBasis = 'auto';
		element.style.flexGrow = '0';
		element.style.whiteSpace = 'nowrap';
		element.style.width = 'max-content';
		element.style.flexShrink = '0';
		const measured = element.getBoundingClientRect().width;
		element.style.whiteSpace = whiteSpace;
		element.style.width = width;
		element.style.flexShrink = flexShrink;
		element.style.flexBasis = flexBasis;
		element.style.flexGrow = flexGrow;
		return measured;
	}

	function measureStack(container: HTMLDivElement) {
		const style = getComputedStyle(container);
		const inner =
			container.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
		const menuWidth = container.querySelector<HTMLElement>('[data-site-header-menu]');
		const needed =
			naturalWidth(brandLink) +
			16 +
			naturalWidth(ctaLink) +
			8 +
			naturalWidth(menuWidth ?? undefined);
		stacked = Boolean(ctaLink) && needed > inner;
	}

	function measureBand() {
		const list = navList;
		const container = bar;
		if (!list || !container) return;
		// The list sits inside the navigation, which is hidden below the breakpoint.
		if (list.offsetParent === null) {
			measureStack(container);
			return;
		}
		stacked = false;
		const items = Array.from(list.children) as HTMLElement[];
		const gap = 4;
		const needed =
			items.reduce((width, item) => width + item.getBoundingClientRect().width, 0) +
			gap * Math.max(0, items.length - 1);
		const style = getComputedStyle(container);
		const inner =
			container.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
		const brandWidth = brandLink?.getBoundingClientRect().width ?? 0;
		const controlsWidth = controls?.getBoundingClientRect().width ?? 0;
		const rowGap = 24;
		const available = inner - brandWidth - controlsWidth - rowGap * (controlsWidth > 0 ? 2 : 1);
		banded = needed > available;
	}

	$effect(() => {
		const container = bar;
		const list = navList;
		if (!container || !list) return;
		// Measure on the next frame: changing the layout inside the observer's own callback loops it.
		let frame = 0;
		const schedule = () => {
			cancelAnimationFrame(frame);
			frame = requestAnimationFrame(measureBand);
		};
		const observer = new ResizeObserver(schedule);
		observer.observe(container);
		observer.observe(list);
		measureBand();
		return () => {
			cancelAnimationFrame(frame);
			observer.disconnect();
		};
	});

	/*
	 * Breakpoint: inline links above `lg` (64rem), the menu button below it. The `lg:` classes
	 * in the markup and this media query must change together.
	 */
	onMount(() => {
		hydrated = true;
		const desktop = window.matchMedia('(min-width: 64rem)');
		// A visitor who opened the pre-hydration fallback list continues in the drawer.
		if (!desktop.matches && window.location.hash === `#${uid}-fallback`) menuOpen = true;
		const sync = () => {
			if (desktop.matches) menuOpen = false;
		};
		desktop.addEventListener('change', sync);
		return () => desktop.removeEventListener('change', sync);
	});

	function toggleDropdown(index: number) {
		openIndex = openIndex === index ? null : index;
	}

	/* The native dialog restores focus to whatever was focused before it opened; make sure that is the menu button. */
	function closeDrawer() {
		menuOpen = false;
		if (menuButton && menuButton.offsetParent !== null) menuButton.focus();
	}

	/* Spacing set for the whole component: 4, 8, 12, 16 and 24 px. Touch targets clear 44 px below lg. */
	const focusClass =
		'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]';
	const linkClass = `inline-flex min-h-11 items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] lg:min-h-9 lg:pointer-coarse:min-h-11 ${focusClass}`;
	/* The current location is a 2 px accent rule sitting on the bar's own bottom hairline; a border, so forced colours keep it. */
	const indicatorClass =
		"after:absolute after:inset-x-3 after:bottom-0 after:h-0 after:border-b-2 after:border-[var(--_accent)] after:content-['']";
	const menuClass = `inline-flex min-h-11 items-center gap-x-2 rounded-lg px-2 py-2 text-sm font-medium text-[var(--_text)] transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_fill)] active:bg-[var(--_fill-pressed)] active:duration-[80ms] ${focusClass} lg:hidden`;
</script>

<header
	class={[
		'site-header relative border-b border-[var(--_line)] text-[var(--_text)]',
		sticky
			? 'sticky top-0 z-40 bg-[var(--_surface-material)] backdrop-blur-md'
			: 'bg-[var(--_surface)]'
	]}
>
	<div
		bind:this={bar}
		class={[
			'mx-auto flex min-h-14 max-w-7xl items-stretch gap-x-4 px-4 sm:px-6 lg:min-h-16 lg:flex-wrap lg:gap-x-6',
			stacked ? 'flex-wrap' : 'flex-nowrap'
		]}
	>
		<a
			bind:this={brandLink}
			href={brand.href}
			aria-label={logo ? brand.name : undefined}
			class={[
				'my-2 flex min-h-11 min-w-18 shrink items-center self-center rounded-lg text-lg font-semibold tracking-[-0.015em] transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] active:text-[var(--_text-2)] active:duration-[80ms] lg:min-h-9 lg:pointer-coarse:min-h-11',
				// With the links on their own band the first row keeps the 64 px it has on one line.
				banded && 'lg:min-h-12',
				// Stacked, the brand shares the first row with Menu and wraps inside it if it has to.
				stacked && 'max-lg:flex-1 max-lg:basis-0',
				focusClass
			]}
		>
			{#if logo}
				{@render logo()}
			{:else}
				<span class="truncate">{brand.name}</span>
			{/if}
		</a>

		<nav
			aria-label={navLabel}
			class={[
				'hidden min-w-0 flex-1 lg:flex',
				align === 'center' ? 'justify-center' : 'justify-end',
				banded && 'order-last min-h-12 basis-full border-t border-[var(--_line)]'
			]}
		>
			<ul
				bind:this={navList}
				role="list"
				class={[
					'flex items-stretch gap-x-1',
					banded ? 'flex-nowrap' : 'flex-wrap',
					align === 'center' ? 'justify-center' : 'justify-end'
				]}
			>
				{#each visibleItems as item, index (index)}
					{#if isGroup(item)}
						<NavDropdown
							group={item}
							id="{uid}-panel-{index}"
							open={openIndex === index}
							{currentPath}
							currentSectionLabel={text.currentSection}
							onToggle={() => toggleDropdown(index)}
							onClose={() => (openIndex = null)}
						/>
					{:else}
						{@const current = isCurrentPath(item.href, currentPath)}
						<li class={['relative flex items-center', current && indicatorClass]}>
							<a
								href={item.href}
								aria-current={current ? 'page' : undefined}
								class={[
									linkClass,
									'active:bg-[var(--_fill-pressed)] active:duration-[80ms]',
									current
										? 'text-[var(--_text)] hover:bg-[var(--_fill)]'
										: 'text-[var(--_text-2)] hover:bg-[var(--_fill)] hover:text-[var(--_text)]'
								]}
							>
								{item.label}
							</a>
						</li>
					{/if}
				{/each}
			</ul>
		</nav>

		<div
			bind:this={controls}
			class={[
				'my-2 ms-auto flex min-w-min shrink items-center gap-x-2 self-center sm:gap-x-3',
				!banded && 'lg:ms-0',
				!cta && !actions && 'lg:hidden',
				stacked && 'max-lg:contents'
			]}
		>
			{#if actions}
				<div class={['hidden items-center gap-x-3', stacked ? 'lg:flex' : 'sm:flex']}>
					{@render actions()}
				</div>
			{/if}
			{#if cta}
				<a
					bind:this={ctaLink}
					href={cta.href}
					class={[
						'relative inline-flex min-h-9 items-center rounded-lg bg-[var(--_accent)] px-3 py-2 text-center text-sm leading-tight font-medium text-[var(--_on-accent)] transition-[background-color,scale] duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_accent-hover)] active:scale-[.98] active:bg-[var(--_accent-pressed)] active:duration-[80ms] motion-reduce:active:scale-100 lg:px-4',
						// The button stays 36 px tall; its hit area is 44 px.
						"before:absolute before:inset-x-0 before:-inset-y-1 before:content-['']",
						stacked &&
							'max-lg:order-last max-lg:mb-3 max-lg:min-h-11 max-lg:basis-full max-lg:justify-center',
						focusClass
					]}
				>
					{cta.label}
				</a>
			{/if}
			{#if !hasMenu}
				<!-- Nothing to reveal: no menu, no fallback list, no drawer. -->
			{:else if hydrated}
				<button
					bind:this={menuButton}
					type="button"
					aria-expanded={menuOpen}
					aria-controls="{uid}-drawer"
					onclick={() => (menuOpen = true)}
					data-site-header-menu
					class={[menuClass, stacked && 'ms-auto self-center']}
				>
					<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
						<path
							d="M2 4h12M2 8h12M2 12h12"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
						/>
					</svg>
					<span>{text.menu}</span>
				</button>
			{:else}
				<!-- Before hydration the menu control is a plain anchor to the fallback list below. -->
				<a
					href="#{uid}-fallback"
					data-site-header-menu
					class={[menuClass, stacked && 'ms-auto self-center']}
				>
					<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
						<path
							d="M2 4h12M2 8h12M2 12h12"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
						/>
					</svg>
					<span>{text.menu}</span>
				</a>
			{/if}
		</div>
	</div>

	{#if !hasMenu}
		<!-- Nothing to reveal. -->
	{:else if !hydrated}
		<!-- No-JS fallback: the anchor above targets this list, which :target reveals below lg. -->
		<div class="lg:hidden">
			<nav
				id="{uid}-fallback"
				aria-label={navLabel}
				class="hidden border-t border-[var(--_line)] px-4 py-2 target:block sm:px-6"
			>
				<ul role="list" class="flex flex-wrap gap-x-1">
					{#each visibleItems as item, index (index)}
						{#if isGroup(item)}
							{#if item.href}
								{@const current = isCurrentPath(item.href, currentPath)}
								<li>
									<a
										href={item.href}
										aria-current={current ? 'page' : undefined}
										class={[
											linkClass,
											current ? 'bg-[var(--_fill-active)] font-semibold' : 'text-[var(--_text-2)]'
										]}
									>
										{item.label}
									</a>
								</li>
							{/if}
							{#each item.children as child, childIndex (childIndex)}
								{@const current = isCurrentPath(child.href, currentPath)}
								<li>
									<a
										href={child.href}
										aria-current={current ? 'page' : undefined}
										class={[
											linkClass,
											current ? 'bg-[var(--_fill-active)] font-semibold' : 'text-[var(--_text-2)]'
										]}
									>
										{child.label}
									</a>
								</li>
							{/each}
						{:else}
							{@const current = isCurrentPath(item.href, currentPath)}
							<li>
								<a
									href={item.href}
									aria-current={current ? 'page' : undefined}
									class={[
										linkClass,
										current ? 'bg-[var(--_fill-active)] font-semibold' : 'text-[var(--_text-2)]'
									]}
								>
									{item.label}
								</a>
							</li>
						{/if}
					{/each}
				</ul>
			</nav>
		</div>
	{:else}
		<MobileDrawer
			id="{uid}-drawer"
			open={menuOpen}
			onClose={closeDrawer}
			{brand}
			{logo}
			items={visibleItems}
			{currentPath}
			{cta}
			{actions}
			labels={text}
			{navLabel}
		/>
	{/if}
</header>

<style>
	.site-header {
		--_surface: var(--site-header-surface, #18181b);
		--_text: var(--site-header-text, #fafafa);
		--_accent: var(--site-header-accent, #fafafa);
		--_on-accent: var(--site-header-on-accent, #18181b);
		/* Three text tones: --_text, then secondary and tertiary mixed from it. */
		--_text-2: color-mix(in oklab, var(--_text) 72%, transparent);
		/* Hairline and one-step fills, all derived from the text colour so every palette gets them. */
		--_line: color-mix(in oklab, var(--_text) 10%, transparent);
		--_fill: color-mix(in oklab, var(--_text) 5%, transparent);
		--_fill-active: color-mix(in oklab, var(--_text) 8%, transparent);
		--_fill-pressed: color-mix(in oklab, var(--_text) 12%, transparent);
		--_accent-hover: color-mix(in oklab, var(--_accent) 88%, var(--_surface));
		--_accent-pressed: color-mix(in oklab, var(--_accent) 80%, var(--_surface));
		/* Raised surfaces (the open panel, the drawer) and the Material bar. */
		--_raised: var(--_surface);
		--_surface-material: color-mix(in oklab, var(--_surface) 80%, transparent);
		/* Layered shadows lit from above; the ring is mixed from the text colour so it shows on dark surfaces too. */
		--_shadow-popover:
			0 0 0 1px color-mix(in oklab, var(--_text) 6%, transparent), 0 4px 6px -1px rgb(0 0 0 / 0.07),
			0 10px 15px -3px rgb(0 0 0 / 0.05);
		--_shadow-drawer:
			0 0 0 1px color-mix(in oklab, var(--_text) 6%, transparent),
			0 10px 15px -3px rgb(0 0 0 / 0.08), 0 25px 50px -12px rgb(0 0 0 / 0.18);
	}

	:global(.dark) .site-header {
		--_surface: var(--site-header-surface, #fafafa);
		--_text: var(--site-header-text, #18181b);
		--_accent: var(--site-header-accent, #18181b);
		--_on-accent: var(--site-header-on-accent, #fafafa);
		/* Raised surfaces step one tone towards white: a dark surface lifts, an inverted light one stays clean. */
		--_raised: color-mix(in oklab, var(--_surface) 92%, #ffffff);
		--_shadow-popover:
			0 0 0 1px color-mix(in oklab, var(--_text) 10%, transparent),
			inset 0 1px 0 color-mix(in oklab, var(--_text) 8%, transparent);
		--_shadow-drawer:
			0 0 0 1px color-mix(in oklab, var(--_text) 10%, transparent),
			inset 0 1px 0 color-mix(in oklab, var(--_text) 8%, transparent);
	}
</style>
```

#### `parts/MobileDrawer.svelte`

Role: component · 10262 bytes · SHA-256 `4feab2a4e551b787c0f3acd531fc9d8755005ec33fe3b3a79ca973b1a3d84817`

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import {
		isCurrentGroup,
		isCurrentPath,
		isGroup,
		type Brand,
		type HeaderLabels,
		type HeaderLink,
		type NavItem
	} from '../types';

	interface Props {
		/** DOM id of the dialog, referenced by the menu button's aria-controls. */
		id: string;
		open: boolean;
		onClose: () => void;
		brand: Brand;
		/** The header's logo snippet, so the drawer keeps the same mark as the bar. */
		logo?: Snippet;
		items: NavItem[];
		currentPath?: string;
		cta?: HeaderLink;
		actions?: Snippet;
		labels: HeaderLabels;
		navLabel: string;
	}

	let {
		id,
		open,
		onClose,
		brand,
		logo,
		items,
		currentPath,
		cta,
		actions,
		labels,
		navLabel
	}: Props = $props();

	let dialog = $state<HTMLDialogElement>();
	let closeButton = $state<HTMLButtonElement>();
	let expandedGroups = $state<number[]>([]);

	/* Native modal dialog: focus moves inside on open and back to the menu button on close. */
	$effect(() => {
		const element = dialog;
		if (!element) return;
		if (open && !element.open) {
			expandedGroups = items.flatMap((item, index) =>
				isGroup(item) && isCurrentGroup(item, currentPath) ? [index] : []
			);
			element.showModal();
			// The brand link comes first in the DOM; the close control is the deliberate first stop.
			closeButton?.focus();
		} else if (!open && element.open) {
			element.close();
		}
	});

	/*
	 * Any link activated inside the drawer, including links from the actions snippet, closes it.
	 * So does a press on the scrim: one that starts and ends outside the panel, never a drag out of it.
	 */
	$effect(() => {
		const element = dialog;
		if (!element) return;
		const outside = (event: MouseEvent) => {
			const rect = element.getBoundingClientRect();
			return (
				event.clientX < rect.left ||
				event.clientX > rect.right ||
				event.clientY < rect.top ||
				event.clientY > rect.bottom
			);
		};
		let pressedOutside = false;
		const onPointerDown = (event: PointerEvent) => {
			pressedOutside = event.target === element && outside(event);
		};
		const onClick = (event: MouseEvent) => {
			const target = event.target instanceof Element ? event.target : null;
			if (target?.closest('a[href]')) onClose();
			else if (pressedOutside && event.target === element && outside(event)) onClose();
			pressedOutside = false;
		};
		element.addEventListener('pointerdown', onPointerDown);
		element.addEventListener('click', onClick);
		return () => {
			element.removeEventListener('pointerdown', onPointerDown);
			element.removeEventListener('click', onClick);
		};
	});

	/* Keep the page from scrolling behind the drawer, restoring whatever was set before. */
	$effect(() => {
		if (!open) return;
		const html = document.documentElement;
		const previous = html.style.overflow;
		html.style.overflow = 'hidden';
		return () => {
			html.style.overflow = previous;
		};
	});

	function toggleGroup(index: number) {
		expandedGroups = expandedGroups.includes(index)
			? expandedGroups.filter((i) => i !== index)
			: [...expandedGroups, index];
	}

	/* 44 px rows for a coarse pointer; the current item is marked by fill and weight, not colour alone. */
	const rowClass =
		'flex min-h-11 w-full items-center rounded-lg px-2 py-2 text-start text-base tracking-[-0.011em] rtl:tracking-normal transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:bg-[var(--_fill-pressed)] active:duration-[80ms]';
	const restClass =
		'font-normal text-[var(--_text-2)] hover:bg-[var(--_fill)] hover:text-[var(--_text)]';
	/* Top-level destinations are medium, their children regular, so the two levels read apart. */
	const topRestClass =
		'font-medium text-[var(--_text-2)] hover:bg-[var(--_fill)] hover:text-[var(--_text)]';
	const currentClass = 'bg-[var(--_fill-active)] font-semibold text-[var(--_text)]';
	/* A current group is still a control: it keeps the hover fill and only changes weight and tone. */
	const currentGroupClass = 'font-medium text-[var(--_text)] hover:bg-[var(--_fill)]';
</script>

<!-- An opaque raised surface with the layered drawer shadow, whose ring is its edge; forced colours get a border instead. -->
<dialog
	bind:this={dialog}
	{id}
	aria-label={labels.menu}
	onclose={onClose}
	class="fixed inset-y-0 start-auto end-0 m-0 h-dvh max-h-none w-full max-w-sm translate-x-full flex-col bg-[var(--_raised)] p-0 text-[var(--_text)] shadow-[var(--_shadow-drawer)] transition-[translate,display,overlay] transition-discrete duration-[180ms] ease-[cubic-bezier(.4,0,1,1)] backdrop:bg-black/40 backdrop:opacity-0 backdrop:transition-[opacity,display,overlay] backdrop:transition-discrete backdrop:duration-[180ms] open:flex open:translate-x-0 open:duration-250 open:ease-[cubic-bezier(.16,1,.3,1)] open:backdrop:opacity-100 open:backdrop:duration-250 motion-reduce:transition-none motion-reduce:backdrop:transition-none rtl:-translate-x-full rtl:open:translate-x-0 starting:open:translate-x-full starting:open:backdrop:opacity-0 rtl:starting:open:-translate-x-full forced-colors:border-s"
>
	<div class="flex items-center justify-between gap-x-4 border-b border-[var(--_line)] px-4 py-2">
		<a
			href={brand.href}
			aria-label={logo ? brand.name : undefined}
			class="flex min-h-11 min-w-0 items-center truncate rounded-lg text-lg font-semibold tracking-[-0.015em] transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:text-[var(--_text-2)] active:duration-[80ms] rtl:tracking-normal"
		>
			{#if logo}{@render logo()}{:else}{brand.name}{/if}
		</a>
		<button
			bind:this={closeButton}
			type="button"
			onclick={onClose}
			class="inline-flex size-11 shrink-0 items-center justify-center rounded-lg transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_fill)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:bg-[var(--_fill-pressed)] active:duration-[80ms]"
		>
			<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
				<path
					d="M5 5l10 10M15 5L5 15"
					stroke="currentColor"
					stroke-width="1.5"
					stroke-linecap="round"
				/>
			</svg>
			<span class="sr-only">{labels.close}</span>
		</button>
	</div>

	<nav aria-label={navLabel} class="min-h-0 flex-1 overflow-y-auto px-2 py-2">
		<ul role="list" class="flex flex-col gap-y-1">
			{#each items as item, index (index)}
				<li>
					{#if isGroup(item)}
						{@const expanded = expandedGroups.includes(index)}
						{@const current = isCurrentGroup(item, currentPath)}
						<button
							type="button"
							aria-expanded={expanded}
							aria-controls="{id}-group-{index}"
							onclick={() => toggleGroup(index)}
							class={[
								rowClass,
								'justify-between gap-x-3',
								current ? currentGroupClass : topRestClass
							]}
						>
							<span class="min-w-0 break-words">
								{item.label}
								{#if current}
									<span class="sr-only">{labels.currentSection}</span>
								{/if}
							</span>
							<svg
								class={[
									'size-4 shrink-0 transition-transform motion-reduce:transition-none',
									// The chevron keeps time with its list: 200 ms in, 150 ms out.
									expanded
										? 'rotate-180 duration-200 ease-[cubic-bezier(.16,1,.3,1)]'
										: 'duration-150 ease-[cubic-bezier(.4,0,1,1)]'
								]}
								viewBox="0 0 16 16"
								fill="none"
								aria-hidden="true"
							>
								<path
									d="M4 6l4 4 4-4"
									stroke="currentColor"
									stroke-width="1.75"
									stroke-linecap="round"
									stroke-linejoin="round"
								/>
							</svg>
						</button>
						<ul
							id="{id}-group-{index}"
							role="list"
							hidden={!expanded}
							class={[
								'ms-3 flex flex-col gap-y-1 pb-3 transition-[opacity,translate,display] transition-discrete motion-reduce:transition-none',
								// The links follow the chevron: in over 200 ms, out faster on the exit curve.
								expanded
									? 'duration-200 ease-[cubic-bezier(.16,1,.3,1)] starting:-translate-y-1 starting:opacity-0'
									: '-translate-y-1 opacity-0 duration-150 ease-[cubic-bezier(.4,0,1,1)]'
							]}
						>
							{#if item.href}
								{@const active = isCurrentPath(item.href, currentPath)}
								<li>
									<a
										href={item.href}
										aria-current={active ? 'page' : undefined}
										class={[rowClass, active ? currentClass : restClass]}
									>
										{item.label}
									</a>
								</li>
							{/if}
							{#each item.children as child, childIndex (childIndex)}
								{@const active = isCurrentPath(child.href, currentPath)}
								<li>
									<a
										href={child.href}
										aria-current={active ? 'page' : undefined}
										class={[rowClass, active ? currentClass : restClass]}
									>
										{child.label}
									</a>
								</li>
							{/each}
						</ul>
					{:else}
						{@const active = isCurrentPath(item.href, currentPath)}
						<a
							href={item.href}
							aria-current={active ? 'page' : undefined}
							class={[rowClass, active ? currentClass : topRestClass]}
						>
							{item.label}
						</a>
					{/if}
				</li>
			{/each}
		</ul>
	</nav>

	{#if cta || actions}
		<div class="flex flex-col gap-y-3 border-t border-[var(--_line)] px-4 py-4">
			{#if actions}
				<!-- Pulled out by the actions' own 4 px padding, so their text shares the rows' edge. -->
				<div class="-mx-1 flex items-center gap-x-3">{@render actions()}</div>
			{/if}
			{#if cta}
				<a
					href={cta.href}
					class="inline-flex min-h-11 items-center justify-center rounded-lg bg-[var(--_accent)] px-4 py-2 text-base font-medium tracking-[-0.011em] text-[var(--_on-accent)] transition-[background-color,scale] duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_accent-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:scale-[.98] active:bg-[var(--_accent-pressed)] active:duration-[80ms] motion-reduce:active:scale-100 rtl:tracking-normal"
				>
					{cta.label}
				</a>
			{/if}
		</div>
	{/if}
</dialog>
```

#### `parts/NavDropdown.svelte`

Role: component · 7725 bytes · SHA-256 `b73af87b48af7e088432556755dc0f3bc82f6b43a175056329f7ce8aba367520`

```svelte
<script lang="ts">
	import { tick } from 'svelte';
	import { isCurrentGroup, isCurrentPath, type NavGroup } from '../types';

	interface Props {
		group: NavGroup;
		/** DOM id of the panel; the button gets `${id}-button`. */
		id: string;
		open: boolean;
		currentPath?: string;
		currentSectionLabel: string;
		onToggle: () => void;
		onClose: () => void;
	}

	let { group, id, open, currentPath, currentSectionLabel, onToggle, onClose }: Props = $props();

	let root = $state<HTMLLIElement>();
	let button = $state<HTMLButtonElement>();
	let panel = $state<HTMLDivElement>();

	const current = $derived(isCurrentGroup(group, currentPath));

	/*
	 * Panel placement: it hangs from its trigger's start edge, and flips to the end edge after opening
	 * only when it would otherwise leave the viewport. Logical sides, so RTL mirrors on its own.
	 */
	let placement = $state<'start' | 'end' | null>(null);
	const side = $derived(placement ?? 'start');

	function measure() {
		const element = panel;
		if (!element) return;
		const gutter = 8;
		const width = document.documentElement.clientWidth;
		const rect = element.getBoundingClientRect();
		const rtl = getComputedStyle(element).direction === 'rtl';
		const overflowsEnd = rtl ? rect.left < gutter : rect.right > width - gutter;
		const overflowsStart = rtl ? rect.right > width - gutter : rect.left < gutter;
		if (overflowsEnd && !overflowsStart) placement = 'end';
		else if (overflowsStart && !overflowsEnd) placement = 'start';
		// The panel's top depends on the bar's height (one row or a band), so its room is measured too.
		const room = document.documentElement.clientHeight - rect.top - gutter;
		element.style.maxHeight = room > 0 ? `${Math.round(room)}px` : '';
	}

	$effect(() => {
		if (!open) {
			placement = null;
			return;
		}
		measure();
	});

	/* The viewport can change while the panel is open; measure again from the default side. */
	function onWindowResize() {
		placement = null;
		requestAnimationFrame(measure);
	}
	const links = $derived(
		group.href ? [{ label: group.label, href: group.href }, ...group.children] : group.children
	);

	const panelLinks = () =>
		panel ? Array.from(panel.querySelectorAll<HTMLAnchorElement>('a[href]')) : [];

	function close() {
		onClose();
		button?.focus();
	}

	async function onButtonKeydown(event: KeyboardEvent) {
		if (event.key !== 'ArrowDown') return;
		event.preventDefault();
		if (!open) onToggle();
		await tick();
		panelLinks()[0]?.focus();
	}

	/* Escape and the optional arrow keys from the APG disclosure navigation pattern. */
	function onWindowKeydown(event: KeyboardEvent) {
		// Escape works even when the opening click left focus on the body (WebKit).
		if (event.key === 'Escape') {
			event.preventDefault();
			close();
			return;
		}
		if (!(event.target instanceof Node) || !panel?.contains(event.target)) return;
		const items = panelLinks();
		const index = items.indexOf(document.activeElement as HTMLAnchorElement);
		if (event.key === 'ArrowDown') {
			event.preventDefault();
			items[Math.min(index + 1, items.length - 1)]?.focus();
		} else if (event.key === 'ArrowUp') {
			event.preventDefault();
			if (index <= 0) button?.focus();
			else items[index - 1]?.focus();
		} else if (event.key === 'Home') {
			event.preventDefault();
			items[0]?.focus();
		} else if (event.key === 'End') {
			event.preventDefault();
			items[items.length - 1]?.focus();
		}
	}

	/* Close when focus moves anywhere outside the item, for example by tabbing past the last link. */
	function onDocumentFocusIn(event: FocusEvent) {
		if (event.target instanceof Node && root && !root.contains(event.target)) onClose();
	}

	/* Close on a pointer press outside the item. Presses inside reach the links first. */
	function onDocumentPointerDown(event: PointerEvent) {
		if (event.target instanceof Node && root && !root.contains(event.target)) onClose();
	}

	const focusClass =
		'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]';
</script>

<svelte:window
	onkeydown={open ? onWindowKeydown : undefined}
	onresize={open ? onWindowResize : undefined}
/>
<svelte:document
	onfocusin={open ? onDocumentFocusIn : undefined}
	onpointerdown={open ? onDocumentPointerDown : undefined}
/>

<!-- The current section is a 2 px accent rule on the bar's bottom hairline, like every current link. -->
<li
	bind:this={root}
	class={[
		'relative flex items-center',
		current &&
			"after:absolute after:inset-x-3 after:bottom-0 after:h-0 after:border-b-2 after:border-[var(--_accent)] after:content-['']"
	]}
>
	<button
		bind:this={button}
		type="button"
		id="{id}-button"
		aria-expanded={open}
		aria-controls={id}
		onclick={onToggle}
		onkeydown={onButtonKeydown}
		class={[
			'inline-flex min-h-11 items-center gap-x-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] lg:min-h-9 lg:pointer-coarse:min-h-11',
			'active:bg-[var(--_fill-pressed)] active:duration-[80ms]',
			open
				? 'bg-[var(--_fill)] text-[var(--_text)] hover:bg-[var(--_fill-active)]'
				: current
					? 'text-[var(--_text)] hover:bg-[var(--_fill)]'
					: 'text-[var(--_text-2)] hover:bg-[var(--_fill)] hover:text-[var(--_text)]',
			focusClass
		]}
	>
		<span>{group.label}</span>
		{#if current}
			<span class="sr-only">{currentSectionLabel}</span>
		{/if}
		<svg
			class={[
				'size-4 shrink-0 transition-transform motion-reduce:transition-none',
				// The chevron keeps time with its panel: 200 ms in, 150 ms out.
				open
					? 'rotate-180 duration-200 ease-[cubic-bezier(.16,1,.3,1)]'
					: 'duration-150 ease-[cubic-bezier(.4,0,1,1)]'
			]}
			viewBox="0 0 16 16"
			fill="none"
			aria-hidden="true"
		>
			<path
				d="M4 6l4 4 4-4"
				stroke="currentColor"
				stroke-width="1.75"
				stroke-linecap="round"
				stroke-linejoin="round"
			/>
		</svg>
	</button>

	<!-- The one elevated surface: a 12 px panel with the popover shadow, 4 px rows inside its 8 px padding. -->
	<div
		bind:this={panel}
		{id}
		hidden={!open}
		class={[
			'absolute top-full z-30 mt-2 w-72 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-xl bg-[var(--_raised)] p-2 shadow-[var(--_shadow-popover)]',
			'transition-[opacity,translate,display] transition-discrete motion-reduce:transition-none',
			// Opens over 200 ms from 4 px above; closes faster, on the exit curve.
			open
				? 'duration-200 ease-[cubic-bezier(.16,1,.3,1)] starting:-translate-y-1 starting:opacity-0'
				: '-translate-y-1 opacity-0 duration-150 ease-[cubic-bezier(.4,0,1,1)]',
			// Pulled out by its own padding, so row text sits under the trigger's text and row fills under its fill.
			side === 'end' ? '-end-2' : '-start-2'
		]}
	>
		<ul role="list" class="flex flex-col">
			{#each links as link, index (index)}
				{@const active = isCurrentPath(link.href, currentPath)}
				<li>
					<a
						href={link.href}
						aria-current={active ? 'page' : undefined}
						class={[
							'block min-h-9 rounded-sm px-3 py-2 transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:bg-[var(--_fill-pressed)] active:duration-[80ms] pointer-coarse:min-h-11',
							active ? 'bg-[var(--_fill-active)]' : 'hover:bg-[var(--_fill)]'
						]}
					>
						<span
							class={[
								'block text-sm break-words text-[var(--_text)]',
								active ? 'font-semibold' : 'font-medium'
							]}>{link.label}</span
						>
						{#if link.description}
							<span class="mt-1 block text-xs leading-snug break-words text-[var(--_text-2)]"
								>{link.description}</span
							>
						{/if}
					</a>
				</li>
			{/each}
		</ul>
	</div>
</li>
```

#### `types.ts`

Role: types · 2189 bytes · SHA-256 `354e2ebed47b69ea3a1b7f81ba75e9dee26d7ee6a1fad257460c75c068a1552c`

```ts
/** A single destination in the primary navigation. */
export interface NavLink {
	label: string;
	href: string;
	/** Optional one-line description shown under the label inside a dropdown. */
	description?: string;
}

/** A top-level item that opens a dropdown of related links. */
export interface NavGroup {
	label: string;
	/** Optional overview page for the group, listed first inside the dropdown. */
	href?: string;
	children: NavLink[];
}

export type NavItem = NavLink | NavGroup;

export interface Brand {
	/** Site name; the brand link's accessible name and its fallback text. */
	name: string;
	href: string;
}

export interface HeaderLink {
	label: string;
	href: string;
}

export interface HeaderLabels {
	/** Text of the small-screen menu button. */
	menu: string;
	/** Accessible name of the drawer's close button. */
	close: string;
	/** Visually hidden suffix on a dropdown whose links include the current page. */
	currentSection: string;
}

export const isGroup = (item: NavItem): item is NavGroup =>
	'children' in item && Array.isArray(item.children);

/**
 * Items as rendered: a group with no children becomes a plain link when it has an href and
 * is dropped otherwise, so nothing renders as a button with an empty panel.
 */
export const renderableItems = (items: NavItem[]): NavItem[] =>
	items.flatMap((item) => {
		if (!isGroup(item) || item.children.length > 0) return [item];
		return item.href ? [{ label: item.label, href: item.href }] : [];
	});

const normalizePath = (path: string) => {
	const bare = path.split(/[?#]/, 1)[0];
	return bare.length > 1 ? bare.replace(/\/+$/, '') : bare;
};

/** True when a link href names the current pathname, ignoring query, hash and a trailing slash. */
export const isCurrentPath = (href: string | undefined, currentPath: string | undefined) =>
	Boolean(href && currentPath) && normalizePath(href!) === normalizePath(currentPath!);

/** True when the group's own page or one of its children is the current page. */
export const isCurrentGroup = (group: NavGroup, currentPath: string | undefined) =>
	isCurrentPath(group.href, currentPath) ||
	group.children.some((child) => isCurrentPath(child.href, currentPath));
```

## Artifacts

### Inverted (`inverted`)

- Artifact digest: `sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3`
- Entry: `SiteHeader.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_site_header_01/1.0.0/inverted/sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_site_header_01/1.0.0/inverted/sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3/bundle.zip (16345 bytes, sha256 `448681340bd838208965e7b9aac772625f230142353c9287c2f435fd1b8a7af6`)

Files:

- `SiteHeader.svelte` (entry, 15954 bytes): https://pagesugar.com/artifacts/cmp_site_header_01/1.0.0/inverted/sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3/source/SiteHeader.svelte
- `parts/MobileDrawer.svelte` (component, 10262 bytes): https://pagesugar.com/artifacts/cmp_site_header_01/1.0.0/inverted/sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3/source/parts/MobileDrawer.svelte
- `parts/NavDropdown.svelte` (component, 7725 bytes): https://pagesugar.com/artifacts/cmp_site_header_01/1.0.0/inverted/sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3/source/parts/NavDropdown.svelte
- `types.ts` (types, 2189 bytes): https://pagesugar.com/artifacts/cmp_site_header_01/1.0.0/inverted/sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3/source/types.ts
