Skip to content
Download ZIP

Neutral palette · 9.5 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.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_primary_nav_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_primary_nav_01 version 1.0.0 with variant "neutral", 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
Neutral
Version
1.0.0
Digest
Full digest
sha256-d45b6be41f463f4695701af7b128662b1e840dd23d1379057defe55e026aa1bb

This component needs all 2 files. Download the ZIP

types.ts TypeScript · 1.9 KB Raw
/** 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';
};

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.

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

Example

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>

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
NameTypeRequiredDefaultDescription
itemsNavLink[]YesNone{ label, href } destinations in priority order. Trailing items are the first to move into More.
currentPathstringNoNoneCurrent 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.
overflowbooleanNotrueCollects the items that do not fit into a More disclosure. Off, the row wraps.
moreLabelstringNo'More'Text of the overflow button.
moreCurrentHintstringNo'includes the current page'Visually hidden suffix on the overflow button while the current page is inside it. Translate it with moreLabel.
labelstringNo'Main'Accessible name of the nav landmark.

Customization#

On this page

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.

Public CSS variables

VariableToken
--primary-nav-accentaccent
--primary-nav-on-accentonAccent
--primary-nav-inkink
--primary-nav-mutedmuted
--primary-nav-hairlinehairline
--primary-nav-surfacesurface

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

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.