Skip to content
Download ZIP

Neutral palette · 12.3 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_dropdown_nav_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_dropdown_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-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756
DropdownNav.svelte Svelte · 7.0 KB Raw
<script lang="ts">
	import type { Snippet } from 'svelte';
	import NavGroup from './parts/NavGroup.svelte';
	import {
		isCurrentPath,
		isGroup,
		renderableItems,
		type NavGroup as Group,
		type NavItem,
		type OpenReason
	} from './types';

	interface Props {
		/** Top-level links, and groups whose children open in a panel. */
		items: NavItem[];
		/** Current URL pathname, for example page.url.pathname. Marks the current link and its group. */
		currentPath?: string;
		/** Also open a group after the mouse rests on it for 150 ms; click and keyboard still work. */
		openOnHover?: boolean;
		/** Text of a group's overview link, the first row of its panel, when the group has an href. */
		overviewLabel?: (groupLabel: string) => string;
		/** Content for the shelf at the foot of each panel; render nothing to leave a group without one. */
		panelFooter?: Snippet<[Group]>;
		/** Visually hidden suffix on a group button whose section holds the current page. */
		currentSectionLabel?: string;
		/** Accessible name of the nav landmark. */
		label?: string;
	}

	let {
		items: givenItems,
		currentPath,
		openOnHover = false,
		overviewLabel = (groupLabel: string) => `${groupLabel} overview`,
		panelFooter,
		currentSectionLabel = 'current section',
		label = 'Main'
	}: Props = $props();

	const uid = $props.id();
	const items = $derived(renderableItems(givenItems));

	/* One panel at a time: opening a group closes whichever was open. */
	let openIndex = $state<number | null>(null);
	let openKey = $state<string | null>(null);
	let reason = $state<OpenReason | null>(null);

	const keyOf = (item: NavItem | undefined) =>
		item && isGroup(item) ? (item.id ?? item.label) : null;

	/* Items can change under an open panel; it stays open only while the same group is in that place. */
	$effect(() => {
		if (openIndex === null) return;
		if (keyOf(items[openIndex]) !== openKey) {
			openIndex = null;
			openKey = null;
			reason = null;
		}
	});

	function openGroup(index: number, why: OpenReason) {
		openIndex = index;
		openKey = keyOf(items[index]);
		reason = why;
	}

	function closeGroup(index: number) {
		if (openIndex !== index) return;
		openIndex = null;
		openKey = null;
		reason = null;
	}

	/*
	 * The optional arrow keys of the APG disclosure navigation example, on the top-level row only:
	 * Left and Right move between items in reading order (mirrored right to left), Home and End jump
	 * to the ends. Tab is never trapped.
	 */
	function onRowKeydown(event: KeyboardEvent) {
		const target = event.target;
		if (!(target instanceof HTMLElement) || !target.hasAttribute('data-top-level')) return;
		const list = event.currentTarget as HTMLElement;
		const controls = Array.from(list.querySelectorAll<HTMLElement>('[data-top-level]'));
		const index = controls.indexOf(target);
		const rtl = getComputedStyle(list).direction === 'rtl';
		const forward = rtl ? 'ArrowLeft' : 'ArrowRight';
		const back = rtl ? 'ArrowRight' : 'ArrowLeft';
		let next: HTMLElement | undefined;
		if (event.key === forward) next = controls[Math.min(index + 1, controls.length - 1)];
		else if (event.key === back) next = controls[Math.max(index - 1, 0)];
		else if (event.key === 'Home') next = controls[0];
		else if (event.key === 'End') next = controls[controls.length - 1];
		else return;
		event.preventDefault();
		next?.focus();
	}

	const linkClass =
		'relative inline-flex min-h-9 items-center rounded-lg px-3 text-sm font-medium break-words transition-colors duration-150 ease-(--_ease) hover:bg-(--_hover) active:bg-(--_fill-pressed) active:duration-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) pointer-coarse:min-h-11';
</script>

