# Primary navigation

> A horizontal row of a website's main destinations with a current-page bar or pill, and a More disclosure that collects the trailing items that do not fit.

- ID: `cmp_primary_nav_01`
- Slug: `primary-nav-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: control
- Primary category: `navigation`
- Detail page: https://pagesugar.com/components/primary-nav-01?variant=neutral
- Preview: https://pagesugar.com/preview/primary-nav-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-d45b6be41f463f4695701af7b128662b1e840dd23d1379057defe55e026aa1bb` |
| `blue` | Blue | no | `sha256-57d787fff7f2f45e0fe98302e93c089211a2a2ba02e3469348f7eafadd35aee1` |

## 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"\>). Without that class the light values apply regardless of the system setting.
- Suggested directory: `src/lib/components/primary-nav-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass the destinations in priority order and the current pathname. The row measures its own width after hydration and moves trailing items into More; before that, and with overflow off, it is a plain wrapping list. It does not read the router, render nested dropdowns or switch to a mobile drawer: the header around it decides those.

Required props: `items`

```svelte
<!-- Illustrative content: replace the links with your own routes. -->
<script lang="ts">
	import { page } from '$app/state';
	import PrimaryNav from '$lib/components/primary-nav-01/PrimaryNav.svelte';
	import type { NavLink } from '$lib/components/primary-nav-01/types';

	const items: NavLink[] = [
		{ label: 'Features', href: '/features' },
		{ label: 'Pricing', href: '/pricing' },
		{ label: 'Docs', href: '/docs' },
		{ label: 'Blog', href: '/blog' }
	];
</script>

<header class="mx-auto max-w-7xl px-4 sm:px-6">
	<a href="/" class="flex min-h-14 items-center font-semibold">Example</a>
	<PrimaryNav {items} currentPath={page.url.pathname} match="prefix" />
