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
/** 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';
};
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.