{#if items.length > 0}
	<nav aria-label={label} class="dropdown-nav">
		<!-- The row pulls out by the item padding, so label text shares the header's start edge. -->
		<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
		<ul role="list" class="-mx-3 flex flex-wrap items-center gap-1" onkeydown={onRowKeydown}>
			{#each items as item, index (index)}
				{#if isGroup(item)}
					<NavGroup
						group={item}
						id="{uid}-panel-{index}"
						open={openIndex === index}
						reason={openIndex === index ? reason : null}
						{currentPath}
						{openOnHover}
						{overviewLabel}
						{currentSectionLabel}
						{panelFooter}
						onOpen={(why) => openGroup(index, why)}
						onClose={() => closeGroup(index)}
					/>
				{:else}
					{@const active = isCurrentPath(item.href, currentPath)}
					<li class="flex items-center">
						<a
							href={item.href}
							data-top-level
							aria-current={active ? 'page' : undefined}
							class={[linkClass, active ? 'text-(--_ink)' : 'text-(--_muted) hover:text-(--_ink)']}
						>
							<!-- Same mark as a current section: semibold, width reserved, a 4 px accent dot under the label. -->
							<span
								data-label={item.label}
								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={[
										'relative col-start-1 row-start-1',
										active &&
											"font-semibold after:absolute after:inset-x-0 after:-bottom-1 after:mx-auto after:size-1 after:rounded-full after:border-2 after:border-(--_accent) after:content-['']"
									]}>{item.label}</span
								>
							</span>
						</a>
					</li>
				{/if}
			{/each}
		</ul>
	</nav>
{/if}

<style>
	/* Public tokens: set --dropdown-nav-* on the nav or any ancestor to retone it. */
	.dropdown-nav {
		--_accent: var(--dropdown-nav-accent, #18181b);
		--_ink: var(--dropdown-nav-ink, #18181b);
		--_muted: var(--dropdown-nav-muted, #52525b);
		--_hairline: var(--dropdown-nav-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--dropdown-nav-surface, #ffffff);
		--_hover: var(--dropdown-nav-hover, rgb(0 0 0 / 0.05));
		/* Current, pressed and shelf fills are mixed from the ink, so a retone carries them along. */
		--_fill-current: color-mix(in oklab, var(--_ink) 7%, transparent);
		--_fill-pressed: color-mix(in oklab, var(--_ink) 10%, transparent);
		--_shelf: color-mix(in oklab, var(--_ink) 3%, var(--_surface));
		/* A white top edge: invisible on a light panel, the lit rim of a dark one, retoned or not. */
		--_highlight: rgb(255 255 255 / 0.08);
		/* The state-change curve shared by the links, the triggers and the panel rows. */
		--_ease: cubic-bezier(0.2, 0, 0, 1);
		/* The popover elevation, lit from above; its ring is the hairline so a dark retone keeps an edge. */
		--_shadow-popover:
			0 0 0 1px var(--_hairline), 0 4px 6px -1px rgb(0 0 0 / 0.07),
			0 10px 15px -3px rgb(0 0 0 / 0.05), inset 0 1px 0 var(--_highlight);
	}

	:global(.dark) .dropdown-nav {
		--_accent: var(--dropdown-nav-accent, #fafafa);
		--_ink: var(--dropdown-nav-ink, #fafafa);
		--_muted: var(--dropdown-nav-muted, #a1a1aa);
		--_hairline: var(--dropdown-nav-hairline, rgb(255 255 255 / 0.1));
		--_surface: var(--dropdown-nav-surface, #18181b);
		--_hover: var(--dropdown-nav-hover, rgb(255 255 255 / 0.07));
		/* Black shadows vanish on dark; the panel is lifted by its light ring and an inset top edge. */
	}
</style>

Pass top-level links and groups, and the current pathname. Each group is a button that opens a single-column panel of its children; a group with an href gets an overview link as its first row. It does not render nested submenus, multi-column mega-menu panels or a mobile drawer: pair it with a drawer or accordion below your breakpoint.

Suggested location
src/lib/components/dropdown-nav-01
Required props
items

Limitations

  • One level only: a group's children are links, never groups of their own.
  • Panels need JavaScript to open. Before hydration, or without it, group buttons do nothing and only top-level links and the overview pages you link elsewhere are reachable; link each section's page from somewhere else on the site too.
  • The row wraps rather than collapsing into a menu button; at phone widths show a drawer or accordion instead.
  • currentPath is compared as a pathname (query, hash and a trailing slash are ignored) and only exactly; a group is the current section when its own href or one of its children matches.
  • Items and children with a blank label are skipped, and a group with no children renders as a plain link when it has an href, or not at all.
  • 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 --dropdown-nav-* variables.
  • The open panel follows its group by id (or label when there is no id); if items change so that a different group sits in its place, the panel closes.

Example

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

	const items: NavItem[] = [
		{
			label: 'Product',
			href: '/product',
			children: [
				{ label: 'Boards', href: '/product/boards', description: 'Plan work in columns.' },
				{ label: 'Timelines', href: '/product/timelines', description: 'Milestones on one schedule.' }
			]
		},
		{ label: 'Pricing', href: '/pricing' },
		{ label: 'Blog', href: '/blog' }
	];
</script>

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

Dropdown navigation#

A header's top-level row where some items open a short panel of related pages. Use it when a section has five to ten destinations worth naming in the header; when it has three columns of them and a promotion, reach for a mega menu instead.

A group is { label, href?, description?, id?, children }. Its button only opens the panel, so the section's own page, when there is one, becomes the panel's first row: the overview link, labelled by overviewLabel ("Product overview" by default) with the group's description under it. Leave href off a group that has no landing page.

Placing it in a header#

Put the nav beside the brand in a flex row. The row pulls out by its items' 12 px padding, so label text lines up with the edge of whatever sits above or below it. Panels hang 8 px under their trigger, pulled out by their own padding so each row's text starts under the trigger's label; near the end of the viewport they hang from the trigger's end edge instead.

Give the header room for the panel to overlap the page (it is absolutely positioned with z-30), and do not clip it with overflow: hidden on an ancestor.

panelFooter receives the group and renders into a raised shelf across the panel's foot. One line and one link reads best: a price note, a support line, a link to the full index. Render nothing for a group and its shelf disappears.

Small screens#

The row wraps; it does not become a menu button. Below your breakpoint, hide this nav and show a drawer or an accordion of the same items.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
itemsNavItem[]YesNoneTop-level items: { label, href } links, and { label, href?, description?, id?, children } groups whose children ({ label, href, description? }) open in a panel.
currentPathstringNoNoneCurrent URL pathname, for example page.url.pathname. The matching link gets aria-current="page" and its group button the current-section mark: semibold with a small accent dot.
openOnHoverbooleanNofalseAlso opens a group when a mouse rests on it for 150 ms, and closes it 200 ms after the pointer leaves. Click, touch and keyboard work the same either way.
overviewLabel(groupLabel: string) => stringNo(groupLabel) => `${groupLabel} overview`Text of the overview link, the first row of a group's panel when the group has an href. Translate it with the rest of the nav.
panelFooterSnippet<[NavGroup]>NoNoneContent for the shelf at the foot of each panel, given the group. Render nothing for a group and its shelf is hidden.
currentSectionLabelstringNo'current section'Visually hidden suffix on a group button whose panel holds the current page.
labelstringNo'Main'Accessible name of the nav landmark.

Customization#

On this page

Change content through props and the panelFooter snippet, and recolour through six --dropdown-nav-* CSS variables on any ancestor.

  • Colours: --dropdown-nav-accent is the current dot and focus ring; -ink is labels and hovered text; -muted is resting top-level text and descriptions; -hairline is the panel ring, overview divider and shelf edge; -surface is the panel; -hover is the hover fill. Current, pressed and shelf fills mix from the ink.
  • Dark header: on a zinc-950 band set --dropdown-nav-ink: #fafafa, --dropdown-nav-muted: #a1a1aa, --dropdown-nav-hairline: rgb(255 255 255 / 0.1), --dropdown-nav-surface: #18181b, --dropdown-nav-hover: rgb(255 255 255 / 0.07) and --dropdown-nav-accent: #fafafa on the header element.
  • Cream or tinted page: set --dropdown-nav-surface to a lighter step of the page colour and --dropdown-nav-hover to a darker one, for example #fffdf8 and rgb(120 80 20 / 0.06).
  • Panel width: w-80 (320 px) on the panel in parts/NavGroup.svelte; it is capped at the viewport less 16 px.
  • Overview link: omit a group's href for no overview row, or pass overviewLabel to change its text, for example (label) => `All ${label.toLowerCase()}`.
  • Footer shelf: pass panelFooter and switch on group.id (or group.label); keep it to one line and at most one link.
  • Hover opening: openOnHover={true} adds it for a mouse; the 150 ms and 200 ms delays are in NavGroup.svelte's hover handlers.
  • Density: top-level items and rows are 36 px tall (44 px under a coarse pointer), set by min-h-9 and pointer-coarse:min-h-11.

Public CSS variables

VariableToken
--dropdown-nav-accentaccent
--dropdown-nav-inkink
--dropdown-nav-mutedmuted
--dropdown-nav-hairlinehairline
--dropdown-nav-surfacesurface
--dropdown-nav-hoverhover

Accessibility#

On this page
  • One nav landmark named by label; give it a distinct name if the page has other nav regions.
  • Follows the APG disclosure navigation pattern: each group is a button with aria-expanded and aria-controls, and its panel is a plain list of links. No menu or menuitem roles.
  • A group's own page is never on its button; it is the overview link, the first row of the panel.
  • Enter or Space toggles a panel; Down Arrow on a button opens it and moves into the links; Up and Down move between links (Up from the first returns to the button); Home and End jump to the first or last link. On the top-level row, Left and Right move between items (mirrored right to left) and Home and End jump to the ends. Tab is never trapped.
  • Escape closes the open panel and returns focus to its button; focus leaving the item (tabbing past the last link) or a press outside closes it. Opening one group closes any other.
  • Closed panels carry the hidden and inert attributes, so their links leave the tab order and the accessibility tree the moment they close, even while the panel fades out.
  • The current link gets aria-current="page" and a fill and heavier weight; its group button is set semibold with a 4 px accent dot under its label (a border, so forced colours keep it) and a visually hidden currentSectionLabel suffix; a current top-level link gets the same mark. Labels reserve their semibold width, so the mark never moves the row. Translate that suffix.
  • Chevrons and the overview arrow are aria-hidden. Items show a 2 px focus outline in the accent colour with a 2 px offset and grow to 44 px under a coarse pointer.
  • With openOnHover, a panel opened by the mouse is kept, not closed, by the click that confirms it, and any key pressed inside it keeps it open when the pointer leaves. A pending hover never overrides a click, a key or Escape.
  • IDs come from $props.id(), so two navs on one page keep distinct aria-controls targets.

Known limitations

  • Hover opening is for a mouse only; there is no safe-triangle path prediction, only a bridge over the gap under the trigger and a 200 ms grace period.
  • Panel content in the footer shelf is yours: keep its links accessible and its text inside 4.5:1.

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.