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.
cmp_primary_nav_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.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_primary_nav_01 version 1.0.0 with variant "blue", 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.Not connected yet? Set up the MCP server
Code
- Palette
- Blue
- Version
- 1.0.0
- Digest
Full digest
sha256-57d787fff7f2f45e0fe98302e93c089211a2a2ba02e3469348f7eafadd35aee1
This component needs all 2 files. Download the ZIP
<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, #2563eb);
--_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, #60a5fa);
--_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>
Usage#
On this pagePass 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.
- Suggested location
src/lib/components/primary-nav-01- Required props
items
Limitations
- The nav needs a width that does not depend on its content: a block element, or
flex-1withmin-w-0inside 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.
currentPathis 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.
Example
<!-- 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>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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
items | NavLink[] | Yes | None | { label, href } destinations in priority order. Trailing items are the first to move into More. |
currentPath | string | No | None | 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#
On this pageChange 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-950band set--primary-nav-ink:#fafafa,--primary-nav-muted:#a1a1aa,--primary-nav-hairline: rgb(255 255 255 / 0.1),--primary-nav-surface:#18181band--primary-nav-accent:#fafafawith--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-12withmin-h-8links (min-h-14rows and 44 px links under a coarse pointer), inPrimaryNav.svelte'sitemClassand the ul. - Panel: width and placement are the
w-max,min-w-48and 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.
Public CSS variables
| Variable | Token |
|---|---|
--primary-nav-accent | accent |
--primary-nav-on-accent | onAccent |
--primary-nav-ink | ink |
--primary-nav-muted | muted |
--primary-nav-hairline | hairline |
--primary-nav-surface | surface |
Accessibility#
On this page- 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-hiddenand inert. - The exact match gets
aria-current="page"; with prefix matching a section containing the page getsaria-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-expandedandaria-controlsand 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
moreCurrentHintsuffix; translate it withmoreLabel. - 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 distinctaria-controlstargets.
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.
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 · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.