Skip to content
Download ZIP

Inverted palette · 16.0 KB ZIP File receipt View as Markdown View code

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

Inverted palette · version 1.0.0
Artifact sha256-786fef0e…fe456da3 sha256-786fef0e98569d9036368816d17cac718c393f6dbb40d1b93953aaa4fe456da3
parts/MobileDrawer.svelte Svelte · 10.0 KB Raw
<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>

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.

Suggested location
src/lib/components/site-header-01
Required props
branditems

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.

Example

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>

Adding the complete file set #

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

Plain 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 and content inputs #

On this page
NameTypeRequiredDefaultDescription
brandBrandYesNone{ name, href }. The site name is the brand link’s accessible name and its text when no logo snippet is given.
itemsNavItem[]YesNonePrimary 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.
currentPathstringNoNoneCurrent URL pathname, for example page.url.pathname. The exact match gets aria-current="page"; a dropdown containing it gets a visible indicator.
ctaHeaderLinkNoNone{ label, href }. Primary action link shown at every width and repeated in the drawer.
actionsSnippetNoNoneExtra actions such as a sign-in link or mini cart. Shown in the bar from sm up and always in the drawer footer.
stickybooleanNofalseSticks 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.
navLabelstringNo'Main'Accessible name of the nav landmarks (inline list, fallback list and drawer).
labelsPartial<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 page

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.

Public CSS variables

VariableToken
--site-header-surfacesurface
--site-header-texttext
--site-header-accentaccent
--site-header-on-accentonAccent

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

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.