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 { 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>
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.