Skip to content
Download ZIP

Blue 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 · Blue palette
Using the PageSugar MCP server, fetch component cmp_dropdown_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-547f654694b6f7a46ad07b93bb370087f9f99987542ded1709fde283c333c4ed
parts/NavGroup.svelte Svelte · 13.5 KB Raw
<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>

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.