</header>
```

Limitations:

- The nav needs a width that does not depend on its content: a block element, or flex-1 with min-w-0 inside a flex row. Sized by its content, it has nothing to measure against.
- Until the component hydrates, and whenever overflow is off, items that do not fit wrap onto another row; the first measurement then collapses them into More, so a very long list shortens by a row as the page becomes interactive.
- Items leave the row from the end of the list only; put the destinations that must stay visible first.
- currentPath is compared as a pathname (query, hash and a trailing slash are ignored); absolute URLs never match. With prefix matching the root / only matches itself.
- Items with a blank label are skipped, since a link with no text has no accessible name.
- Renders nothing when items is empty.
- Dark colours apply inside an ancestor with the class dark; a media-query setup needs its own rule that sets the --primary-nav-\* variables.

## Usage guide

### Primary navigation

The main row of a site's destinations, for use inside a header you build yourself. It marks the
current page and, as the row narrows, moves the trailing items into a More disclosure so the row
never wraps or scrolls.

#### Placing it in a header

The nav measures its own width, so that width has to come from the layout, not from its links:

- On its own row (under the brand, as in the preview), it is a block and fills the row.
- Beside the brand in a flex row, wrap it so it takes the remaining width: `<div class="min-w-0 flex-1"><PrimaryNav … /></div>`.

With the underline indicator the nav draws a hairline under itself and the current page's bar sits
on it. If your header already draws a bottom border, set `--primary-nav-hairline: transparent`
and put the nav flush against that border.

#### Current page

Pass the pathname (`page.url.pathname` in SvelteKit). Use `match="prefix"` when sections have
pages below them: `/blog` is then current for `/blog/planning-in-quarters`, with
`aria-current="true"` because the link is the section, not the page. The longest matching href
wins, so `/docs/api` beats `/docs` for `/docs/api/webhooks`.

If the current item has been collected into More, the More button carries the bar (or pill) and
announces `moreCurrentHint`, so the visitor is never left without a mark.

#### Before hydration

The server renders every item in a wrapping row, and so does the first client frame. Once the
component has hydrated it measures the labels (and measures again when a web font arrives or the
row resizes), then collects what does not fit. Put the destinations that must always be visible
first in `items`.

#### Small screens

The component does not switch to a drawer. At phone widths most items will be in More; if you
prefer a drawer, hide this nav below your breakpoint and show a drawer there instead.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `items` | `NavLink[]` | yes |  | { label, href } destinations in priority order. Trailing items are the first to move into More. |
| `currentPath` | `string` | no |  | Current URL pathname, for example page.url.pathname. The matching item gets aria-current and the current indicator. |
| `match` | 'exact' \| 'prefix' | no | `'exact'` | prefix also marks an item current for pages below it (/blog for /blog/a-post) with aria-current="true"; an exact match always gets aria-current="page". The longest matching href wins. |
| `indicator` | 'underline' \| 'pill' | no | `'underline'` | underline: a 2 px accent bar on the row's hairline. pill: an accent-filled pill and no hairline. |
| `overflow` | `boolean` | no | `true` | Collects the items that do not fit into a More disclosure. Off, the row wraps. |
| `moreLabel` | `string` | no | `'More'` | Text of the overflow button. |
| `moreCurrentHint` | `string` | no | `'includes the current page'` | Visually hidden suffix on the overflow button while the current page is inside it. Translate it with moreLabel. |
| `label` | `string` | no | `'Main'` | Accessible name of the nav landmark. |

## Customization

Change content through props, recolour through six --primary-nav-\* CSS variables on any ancestor, and switch the current-page mark with indicator.

- Colours: set --primary-nav-accent (the bar, the pill and the focus ring), --primary-nav-on-accent (text on the pill), --primary-nav-ink (current and hovered text), --primary-nav-muted (resting text), --primary-nav-hairline (the rule under the underline row) and --primary-nav-surface (the More panel). Hover, current and pressed fills are mixed from the ink.
- Dark or tinted header: for a zinc-950 band set --primary-nav-ink: #fafafa, --primary-nav-muted: #a1a1aa, --primary-nav-hairline: rgb(255 255 255 / 0.1), --primary-nav-surface: #18181b and --primary-nav-accent: #fafafa with --primary-nav-on-accent: #09090b.
- Hairline: set --primary-nav-hairline: transparent when the header draws its own bottom border, and place the nav flush with that border so the bar sits on it.
- Priority: order items by importance; the row keeps the leading items and collects from the end.
- Row height and density: the row is min-h-12 with min-h-8 links (min-h-14 rows and 44 px links under a coarse pointer), in PrimaryNav.svelte's itemClass and the ul.
- Panel: width and placement are the w-max, min-w-48 and max-w utilities on the More panel; it hangs from More's end edge, or from its start edge when that keeps it further inside the viewport, and re-places itself on resize while open.
- Overflow off: pass overflow={false} for a plain wrapping list with no measurement and no More button.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--primary-nav-accent` |
| `onAccent` | `--primary-nav-on-accent` |
| `ink` | `--primary-nav-ink` |
| `muted` | `--primary-nav-muted` |
| `hairline` | `--primary-nav-hairline` |
| `surface` | `--primary-nav-surface` |

## Accessibility

- One nav landmark named by label; give it a distinct name if the page has other nav regions.
- Each destination appears once in the accessibility tree: an item is either in the row or in the More panel, never both. The measuring copy of the labels is invisible, aria-hidden and inert.
- The exact match gets aria-current="page"; with prefix matching a section containing the page gets aria-current="true". The current item is marked by a 2 px bar (or a filled pill) and a heavier weight, not by colour alone.
- More follows the APG disclosure navigation pattern: a button with aria-expanded and aria-controls and a plain list of links, no menu roles. Enter or Space toggles it, Down Arrow opens it and enters the list, Up, Down, Home and End move between links, and Escape, focus leaving or a press outside closes it. Escape returns focus to More.
- When the current page is inside More, the button carries the current indicator and a visually hidden moreCurrentHint suffix; translate it with moreLabel.
- Focus is never dropped by a resize: a focused link collected into More hands focus to More, a focused panel link that returns to the row keeps focus there, and a focused More that disappears hands focus to the first item it held.
- Links and More show a 2 px focus outline in the accent colour with a 2 px offset; links grow to 44 px under a coarse pointer.
- IDs come from $props.id(), so two navs on one page keep distinct aria-controls targets.

