# Dropdown navigation

> A row of top-level links where some items are buttons that open a compact panel of related links, with optional one-line descriptions, an overview link for the section's own page and a footer shelf.

- ID: `cmp_dropdown_nav_01`
- Slug: `dropdown-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/dropdown-nav-01
- Preview: https://pagesugar.com/preview/dropdown-nav-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756` |
| `blue` | Blue | no | `sha256-547f654694b6f7a46ad07b93bb370087f9f99987542ded1709fde283c333c4ed` |

## 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/dropdown-nav-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

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.

Required props: `items`

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

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.

## Usage guide

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

#### Groups and the overview link

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.

#### Footer shelf

`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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `items` | `NavItem[]` | yes |  | Top-level items: { label, href } links, and { label, href?, description?, id?, children } groups whose children ({ label, href, description? }) open in a panel. |
| `currentPath` | `string` | no |  | Current 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. |
| `openOnHover` | `boolean` | no | `false` | Also 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) => string` | no | `` (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. |
| `panelFooter` | `Snippet<[NavGroup]>` | no |  | Content for the shelf at the foot of each panel, given the group. Render nothing for a group and its shelf is hidden. |
| `currentSectionLabel` | `string` | no | `'current section'` | Visually hidden suffix on a group button whose panel holds the current page. |
| `label` | `string` | no | `'Main'` | Accessible name of the nav landmark. |

## Customization

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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--dropdown-nav-accent` |
| `ink` | `--dropdown-nav-ink` |
| `muted` | `--dropdown-nav-muted` |
| `hairline` | `--dropdown-nav-hairline` |
| `surface` | `--dropdown-nav-surface` |
| `hover` | `--dropdown-nav-hover` |

## Accessibility

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

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `DropdownNav.svelte`
- Suggested directory: `src/lib/components/dropdown-nav-01`
- Files: 3
- Artifact digest: `sha256-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756`

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

#### `DropdownNav.svelte`

Role: entry · 7219 bytes · SHA-256 `299ae657bca8578c4a5d7ab02d9e9eccc15b2e354d6288d6709665ffe7aeb16e`

```svelte
<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>
```

#### `parts/NavGroup.svelte`

Role: component · 13782 bytes · SHA-256 `5b07cf600b6819a965cfb83c2bb0ea3b68abed947f9831d81e278caea0c3a4c7`

```svelte
<script lang="ts">
	import { flushSync, untrack, type Snippet } from 'svelte';
	import { isCurrentGroup, isCurrentPath, type NavGroup, type OpenReason } from '../types';

	interface Props {
		group: NavGroup;
		/** DOM id of the panel; the button gets `${id}-button`. */
		id: string;
		open: boolean;
		reason: OpenReason | null;
		currentPath?: string;
		openOnHover: boolean;
		overviewLabel: (groupLabel: string) => string;
		currentSectionLabel: string;
		panelFooter?: Snippet<[NavGroup]>;
		onOpen: (reason: OpenReason) => void;
		onClose: () => void;
	}

	let {
		group,
		id,
		open,
		reason,
		currentPath,
		openOnHover,
		overviewLabel,
		currentSectionLabel,
		panelFooter,
		onOpen,
		onClose
	}: Props = $props();

	let root = $state<HTMLLIElement>();
	let button = $state<HTMLButtonElement>();
	let panel = $state<HTMLDivElement>();
	let scroller = $state<HTMLDivElement>();

	const current = $derived(isCurrentGroup(group, currentPath));

	/*
	 * Placement. The panel hangs from the trigger's start edge, pulled out by its own padding so row
	 * text starts under the trigger's label. After opening it measures itself: when it would cross the
	 * viewport's 8 px gutter it hangs from the end edge instead, and when both edges cross, it is
	 * clamped inside the gutter. Logical sides, so right-to-left mirrors on its own. The result is
	 * computed from the trigger alone and kept through the closing fade, so a closing panel never jumps.
	 */
	let side = $state<'start' | 'end'>('start');
	let clampStart = $state<number | null>(null);

	function place() {
		if (!panel || !root) return;
		const gutter = 8;
		const viewport = document.documentElement.clientWidth;
		const anchor = root.getBoundingClientRect();
		const width = panel.offsetWidth;
		const rtl = getComputedStyle(panel).direction === 'rtl';
		const pull = 8;
		// Physical left edge of the panel for each logical side.
		const startLeft = rtl ? anchor.right + pull - width : anchor.left - pull;
		const endLeft = rtl ? anchor.left - pull : anchor.right + pull - width;
		const fits = (left: number) => left >= gutter && left + width <= viewport - gutter;
		if (fits(startLeft)) {
			side = 'start';
			clampStart = null;
		} else if (fits(endLeft)) {
			side = 'end';
			clampStart = null;
		} else {
			const left = Math.min(Math.max(startLeft, gutter), viewport - gutter - width);
			// Stored as an offset from the item's inline-start edge, so the style stays logical.
			clampStart = Math.round(rtl ? anchor.right - (left + width) : left - anchor.left);
		}
		// The panel's top depends on where the row sits, so its room below is measured too; the list
		// scrolls inside whatever is left, never less than 120 px.
		const top = anchor.bottom + 8;
		const room = document.documentElement.clientHeight - top - gutter;
		panel.style.maxHeight = `${Math.max(120, Math.floor(room))}px`;
	}

	$effect(() => {
		if (open) untrack(place);
	});

	/* The viewport can change while the panel is open; one placement per frame, the latest resize wins. */
	let placeFrame = 0;
	function onWindowResize() {
		cancelAnimationFrame(placeFrame);
		placeFrame = requestAnimationFrame(place);
	}

	/* The navigable rows: the overview link and the children. Footer controls keep their own keys. */
	const panelLinks = () =>
		scroller ? Array.from(scroller.querySelectorAll<HTMLAnchorElement>('a[href]')) : [];

	/* A click opens, or closes an open panel. A panel the pointer opened is kept by the click that confirms it. */
	function onClick() {
		clearTimers();
		if (!open) onOpen('click');
		else if (reason === 'hover') onOpen('click');
		else onClose();
	}

	/* Down Arrow opens the panel and moves into it; the panel is shown before focus moves. */
	function onButtonKeydown(event: KeyboardEvent) {
		if (event.key !== 'ArrowDown') return;
		event.preventDefault();
		if (!open) {
			onOpen('key');
			flushSync();
		}
		panelLinks()[0]?.focus();
	}

	/* Escape, and the optional arrow keys the APG disclosure navigation example allows. */
	function onWindowKeydown(event: KeyboardEvent) {
		// One Escape closes one panel: a nav that handled it first marks it handled.
		if (event.defaultPrevented) return;
		if (event.key === 'Escape') {
			// Focus returns to the button when it was inside the item, or dropped on the body (WebKit's click).
			const active = document.activeElement;
			const inside = !active || active === document.body || Boolean(root?.contains(active));
			event.preventDefault();
			onClose();
			if (inside) button?.focus();
			return;
		}
		if (!(event.target instanceof Node) || !root?.contains(event.target)) return;
		const links = panelLinks();
		const index = links.indexOf(event.target as HTMLAnchorElement);
		if (index === -1) return;
		let next: HTMLElement | undefined;
		if (event.key === 'ArrowDown') next = links[Math.min(index + 1, links.length - 1)];
		else if (event.key === 'ArrowUp') next = index <= 0 ? button : 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();
		next?.focus();
	}

	/* Any key inside the item makes an open panel the keyboard's: a hover panel stops closing when the pointer leaves. */
	function onItemKeydown() {
		if (open && reason === 'hover') onOpen('key');
	}

	/* Focus leaving the item, for example by tabbing past the last link, closes it. */
	function onDocumentFocusIn(event: FocusEvent) {
		if (event.target instanceof Node && root && !root.contains(event.target)) onClose();
	}

	/*
	 * Tabbing past the last control on the page sends focus to the browser itself, where no element
	 * receives it. A focus loss with no destination closes the panel only once the document has lost
	 * focus, so a WebKit link click (which also blurs to nothing) still lands.
	 */
	function onItemFocusOut(event: FocusEvent) {
		if (event.relatedTarget !== null) return;
		clearTimeout(blurTimer);
		blurTimer = window.setTimeout(() => {
			if (open && !document.hasFocus()) onClose();
		}, 0);
	}

	/* A press outside the item closes it; presses inside reach the links first. */
	function onDocumentPointerDown(event: PointerEvent) {
		if (event.target instanceof Node && root && !root.contains(event.target)) onClose();
	}

	/*
	 * Hover intent, for a mouse only: the panel opens after the pointer has rested 150 ms on the
	 * item, and a panel it opened closes 200 ms after the pointer leaves both trigger and panel.
	 * Touch and pen skip this and use the click.
	 */
	let enterTimer = 0;
	let leaveTimer = 0;
	let blurTimer = 0;
	function clearTimers() {
		clearTimeout(enterTimer);
		clearTimeout(leaveTimer);
		clearTimeout(blurTimer);
	}

	function onPointerEnter(event: PointerEvent) {
		if (!openOnHover || event.pointerType !== 'mouse') return;
		clearTimers();
		if (!open) enterTimer = window.setTimeout(() => onOpen('hover'), 150);
	}

	function onPointerLeave(event: PointerEvent) {
		if (!openOnHover || event.pointerType !== 'mouse') return;
		clearTimers();
		if (open && reason === 'hover') leaveTimer = window.setTimeout(onClose, 200);
	}

	/* Any change of state (a click, a key, Escape, another group opening) cancels pending intent. */
	$effect(() => {
		void open;
		void reason;
		untrack(clearTimers);
	});
	$effect(() => () => {
		clearTimers();
		cancelAnimationFrame(placeFrame);
	});

	const focusClass =
		'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)';
	const rowClass =
		'block min-h-9 rounded-sm px-3 py-2 transition-colors duration-150 ease-(--_ease) active:bg-(--_fill-pressed) active:duration-80 pointer-coarse:min-h-11';
</script>

<svelte:window
	onkeydown={open ? onWindowKeydown : undefined}
	onresize={open ? onWindowResize : undefined}
/>
<svelte:document
	onfocusin={open ? onDocumentFocusIn : undefined}
	onpointerdown={open ? onDocumentPointerDown : undefined}
/>

<!-- The item only listens: hover intent and keyboard ownership; its button and links are the controls. -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li
	bind:this={root}
	class="relative flex items-center"
	onpointerenter={onPointerEnter}
	onpointerleave={onPointerLeave}
	onfocusout={onItemFocusOut}
	onkeydown={onItemKeydown}
>
	<button
		bind:this={button}
		type="button"
		id="{id}-button"
		data-top-level
		aria-expanded={open}
		aria-controls={id}
		onclick={onClick}
		onkeydown={onButtonKeydown}
		class={[
			'relative inline-flex min-h-9 items-center gap-x-1 rounded-lg px-3 text-start text-sm font-medium break-words transition-colors duration-150 ease-(--_ease) active:bg-(--_fill-pressed) active:duration-80 pointer-coarse:min-h-11',
			open
				? 'bg-(--_hover) text-(--_ink)'
				: current
					? 'text-(--_ink) hover:bg-(--_hover)'
					: 'text-(--_muted) hover:bg-(--_hover) hover:text-(--_ink)',
			focusClass
		]}
	>
		<!--
			The label reserves its semibold width, so marking a section current never moves the row. The
			current section is set semibold in ink with a 4 px accent dot under the label: no resting
			underline, and nothing between the trigger and its open panel. The dot is a border, so forced
			colours keep it.
		-->
		<span
			data-label={group.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',
					current &&
						"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-['']"
				]}
				>{group.label}{#if current}<span class="sr-only">, {currentSectionLabel}</span>{/if}</span
			>
		</span>
		<svg
			class={[
				'size-4 shrink-0 transition-transform motion-reduce:transition-none',
				// The chevron keeps time with its 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, its rows inset 8 px so their 4 px radius shares its centre.
		It does not scroll itself, so its ::before can fill the 8 px gap under the trigger and a pointer
		crossing it never leaves the item; the rows scroll inside, above a shelf that stays put. Inert the
		moment it closes, so the closing fade can never take focus.
	-->
	<div
		bind:this={panel}
		{id}
		hidden={!open}
		inert={!open}
		style:inset-inline-start={clampStart === null ? undefined : `${clampStart}px`}
		style:inset-inline-end={clampStart === null ? undefined : 'auto'}
		class={[
			'absolute top-full z-30 mt-2 flex w-80 max-w-[calc(100vw-1rem)] flex-col rounded-xl bg-(--_surface) shadow-(--_shadow-popover)',
			"before:absolute before:inset-x-0 before:-top-2 before:h-2 before:content-['']",
			'transition-[opacity,translate,display] transition-discrete motion-reduce:transition-none',
			// Opens over 200 ms from 4 px above; closes faster, on the exit curve.
			open
				? 'duration-200 ease-[cubic-bezier(.16,1,.3,1)] starting:-translate-y-1 starting:opacity-0 motion-reduce:starting:translate-y-0'
				: '-translate-y-1 opacity-0 duration-150 ease-[cubic-bezier(.4,0,1,1)] motion-reduce:translate-y-0',
			side === 'end' ? '-end-2' : '-start-2'
		]}
	>
		<div bind:this={scroller} class="min-h-0 overflow-y-auto overscroll-contain p-2">
			{#if group.href}
				{@const active = isCurrentPath(group.href, currentPath)}
				<a
					href={group.href}
					aria-current={active ? 'page' : undefined}
					class={[rowClass, active ? 'bg-(--_fill-current)' : 'hover:bg-(--_hover)', focusClass]}
				>
					<span class="block text-sm font-semibold break-words text-(--_ink)"
						>{overviewLabel(group.label)}&#8288;<svg
							class="ms-1 inline-block size-4 align-[-3px] rtl:-scale-x-100"
							viewBox="0 0 16 16"
							fill="none"
							aria-hidden="true"
							><path
								d="M3.5 8h9m-3.5-3.5L12.5 8 9 11.5"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
								stroke-linejoin="round"
							/></svg
						></span
					>
					{#if group.description}
						<span class="mt-1 block text-xs leading-4 text-pretty break-words text-(--_muted)"
							>{group.description}</span
						>
					{/if}
				</a>
				<div class="mx-3 my-1 border-t border-(--_hairline)" aria-hidden="true"></div>
			{/if}

			<ul role="list" class="flex flex-col">
				{#each group.children as link, index (index)}
					{@const active = isCurrentPath(link.href, currentPath)}
					<li>
						<a
							href={link.href}
							aria-current={active ? 'page' : undefined}
							class={[
								rowClass,
								active ? 'bg-(--_fill-current)' : 'hover:bg-(--_hover)',
								focusClass
							]}
						>
							<span
								class={[
									'block text-sm break-words text-(--_ink)',
									active ? 'font-semibold' : 'font-medium'
								]}>{link.label}</span
							>
							{#if link.description}
								<span class="mt-1 block text-xs leading-4 text-pretty break-words text-(--_muted)"
									>{link.description}</span
								>
							{/if}
						</a>
					</li>
				{/each}
			</ul>
		</div>

		{#if panelFooter}
			<!-- The shelf runs flush to the panel's edges; its 20 px inset puts content on the rows' text edge. -->
			<div
				class="shrink-0 rounded-b-xl border-t border-(--_hairline) bg-(--_shelf) px-5 py-3 text-xs leading-4 text-(--_muted) empty:hidden"
			>
				{@render panelFooter(group)}
			</div>
		{/if}
	</div>
</li>
```

#### `types.ts`

Role: types · 2392 bytes · SHA-256 `8649e660a5a61a11cf042b46d9229b79448002786c55a584b6cdcf68a5ede98d`

```ts
/** A destination: a top-level link, or a row inside a group's panel. */
export interface NavLink {
	label: string;
	href: string;
	/** One line under the label, shown inside a panel only. */
	description?: string;
}

/** A top-level item that opens a panel of related links. */
export interface NavGroup {
	label: string;
	/** The section's own landing page, listed first in the panel as the overview link. */
	href?: string;
	/** One line under the overview link. */
	description?: string;
	/** Optional stable key, for a panelFooter that switches on the group rather than its label. */
	id?: string;
	children: NavLink[];
}

export type NavItem = NavLink | NavGroup;

/** Why a panel is open: a panel the pointer opened closes when it leaves; click and key panels stay. */
export type OpenReason = 'click' | 'key' | 'hover';

export const isGroup = (item: NavItem): item is NavGroup =>
	'children' in item && Array.isArray(item.children);

/**
 * Items as rendered. A link or group with a blank label has no accessible name and is dropped;
 * a group with no children becomes a plain link when it has an href and is dropped otherwise,
 * so nothing renders as a button over an empty panel.
 */
export const renderableItems = (items: NavItem[]): NavItem[] =>
	items.flatMap((item): NavItem[] => {
		if (item.label.trim() === '') return [];
		if (!isGroup(item)) return [item];
		const children = item.children.filter((child) => child.label.trim() !== '');
		if (children.length > 0) return [{ ...item, children }];
		return item.href ? [{ label: item.label, href: item.href }] : [];
	});

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

/** True when an href names the current pathname, ignoring query, hash and a trailing slash. */
export const isCurrentPath = (href: string | undefined, currentPath: string | undefined) =>
	Boolean(href && currentPath) && normalizePath(href!) === normalizePath(currentPath!);

/** True when the group's overview page or one of its children is the current page. */
export const isCurrentGroup = (group: NavGroup, currentPath: string | undefined) =>
	isCurrentPath(group.href, currentPath) ||
	group.children.some((child) => isCurrentPath(child.href, currentPath));
```

## Artifacts

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

- Artifact digest: `sha256-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756`
- Entry: `DropdownNav.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/neutral/sha256-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/neutral/sha256-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756/bundle.zip (12621 bytes, sha256 `d5f9df77b399036834f4964fd4e2827dbe62448c351ebf2399d405cd2186ea3f`)

Files:

- `DropdownNav.svelte` (entry, 7219 bytes): https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/neutral/sha256-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756/source/DropdownNav.svelte
- `parts/NavGroup.svelte` (component, 13782 bytes): https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/neutral/sha256-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756/source/parts/NavGroup.svelte
- `types.ts` (types, 2392 bytes): https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/neutral/sha256-541b4f7ecf677b37d99fc4c714fb9008857f05c324e94c52118c0aebda922756/source/types.ts

### Blue (`blue`)

- Artifact digest: `sha256-547f654694b6f7a46ad07b93bb370087f9f99987542ded1709fde283c333c4ed`
- Entry: `DropdownNav.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/blue/sha256-547f654694b6f7a46ad07b93bb370087f9f99987542ded1709fde283c333c4ed/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/blue/sha256-547f654694b6f7a46ad07b93bb370087f9f99987542ded1709fde283c333c4ed/bundle.zip (12631 bytes, sha256 `5d3fd09a68767ddf6a03c943a458636060514765657e641330d6ca6eab0214db`)

Files:

- `DropdownNav.svelte` (entry, 7219 bytes): https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/blue/sha256-547f654694b6f7a46ad07b93bb370087f9f99987542ded1709fde283c333c4ed/source/DropdownNav.svelte
- `parts/NavGroup.svelte` (component, 13782 bytes): https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/blue/sha256-547f654694b6f7a46ad07b93bb370087f9f99987542ded1709fde283c333c4ed/source/parts/NavGroup.svelte
- `types.ts` (types, 2392 bytes): https://pagesugar.com/artifacts/cmp_dropdown_nav_01/1.0.0/blue/sha256-547f654694b6f7a46ad07b93bb370087f9f99987542ded1709fde283c333c4ed/source/types.ts
