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.
cmp_site_header_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Use this component with your coding agent
Using the PageSugar MCP server, fetch component cmp_site_header_01 version 1.0.0 with variant "inverted", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.
Code
Artifact sha256-786fef0e…fe456da3
sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3This component needs all 4 files. Download the ZIP
<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>
Usage #
On this pageSupply 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.
- Suggested location
src/lib/components/site-header-01
Limitations
- Before hydration the
small-screenmenu 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. currentPathis 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.
Example
<!-- 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>Adding the complete file set #
Copy all four files into src/lib/components/site-header-01/, keeping these relative paths:
SiteHeader.svelte
parts/MobileDrawer.svelte
parts/NavDropdown.svelte
types.tsThe 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:
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:
: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 and content inputs #
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
brand | Brand | Yes | None | { name, href }. The site name is the brand link’s accessible name and its text when no logo snippet is given. |
items | NavItem[] | Yes | None | 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 | None | 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 | None | 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 | None | { label, href }. Primary action link shown at every width and repeated in the drawer. |
actions | Snippet | No | None | 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 #
On this pageChange 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-accentand--site-header-on-accenton 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:flexand the menu buttonlg:hidden; change every lg: utility inSiteHeader.sveltetogether with the (min-width: 64rem) query in itsonMount. The actions snippet switches between the bar and the drawer withsm:flexandsm: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-7xlon the inner wrapper; change it there. - Dropdown panel: width and alignment live in parts/NavDropdown.svelte (
w-72,end-0orstart-0); it is the one elevated surface, with the layered popover shadow declared as --_shadow-popover inSiteHeader.svelte; descriptions render when a child has one. - Drawer: width and side are the
max-w-smandend-0utilities 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.
Public CSS variables
| Variable | Token |
|---|---|
--site-header-surface | surface |
--site-header-text | text |
--site-header-accent | accent |
--site-header-on-accent | onAccent |
Accessibility #
On this page- 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-expandedandaria-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
currentPathmatch getsaria-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 hiddencurrentSectionsuffix on the dropdown; in the panel and the drawer the current link is marked by a fill and a heavier weight. - The
small-screendrawer 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.namethrougharia-labelwhen 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 distinctaria-controlstargets 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.
Release details #
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 Published Current release · 17 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.