Known limitations:

- Before hydration there is no More button; every item is in the wrapping list, so nothing is unreachable.
- Hover does not open More; it opens on click and keyboard only.

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `PrimaryNav.svelte`
- Suggested directory: `src/lib/components/primary-nav-01`
- Files: 2
- Artifact digest: `sha256-d45b6be41f463f4695701af7b128662b1e840dd23d1379057defe55e026aa1bb`

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

#### `PrimaryNav.svelte`

Role: entry · 15735 bytes · SHA-256 `122498aa2199341654951b7b0805114616e4ee87476b6bb1b3e44c412d3b2bb5`

```svelte
<script lang="ts">
	import { onMount, tick, untrack } from 'svelte';
	import {
		currentIndex,
		currentState,
		type NavIndicator,
		type NavLink,
		type NavMatch
	} from './types';

	interface Props {
		/** Top-level destinations in priority order; trailing items are the first to move into More. */
		items: NavLink[];
		/** Current URL pathname, for example page.url.pathname. */
		currentPath?: string;
		/** `prefix` also marks a section current for any page below it. */
		match?: NavMatch;
		/** How the current page is marked in the row. */
		indicator?: NavIndicator;
		/** Collect the items that do not fit into a More disclosure. Off, the row wraps. */
		overflow?: boolean;
		/** Text of the overflow button. */
		moreLabel?: string;
		/** Hidden suffix on the overflow button while the current page is inside it. */
		moreCurrentHint?: string;
		/** Accessible name of the nav landmark. */
		label?: string;
	}

	let {
		items: givenItems,
		currentPath,
		match = 'exact',
		indicator = 'underline',
		overflow = true,
		moreLabel = 'More',
		moreCurrentHint = 'includes the current page',
		label = 'Main'
	}: Props = $props();

	const uid = $props.id();
	const panelId = `${uid}-more`;

	/* A link with no text has no accessible name, so an item with a blank label is not rendered. */
	const items = $derived(givenItems.filter((item) => item.label.trim() !== ''));
	const current = $derived(currentIndex(items, currentPath, match));

	/*
	 * How many items fit on the row, or null before the first measurement. The server and the
	 * first client frame render every item in a wrapping row, so nothing is hidden before
	 * hydration; the measurement then collects the trailing items that do not fit into More.
	 */
	let fitCount = $state<number | null>(null);
	const count = $derived(
		overflow && fitCount !== null ? Math.min(fitCount, items.length) : items.length
	);
	const inline = $derived(items.slice(0, count));
	const collected = $derived(items.slice(count));
	const currentInMore = $derived(current >= count);

	let hydrated = $state(false);
	let open = $state(false);
	let placement = $state<'end' | 'start'>('end');

	let list = $state<HTMLUListElement>();
	let ruler = $state<HTMLDivElement>();
	let moreItem = $state<HTMLLIElement>();
	let moreButton = $state<HTMLButtonElement>();
	let panel = $state<HTMLDivElement>();

	onMount(() => {
		hydrated = true;
	});

	/*
	 * Widths come from the ruler, an invisible copy of every label set at the weight it renders
	 * at, never from the row itself. The row's width does not depend on how many items it holds,
	 * so the count is a pure function of two measurements and cannot oscillate at a boundary.
	 */
	function measure() {
		if (!list || !ruler) return;
		const available = list.clientWidth;
		const marks = Array.from(ruler.children) as HTMLElement[];
		const widths = marks.slice(0, items.length).map((mark) => mark.getBoundingClientRect().width);
		const moreWidth = marks[items.length]?.getBoundingClientRect().width ?? 0;
		const gap = parseFloat(getComputedStyle(list).columnGap) || 0;
		const all =
			widths.reduce((sum, width) => sum + width, 0) + gap * Math.max(0, widths.length - 1);
		let next = widths.length;
		if (all > available + 0.5) {
			let used = 0;
			next = 0;
			for (const width of widths) {
				const withItem = used + (next > 0 ? gap : 0) + width;
				if (withItem + gap + moreWidth > available + 0.5) break;
				used = withItem;
				next += 1;
			}
		}
		if (next === fitCount) return;
		// Focus follows its destination between the row and the panel, so a resize never drops it.
		const active = document.activeElement;
		const focusedMore = active === moreButton;
		const focusedItem =
			active instanceof HTMLElement && list.contains(active) && active.dataset.itemIndex
				? Number(active.dataset.itemIndex)
				: -1;
		const before = count;
		fitCount = next;
		if (focusedItem === -1 && !focusedMore) return;
		tick().then(() => {
			if (!list || list.contains(document.activeElement)) return;
			// A collected link lands on More; More itself, gone, hands focus to the first item it held.
			const target =
				focusedItem !== -1 && focusedItem < next ? focusedItem : focusedMore ? before : -1;
			const link =
				target === -1
					? null
					: list.querySelector<HTMLElement>(`:scope > li > a[data-item-index="${target}"]`);
			(link ?? moreButton)?.focus();
		});
	}

	$effect(() => {
		// The current item is set heavier, so the fit changes with it even when the ruler's total does not.
		void current;
		void items;
		if (!overflow || !list || !ruler) return;
		// Measure on the next frame: changing the row inside the observer's own callback loops it.
		let frame = 0;
		const schedule = () => {
			cancelAnimationFrame(frame);
			frame = requestAnimationFrame(measure);
		};
		// The ruler resizes when a web font lands or a label changes; the list when its column does.
		const observer = new ResizeObserver(schedule);
		observer.observe(list);
		observer.observe(ruler);
		untrack(measure);
		return () => {
			cancelAnimationFrame(frame);
			observer.disconnect();
		};
	});

	/* Nothing left to collect, nothing to show. */
	$effect(() => {
		if (collected.length === 0) open = false;
	});

	/*
	 * The panel hangs from More's end edge. When that would cross the viewport's gutter it hangs
	 * from the start edge instead, and when both would, from whichever crosses less.
	 */
	function place() {
		if (!panel || !moreItem) return;
		const gutter = 8;
		const viewport = document.documentElement.clientWidth;
		const anchor = moreItem.getBoundingClientRect();
		const width = panel.offsetWidth;
		const rtl = getComputedStyle(panel).direction === 'rtl';
		// Left edges of the panel for each placement, in physical pixels.
		const endLeft = rtl ? anchor.left : anchor.right - width;
		const startLeft = rtl ? anchor.right - width : anchor.left;
		const spill = (left: number) =>
			Math.max(0, gutter - left) + Math.max(0, left + width - (viewport - gutter));
		placement = spill(endLeft) <= spill(startLeft) ? 'end' : 'start';
	}

	$effect(() => {
		if (open && panel) untrack(place);
		else placement = 'end';
	});

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

	function close(returnFocus: boolean) {
		open = false;
		if (returnFocus) moreButton?.focus();
	}

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

	/* Escape from anywhere, and the optional arrow keys of the APG disclosure navigation pattern. */
	function onWindowKeydown(event: KeyboardEvent) {
		if (event.key === 'Escape') {
			event.preventDefault();
			close(true);
			return;
		}
		if (!(event.target instanceof Node) || !panel?.contains(event.target)) return;
		const links = panelLinks();
		const index = links.indexOf(document.activeElement as HTMLAnchorElement);
		let next: HTMLAnchorElement | undefined;
		if (event.key === 'ArrowDown') next = links[Math.min(index + 1, links.length - 1)];
		else if (event.key === 'ArrowUp') next = index <= 0 ? undefined : links[index - 1];
		else if (event.key === 'Home') next = links[0];
		else if (event.key === 'End') next = links[links.length - 1];
		else return;
		event.preventDefault();
		if (next) next.focus();
		else moreButton?.focus();
	}

	/* Focus or a press anywhere outside the More item closes it. */
	function onOutside(event: Event) {
		if (event.target instanceof Node && moreItem && !moreItem.contains(event.target)) open = false;
	}

	/*
	 * Spacing set: 4, 8 and 12 px. Rows are 48 px and links 32 px, so a focus ring clears the current
	 * bar under them; under a coarse pointer, 56 px rows hold 44 px links for the same clearance.
	 */
	const focusClass =
		'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]';
	const itemClass = $derived([
		'inline-flex min-h-8 items-center px-3 text-sm whitespace-nowrap transition-[color,background-color,box-shadow] duration-150 ease-[cubic-bezier(.2,0,0,1)] active:duration-[80ms] pointer-coarse:min-h-11',
		indicator === 'pill' ? 'rounded-full' : 'rounded-lg',
		focusClass
	]);
	const restClass =
		'font-medium text-[var(--_muted)] hover:bg-[var(--_fill)] hover:text-[var(--_ink)] active:bg-[var(--_fill-pressed)]';
	const currentClass = $derived(
		indicator === 'pill'
			? // Hover and press darken the pill from above; with a light label on the accent, contrast rises.
				'bg-[var(--_accent)] font-semibold text-[var(--_on-accent)] hover:shadow-[inset_0_0_0_999px_rgb(0_0_0/0.1)] active:shadow-[inset_0_0_0_999px_rgb(0_0_0/0.18)]'
			: 'font-semibold text-[var(--_ink)] hover:bg-[var(--_fill)] active:bg-[var(--_fill-pressed)]'
	);
	/* The current page in the underline row: a 2 px accent bar resting on the row's hairline. A border, so forced colours keep it. */
	const barClass =
		"after:absolute after:inset-x-3 after:-bottom-px after:border-b-2 after:border-[var(--_accent)] after:content-['']";
</script>

<!--
	Every label reserves its semibold width, so marking a different page current never moves the
	labels after it, and the ruler can measure each one at a single weight.
-->
{#snippet labelText(text: string)}
	<span
		data-label={text}
		class="grid justify-items-start before:invisible before:col-start-1 before:row-start-1 before:h-0 before:font-semibold before:content-[attr(data-label)]"
		><span class="col-start-1 row-start-1">{text}</span></span
	>
{/snippet}

<svelte:window onkeydown={open ? onWindowKeydown : undefined} onresize={open ? place : undefined} />
<svelte:document
	onfocusin={open ? onOutside : undefined}
	onpointerdown={open ? onOutside : undefined}
/>

{#if items.length > 0}
	<nav
		aria-label={label}
		class={[
			'primary-nav relative min-w-0',
			indicator === 'underline' && 'border-b border-[var(--_hairline)]'
		]}
	>
		<ul
			bind:this={list}
			role="list"
			class={[
				'flex min-h-12 gap-x-1 pointer-coarse:min-h-14',
				overflow && fitCount !== null ? 'flex-nowrap' : 'flex-wrap',
				// The row pulls out by the link padding, so label text shares the header's left edge.
				'-mx-3',
				indicator === 'underline' ? 'items-stretch' : 'items-center'
			]}
		>
			{#each inline as item, index (index)}
				{@const state = currentState(items, index, current, currentPath)}
				<li class={['relative flex items-center', state && indicator === 'underline' && barClass]}>
					<a
						href={item.href}
						aria-current={state}
						data-item-index={index}
						class={[itemClass, state ? currentClass : restClass]}
					>
						{@render labelText(item.label)}
					</a>
				</li>
			{/each}

			{#if collected.length > 0}
				<li
					bind:this={moreItem}
					class={[
						'relative flex items-center',
						currentInMore && indicator === 'underline' && barClass
					]}
				>
					<button
						bind:this={moreButton}
						type="button"
						aria-expanded={open}
						aria-controls={panelId}
						onclick={() => (open = !open)}
						onkeydown={onButtonKeydown}
						class={[
							itemClass,
							'gap-x-1',
							currentInMore
								? currentClass
								: open
									? 'bg-[var(--_fill)] font-medium text-[var(--_ink)] active:bg-[var(--_fill-pressed)]'
									: restClass
						]}
					>
						{@render labelText(moreLabel)}
						{#if currentInMore}
							<span class="sr-only">, {moreCurrentHint}</span>
						{/if}
						<svg
							class={[
								'size-4 shrink-0 transition-transform motion-reduce:transition-none',
								// The chevron keeps time with the 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: 12 px radius, 8 px padding, so its 4 px rows share its centre. -->
					<div
						bind:this={panel}
						id={panelId}
						hidden={!open}
						class={[
							'absolute top-full z-30 mt-1 w-max max-w-[min(18rem,calc(100vw-2rem))] min-w-48 rounded-xl bg-[var(--_surface)] p-2 shadow-[var(--_shadow-popover)]',
							'transition-[opacity,translate,display] transition-discrete motion-reduce:transition-none',
							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)]',
							placement === 'end' ? 'end-0' : 'start-0'
						]}
					>
						<ul role="list" class="flex flex-col">
							{#each collected as item, offset (count + offset)}
								{@const state = currentState(items, count + offset, current, currentPath)}
								<li>
									<a
										href={item.href}
										aria-current={state}
										data-item-index={count + offset}
										onclick={() => close(false)}
										class={[
											'flex min-h-9 items-center rounded-sm px-3 py-2 text-sm break-words transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] active:bg-[var(--_fill-pressed)] active:duration-[80ms] pointer-coarse:min-h-11',
											state
												? 'bg-[var(--_fill-current)] font-semibold text-[var(--_ink)]'
												: 'font-medium text-[var(--_muted)] hover:bg-[var(--_fill)] hover:text-[var(--_ink)]',
											focusClass
										]}
									>
										{item.label}
									</a>
								</li>
							{/each}
						</ul>
					</div>
				</li>
			{/if}
		</ul>

		{#if overflow && hydrated}
			<!-- The ruler: every label at its rendered weight, plus More, invisible and out of the accessibility tree. -->
			<div
				bind:this={ruler}
				aria-hidden="true"
				inert
				class="pointer-events-none invisible absolute start-0 top-0 flex h-0 w-max overflow-hidden"
			>
				{#each items as item, index (index)}
					<span class="px-3 text-sm font-semibold whitespace-nowrap">{item.label}</span>
				{/each}
				<span class="inline-flex gap-x-1 px-3 text-sm font-semibold whitespace-nowrap"
					>{moreLabel}<span class="size-4 shrink-0"></span></span
				>
			</div>
		{/if}
	</nav>
{/if}

<style>
	/* Public tokens: set --primary-nav-* on the nav or any ancestor to retone it. */
	.primary-nav {
		--_accent: var(--primary-nav-accent, #18181b);
		--_on-accent: var(--primary-nav-on-accent, #ffffff);
		--_ink: var(--primary-nav-ink, #18181b);
		--_muted: var(--primary-nav-muted, #52525b);
		--_hairline: var(--primary-nav-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--primary-nav-surface, #ffffff);
		/* Hover, current and pressed fills are mixed from the ink, so every palette gets them. */
		--_fill: color-mix(in oklab, var(--_ink) 5%, transparent);
		--_fill-current: color-mix(in oklab, var(--_ink) 8%, transparent);
		--_fill-pressed: color-mix(in oklab, var(--_ink) 11%, transparent);
		--_shadow-popover:
			0 0 0 1px rgb(0 0 0 / 0.05), 0 4px 6px -1px rgb(0 0 0 / 0.07),
			0 10px 15px -3px rgb(0 0 0 / 0.05);
	}

	:global(.dark) .primary-nav {
		--_accent: var(--primary-nav-accent, #fafafa);
		--_on-accent: var(--primary-nav-on-accent, #09090b);
		--_ink: var(--primary-nav-ink, #fafafa);
		--_muted: var(--primary-nav-muted, #a1a1aa);
		--_hairline: var(--primary-nav-hairline, rgb(255 255 255 / 0.1));
		--_surface: var(--primary-nav-surface, #18181b);
		/* Black shadows vanish on dark; the panel is lifted by a light ring and an inset top edge. */
		--_shadow-popover: 0 0 0 1px rgb(255 255 255 / 0.1), inset 0 1px 0 rgb(255 255 255 / 0.08);
	}
</style>
```

