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.
cmp_dropdown_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_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
This component needs all 3 files. Download the ZIP
<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, #2563eb);
--_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, #60a5fa);
--_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>
Usage#
On this pagePass 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.
currentPathis 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
<!-- 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.
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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
items | NavItem[] | Yes | None | 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 | None | 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 | None | 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#
On this pageChange content through props and the panelFooter snippet, and recolour through six --dropdown-nav-* CSS variables on any ancestor.
- Colours:
--dropdown-nav-accentis 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-950band 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:#fafafaon the header element. - Cream or tinted page: set
--dropdown-nav-surfaceto a lighter step of the page colour and--dropdown-nav-hoverto a darker one, for example#fffdf8and 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
overviewLabelto change its text, for example (label) => `All ${label.toLowerCase()}`. - Footer shelf: pass
panelFooterand switch ongroup.id(orgroup.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 inNavGroup.svelte's hover handlers. - Density: top-level items and rows are 36 px tall (44 px under a coarse pointer), set by
min-h-9and pointer-coarse:min-h-11.
Public CSS variables
| Variable | Token |
|---|---|
--dropdown-nav-accent | accent |
--dropdown-nav-ink | ink |
--dropdown-nav-muted | muted |
--dropdown-nav-hairline | hairline |
--dropdown-nav-surface | surface |
--dropdown-nav-hover | hover |
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-expandedandaria-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 hiddencurrentSectionLabelsuffix; 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 distinctaria-controlstargets.
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.