#### `types.ts`

Role: types · 1957 bytes · SHA-256 `2bd35972b01524c6c937198ecdb5e84424c0faeef6d836786eaf681f2ae2e575`

```ts
/** One top-level destination. */
export interface NavLink {
	label: string;
	href: string;
}

/** How currentPath is compared with each href. */
export type NavMatch = 'exact' | 'prefix';

/** What marks the current page in the row. */
export type NavIndicator = 'underline' | 'pill';

/** The aria-current value for a link: the page itself, a section containing it, or neither. */
export type CurrentState = 'page' | 'true' | undefined;

/** Pathname only: no query, no hash, no trailing slash (except the root). */
export const normalizePath = (path: string): string => {
	const bare = path.split(/[?#]/, 1)[0];
	return bare.length > 1 ? bare.replace(/\/+$/, '') : bare;
};

/**
 * Index of the item that is current, or -1. An exact match always wins. With `prefix`, the
 * item whose href is the longest leading segment of the path is current as a section, so
 * `/blog` is current for `/blog/planning-in-quarters`; the root `/` only ever matches itself.
 */
export function currentIndex(
	items: NavLink[],
	currentPath: string | undefined,
	match: NavMatch
): number {
	if (!currentPath) return -1;
	const path = normalizePath(currentPath);
	const exact = items.findIndex((item) => normalizePath(item.href) === path);
	if (exact !== -1 || match === 'exact') return exact;
	let best = -1;
	let bestLength = 0;
	items.forEach((item, index) => {
		const href = normalizePath(item.href);
		if (href === '/' || !href.startsWith('/')) return;
		if (path.startsWith(`${href}/`) && href.length > bestLength) {
			best = index;
			bestLength = href.length;
		}
	});
	return best;
}

/** aria-current for the item at `index`, given the current index from currentIndex. */
export const currentState = (
	items: NavLink[],
	index: number,
	current: number,
	currentPath: string | undefined
): CurrentState => {
	if (index !== current || !currentPath) return undefined;
	return normalizePath(items[index].href) === normalizePath(currentPath) ? 'page' : 'true';
};
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-d45b6be41f463f4695701af7b128662b1e840dd23d1379057defe55e026aa1bb`
- Entry: `PrimaryNav.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_primary_nav_01/1.0.0/neutral/sha256-d45b6be41f463f4695701af7b128662b1e840dd23d1379057defe55e026aa1bb/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_primary_nav_01/1.0.0/neutral/sha256-d45b6be41f463f4695701af7b128662b1e840dd23d1379057defe55e026aa1bb/bundle.zip (9697 bytes, sha256 `93556d813ac6c03f87baf84822632f82a42630a0a1acac3e132bb93792421b0e`)

Files:

- `PrimaryNav.svelte` (entry, 15735 bytes): https://pagesugar.com/artifacts/cmp_primary_nav_01/1.0.0/neutral/sha256-d45b6be41f463f4695701af7b128662b1e840dd23d1379057defe55e026aa1bb/source/PrimaryNav.svelte
- `types.ts` (types, 1957 bytes): https://pagesugar.com/artifacts/cmp_primary_nav_01/1.0.0/neutral/sha256-d45b6be41f463f4695701af7b128662b1e840dd23d1379057defe55e026aa1bb/source/types.ts
