Skip to content
Download ZIP

Dark surface palette · 9.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_mobile_nav_drawer_01 · version 1.0.0 · Dark surface palette
Using the PageSugar MCP server, fetch component cmp_mobile_nav_drawer_01 version 1.0.0 with variant "dark", 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
Dark surface
Version
1.0.0
Digest
Full digest
sha256-f3693ced5de692b920342bd628ffc5772e6d732a2fb973612c0fc34d8ba05749
MobileNavDrawer.svelte Svelte · 18.4 KB Raw
<script lang="ts" module>
	import type { Snippet } from 'svelte';

	export interface DrawerLink {
		label: string;
		href: string;
	}

	/** Compare pathnames only: query, hash and a trailing slash never decide the current page. */
	export function isCurrentPath(href: string, currentPath: string | undefined): boolean {
		if (!currentPath || !href.startsWith('/')) return false;
		const clean = (path: string) => path.split(/[?#]/)[0].replace(/(.)\/+$/, '$1');
		return clean(href) === clean(currentPath);
	}

	/*
	 * One scroll lock per document, shared by every open drawer: the page's own overflow and
	 * gutter are saved when the first drawer opens and restored when the last one lets go.
	 */
	let lockHolders = 0;
	let savedStyles = { overflow: '', gutter: '' };

	function lockScroll() {
		const html = document.documentElement;
		if (lockHolders++ > 0) return;
		savedStyles = { overflow: html.style.overflow, gutter: html.style.scrollbarGutter };
		const hadScrollbar = window.innerWidth > html.clientWidth;
		html.style.overflow = 'hidden';
		// Hold the scrollbar's gutter so nothing shifts, unless the page already reserves one.
		if (hadScrollbar && getComputedStyle(html).scrollbarGutter === 'auto') {
			html.style.scrollbarGutter = 'stable';
		}
	}

	function unlockScroll() {
		if (lockHolders === 0 || --lockHolders > 0) return;
		const html = document.documentElement;
		html.style.overflow = savedStyles.overflow;
		html.style.scrollbarGutter = savedStyles.gutter;
	}
</script>

<script lang="ts">
	import { onMount } from 'svelte';

	interface Props {
		/** Primary destinations, set large. Omit when the content snippet supplies the body. */
		items?: DrawerLink[];
		/** Smaller links under the primary list: sign in, help, contact. */
		secondaryItems?: DrawerLink[];
		/** The one primary action, pinned to the foot of the panel. */
		cta?: DrawerLink;
		/** Custom body in place of items, such as an accordion; receives close for its own links. */
		content?: Snippet<[{ close: () => void }]>;
		/** Extra footer content above the call to action: a language chooser, a phone number. */
		footer?: Snippet;
		/** The site's mark in the panel header. Without it the title shows there instead. */
		brand?: Snippet;
		/** Current URL pathname; the matching link gets aria-current="page". */
		currentPath?: string;
		/** Whether the drawer is open. Bindable for external control. */
		open?: boolean;
		/** Inline side the panel enters from; follows the text direction. */
		side?: 'start' | 'end';
		/** A 360 px panel over a dimmed page, or the full width of the screen. */
		width?: 'panel' | 'full';
		/** Accessible name of the dialog, shown in the header when there is no brand. */
		title?: string;
		/** Text of the menu button. */
		triggerLabel?: string;
		/** Accessible name of the close button. */
		closeLabel?: string;
		/** Accessible name of the nav landmark inside the drawer. */
		navLabel?: string;
	}

	let {
		items = [],
		secondaryItems = [],
		cta,
		content,
		footer,
		brand,
		currentPath,
		open = $bindable(false),
		side = 'end',
		width = 'panel',
		title = 'Menu',
		triggerLabel = 'Menu',
		closeLabel = 'Close menu',
		navLabel = 'Main'
	}: Props = $props();

	const uid = $props.id();
	const dialogId = `${uid}-drawer`;
	const titleId = `${uid}-title`;
	const fallbackId = `${uid}-menu`;

	/* Nothing to show means no menu button at all, rather than a button that opens an empty panel. */
	const hasContent = $derived(
		items.length > 0 || secondaryItems.length > 0 || Boolean(cta || content || footer)
	);

	let hydrated = $state(false);
	let dialog = $state<HTMLDialogElement>();
	let toggle = $state<HTMLButtonElement>();
	let closeButton = $state<HTMLButtonElement>();
	let body = $state<HTMLElement>();
	let bodyContent = $state<HTMLElement>();

	/* Hairlines under the header and over the footer appear only while the list runs beneath them. */
	let scrolledPast = $state(false);
	let moreBelow = $state(false);

	/* Opened from the keyboard, the close button shows its ring; opened by a tap or a prop, it does not. */
	let openedByKeyboard = false;

	function close() {
		open = false;
	}

	function measureScroll() {
		if (!body) return;
		scrolledPast = body.scrollTop > 0;
		moreBelow = body.scrollTop + body.clientHeight < body.scrollHeight - 1;
	}

	onMount(() => {
		hydrated = true;
		// A visitor who opened the no-script list before hydration lands in the open drawer.
		if (location.hash === `#${fallbackId}`) {
			history.replaceState(history.state, '', location.pathname + location.search);
			open = true;
		}
	});

	/* With nothing left to show, the drawer cannot stay open: there would be no way to close it. */
	$effect(() => {
		if (open && !hasContent) open = false;
	});

	/* Native modal dialog: the page behind is inert, Escape closes it, focus starts on close. */
	$effect(() => {
		const element = dialog;
		if (!element) return;
		if (open && !element.open) {
			element.showModal();
			// showModal() may already have focused close as visible; refocus so the hint decides.
			// focusVisible is a hint; browsers without it fall back to their own heuristic.
			if (closeButton && document.activeElement === closeButton) closeButton.blur();
			closeButton?.focus({ focusVisible: openedByKeyboard });
			openedByKeyboard = false;
			if (body) body.scrollTop = 0;
			measureScroll();
		} else if (!open && element.open) {
			element.close();
		}
	});

	/* Keep the page still behind the drawer while a rendered dialog is open, and on destroy. */
	$effect(() => {
		if (!open || !dialog) return;
		lockScroll();
		return unlockScroll;
	});

	/* The list resizing with the viewport, or its content growing (an accordion), re-checks the hairlines. */
	$effect(() => {
		const elements = [body, bodyContent].filter((element) => element !== undefined);
		if (!elements.length || typeof ResizeObserver === 'undefined') return;
		const observer = new ResizeObserver(measureScroll);
		for (const element of elements) observer.observe(element);
		return () => observer.disconnect();
	});

	/*
	 * Any link chosen inside the drawer closes it, including links from the snippets, so a
	 * client-side navigation never leaves it open. A press on the backdrop closes it too, but
	 * only one that starts and ends outside the panel, never a drag out of it.
	 */
	let pressedOutside = false;
	function outside(event: MouseEvent) {
		if (!dialog || event.target !== dialog) return false;
		const rect = dialog.getBoundingClientRect();
		return (
			event.clientX < rect.left ||
			event.clientX > rect.right ||
			event.clientY < rect.top ||
			event.clientY > rect.bottom
		);
	}
	function onpointerdown(event: PointerEvent) {
		pressedOutside = outside(event);
	}
	function onclick(event: MouseEvent) {
		const target = event.target instanceof Element ? event.target : null;
		if (target?.closest('a[href]')) close();
		else if (pressedOutside && outside(event)) close();
		pressedOutside = false;
	}

	/*
	 * Escape, the close button, the backdrop and a link all end here; focus goes back to Menu.
	 * The close event is queued, so one that arrives after the drawer was reopened is ignored,
	 * and focus a router has since placed somewhere else on the page is left where it is.
	 */
	function onclose() {
		if (dialog?.open) return;
		open = false;
		const active = document.activeElement;
		const unclaimed = !active || active === document.body || Boolean(dialog?.contains(active));
		if (unclaimed && toggle?.isConnected) toggle.focus({ preventScroll: true });
	}

	/* In full screen the content keeps a readable column instead of stretching across a desktop. */
	const column = $derived(width === 'full' ? 'mx-auto w-full max-w-2xl' : 'w-full');

	/*
	 * 48 px rows: large enough for a thumb, and the size that makes the list the panel's anchor.
	 * Text starts 16 px from the panel edge (the list's 8 px plus the row's 8 px), the gutter a
	 * site header usually has, so the brand and each row do not jump sideways when a full-width
	 * drawer opens over that header.
	 */
	const primaryClass =
		'mobile-nav-drawer__tracked flex min-h-12 w-full items-center rounded-lg px-2 py-2 text-start text-xl leading-7 tracking-[-0.015em] break-words text-[var(--_ink)] transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_fill)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:bg-[var(--_fill-pressed)] active:duration-[80ms]';
	const secondaryClass =
		'flex min-h-11 w-full items-center rounded-lg px-2 py-2 text-start text-base leading-6 break-words transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_fill)] hover:text-[var(--_ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:bg-[var(--_fill-pressed)] active:duration-[80ms]';
	/* The current page is marked by a fill and a heavier weight, never by colour alone. */
	const currentClass = 'bg-[var(--_fill-current)] font-semibold text-[var(--_ink)]';
	const ctaClass =
		'mobile-nav-drawer__tracked inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-[var(--_accent)] px-4 py-2 text-center text-base leading-6 font-medium tracking-[-0.011em] text-[var(--_on-accent)] transition-[background-color,scale] duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_accent-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:scale-[.98] active:duration-[80ms] motion-reduce:active:scale-100';
	const toggleClass =
		'inline-flex min-h-11 items-center gap-2 rounded-lg px-3 text-base leading-6 font-medium transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_toggle-fill)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current active:bg-[var(--_toggle-pressed)] active:duration-[80ms]';
	const closeClass =
		'inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-[var(--_ink)] transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_fill)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:bg-[var(--_fill-pressed)] active:duration-[80ms]';
</script>

{#snippet menuIcon()}
	<svg class="size-5 shrink-0" viewBox="0 0 20 20" fill="none" aria-hidden="true">
		<path
			d="M3 6h14M3 10h14M3 14h14"
			stroke="currentColor"
			stroke-width="1.5"
			stroke-linecap="round"
		/>
	</svg>
{/snippet}

{#snippet closeIcon()}
	<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
		<path
			d="M5 5l10 10M15 5L5 15"
			stroke="currentColor"
			stroke-width="1.5"
			stroke-linecap="round"
		/>
	</svg>
{/snippet}

{#snippet header(closeControl: Snippet)}
	<div
		class={[
			'shrink-0 border-b transition-colors duration-150',
			scrolledPast ? 'border-[var(--_hairline)]' : 'border-transparent'
		]}
	>
		<div class={[column, 'flex min-h-16 items-center justify-between gap-4 ps-4 pe-3']}>
			<h2
				id={titleId}
				class={brand ? 'sr-only' : 'text-base leading-6 font-semibold text-[var(--_ink)]'}
			>
				{title}
			</h2>
			{#if brand}
				<div class="flex min-w-0 items-center text-[var(--_ink)]">{@render brand()}</div>
			{/if}
			{@render closeControl()}
		</div>
	</div>
{/snippet}

{#snippet links()}
	{#if content}
		{@render content({ close })}
	{:else}
		{#if items.length}
			<ul role="list" class="flex flex-col gap-1">
				{#each items as item, index (index)}
					{@const current = isCurrentPath(item.href, currentPath)}
					<li>
						<a
							href={item.href}
							aria-current={current ? 'page' : undefined}
							class={[primaryClass, current ? currentClass : 'font-medium']}
						>
							{item.label}
						</a>
					</li>
				{/each}
			</ul>
		{/if}
		{#if secondaryItems.length}
			<ul role="list" class={['flex flex-col', items.length && 'mt-4']}>
				{#each secondaryItems as item, index (index)}
					{@const current = isCurrentPath(item.href, currentPath)}
					<li>
						<a
							href={item.href}
							aria-current={current ? 'page' : undefined}
							class={[secondaryClass, current ? currentClass : 'text-[var(--_muted)]']}
						>
							{item.label}
						</a>
					</li>
				{/each}
			</ul>
		{/if}
	{/if}
{/snippet}

{#snippet foot()}
	{#if footer || cta}
		<div
			class={[
				'shrink-0 border-t transition-colors duration-150',
				moreBelow ? 'border-[var(--_hairline)]' : 'border-transparent'
			]}
		>
			<div
				class={[column, 'flex flex-col gap-3 px-4 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]']}
			>
				{#if footer}
					<!-- An oversized footer scrolls on its own, so the call to action below it stays in reach. -->
					<div class="max-h-[40dvh] overflow-y-auto text-base leading-6 text-[var(--_muted)]">
						{@render footer()}
					</div>
				{/if}
				{#if cta}
					<a href={cta.href} class={ctaClass}>{cta.label}</a>
				{/if}
			</div>
		</div>
	{/if}
{/snippet}

{#if hasContent}
	<div class="mobile-nav-drawer contents">
		{#if hydrated}
			<button
				bind:this={toggle}
				type="button"
				aria-expanded={open}
				aria-controls={dialogId}
				onclick={(event) => {
					// A keyboard activation of a button is a click with no pointer detail.
					openedByKeyboard = event.detail === 0;
					open = true;
				}}
				class={toggleClass}
			>
				{@render menuIcon()}
				<span>{triggerLabel}</span>
			</button>

			<dialog
				bind:this={dialog}
				id={dialogId}
				aria-labelledby={titleId}
				data-side={side}
				data-width={width}
				{onclose}
				{onclick}
				{onpointerdown}
				class={[
					'mobile-nav-drawer__panel fixed inset-y-0 m-0 h-dvh max-h-none w-full flex-col overflow-hidden bg-[var(--_surface)] p-0 text-[var(--_ink)] open:flex',
					side === 'start'
						? 'start-0 end-auto forced-colors:border-e'
						: 'start-auto end-0 forced-colors:border-s',
					width === 'full' ? 'max-w-none' : 'max-w-90 shadow-[var(--_shadow)]'
				]}
			>
				{#snippet closeControl()}
					<button bind:this={closeButton} type="button" onclick={close} class={closeClass}>
						{@render closeIcon()}
						<span class="sr-only">{closeLabel}</span>
					</button>
				{/snippet}
				{@render header(closeControl)}
				<nav
					bind:this={body}
					aria-label={navLabel}
					onscroll={measureScroll}
					class="min-h-0 flex-1 overflow-y-auto overscroll-contain pt-1 pb-4"
				>
					<div bind:this={bodyContent} class={[column, 'px-2']}>{@render links()}</div>
				</nav>
				{@render foot()}
			</dialog>
		{:else}
			<!-- Before hydration the menu button is a link to a list the browser reveals with :target. -->
			<a id="{uid}-toggle" href="#{fallbackId}" class={toggleClass}>
				{@render menuIcon()}
				<span>{triggerLabel}</span>
			</a>
			<div
				id={fallbackId}
				class="mobile-nav-drawer__fallback fixed inset-0 z-50 hidden flex-col bg-[var(--_surface)] text-[var(--_ink)] target:flex"
			>
				{#snippet closeControl()}
					<!-- Targeting the Menu link hides the list and puts the reader back where they opened it. -->
					<a href="#{uid}-toggle" class={closeClass}>
						{@render closeIcon()}
						<span class="sr-only">{closeLabel}</span>
					</a>
				{/snippet}
				{@render header(closeControl)}
				<nav aria-label={navLabel} class="min-h-0 flex-1 overflow-y-auto pt-1 pb-4">
					<div class={[column, 'px-2']}>{@render links()}</div>
				</nav>
				{@render foot()}
			</div>
		{/if}
	</div>
{/if}

<style>
	/* Public tokens: set --mobile-nav-drawer-* on any ancestor to retone the panel. */
	.mobile-nav-drawer {
		--_surface: var(--mobile-nav-drawer-surface, #18181b);
		--_ink: var(--mobile-nav-drawer-ink, #fafafa);
		--_muted: var(--mobile-nav-drawer-muted, #a1a1aa);
		--_hairline: var(--mobile-nav-drawer-hairline, #ffffff1a);
		--_accent: var(--mobile-nav-drawer-accent, #fafafa);
		--_on-accent: var(--mobile-nav-drawer-on-accent, #18181b);
		--_backdrop: var(--mobile-nav-drawer-backdrop, #00000099);
		/* Row fills are mixed from the ink, so a retoned surface keeps quiet, visible states. */
		--_fill: color-mix(in oklab, var(--_ink) 5%, transparent);
		--_fill-current: color-mix(in oklab, var(--_ink) 7%, transparent);
		--_fill-pressed: color-mix(in oklab, var(--_ink) 10%, transparent);
		--_accent-hover: color-mix(in oklab, var(--_accent) 86%, var(--_surface));
		/* The menu button sits in the consumer's header, so it takes that header's text colour. */
		--_toggle-fill: color-mix(in oklab, currentColor 6%, transparent);
		--_toggle-pressed: color-mix(in oklab, currentColor 11%, transparent);
		/* The dialog elevation, lit from above; its ring is the hairline so it reads on dark too. */
		--_shadow:
			0 0 0 1px var(--_hairline), 0 10px 15px -3px rgb(0 0 0 / 0.08),
			0 25px 50px -12px rgb(0 0 0 / 0.18);
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.mobile-nav-drawer__tracked:dir(rtl) {
		letter-spacing: 0;
	}

	/*
	 * The panel slides in from its side: open over 250 ms on the enter curve, closed faster on
	 * the exit curve. --_from is where it rests while closed, flipped for the start side and for
	 * right-to-left text, so an end-side drawer enters from the left in Arabic.
	 */
	.mobile-nav-drawer__panel {
		--_from: 100%;
		translate: var(--_from) 0;
		transition:
			translate 180ms cubic-bezier(0.4, 0, 1, 1),
			display 180ms allow-discrete,
			overlay 180ms allow-discrete;
	}
	.mobile-nav-drawer__panel[data-side='start'],
	.mobile-nav-drawer__panel:dir(rtl) {
		--_from: -100%;
	}
	.mobile-nav-drawer__panel[data-side='start']:dir(rtl) {
		--_from: 100%;
	}
	.mobile-nav-drawer__panel[open] {
		translate: 0 0;
		transition-duration: 250ms;
		transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
	}
	@starting-style {
		.mobile-nav-drawer__panel[open] {
			translate: var(--_from) 0;
		}
	}

	.mobile-nav-drawer__panel::backdrop {
		background-color: var(--_backdrop, rgb(9 9 11 / 0.4));
		opacity: 0;
		transition:
			opacity 180ms cubic-bezier(0.4, 0, 1, 1),
			display 180ms allow-discrete,
			overlay 180ms allow-discrete;
	}
	.mobile-nav-drawer__panel[open]::backdrop {
		opacity: 1;
		transition-duration: 250ms;
		transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
	}
	@starting-style {
		.mobile-nav-drawer__panel[open]::backdrop {
			opacity: 0;
		}
	}

	/* Under reduced motion the panel fades in place instead of travelling. */
	@media (prefers-reduced-motion: reduce) {
		.mobile-nav-drawer__panel {
			translate: none;
			opacity: 0;
			transition:
				opacity 150ms cubic-bezier(0.4, 0, 1, 1),
				display 150ms allow-discrete,
				overlay 150ms allow-discrete;
		}
		.mobile-nav-drawer__panel[open] {
			translate: none;
			opacity: 1;
		}
		@starting-style {
			.mobile-nav-drawer__panel[open] {
				translate: none;
				opacity: 0;
			}
		}
	}
</style>

Place it in your header where the Menu button should sit, pass the links, and hide it at the width where your inline navigation takes over. It does not choose the breakpoint, does not read the router (pass currentPath yourself), does not render nested groups itself (supply them through the content snippet) and closes on a link click rather than on navigation.

Suggested location
src/lib/components/mobile-nav-drawer-01

Limitations

  • Before hydration the Menu control is a link to a full-screen list the browser reveals with :target. It has no backdrop, animation or scroll lock, and it cannot make the page behind it inert, so Tab can still reach the hidden page; its close control is a link back to the Menu link.
  • currentPath is compared with each href as a pathname (query, hash and a trailing slash are ignored); absolute URLs never match.
  • With no items, secondary items, content, footer or call to action the component renders nothing, not even the Menu button.
  • The slide and backdrop fade rely on @starting-style and transition-behavior: allow-discrete; older browsers open and close the dialog without the animation.
  • The backdrop colour is inherited by ::backdrop from the dialog, which needs a current browser; older ones fall back to the default dim.

Example

Svelte
<!-- Illustrative content: replace the links and labels with your own routes. -->
<script lang="ts">
	import { page } from '$app/state';
	import MobileNavDrawer from '$lib/components/mobile-nav-drawer-01/MobileNavDrawer.svelte';

	const items = [
		{ label: 'Features', href: '/features' },
		{ label: 'Pricing', href: '/pricing' },
		{ label: 'Docs', href: '/docs' }
	];
</script>

<header class="flex items-center justify-between px-4">
	<a href="/">Example</a>
	<div class="lg:hidden">
		<MobileNavDrawer
			{items}
			secondaryItems={[{ label: 'Sign in', href: '/signin' }]}
			cta={{ label: 'Get started', href: '/signup' }}
			currentPath={page.url.pathname}
		>
			{#snippet brand()}
				<a href="/" class="font-semibold">Example</a>
			{/snippet}
		</MobileNavDrawer>
	</div>
</header>

Where it goes#

The component is the Menu button and the drawer it opens. Put it in your header where the button should sit and hide it at the width where your inline navigation takes over:

Svelte
<div class="lg:hidden">
	<MobileNavDrawer {items} cta={{ label: 'Get started', href: '/signup' }} />
</div>

It does not decide the breakpoint. If the viewport can grow past it while the drawer is open, close it from the parent with bind:open.

Marking the current page#

The drawer never reads the router. Pass the current pathname, for example page.url.pathname from $app/state in SvelteKit. The link whose href matches it (query, hash and a trailing slash are ignored) gets aria-current="page", a quiet fill and a heavier weight.

Closing#

Escape, the close button, a press on the backdrop and choosing any link inside the drawer all close it, and focus goes back to the Menu button. Links inside the content and footer snippets close it too. Anything else that should close it, such as a button that changes the language, calls the close function the content snippet receives, or sets open to false through bind:open.

Nested navigation#

items is a flat list. For grouped destinations, pass a mobile navigation accordion in the content snippet; it replaces items and secondaryItems inside the same nav landmark, and the call to action stays pinned to the foot.

Before JavaScript runs#

On the server the Menu button renders as a link to a hidden full-screen list of the same links, which the browser shows through :target. After hydration the link becomes the button and the list is replaced by the dialog. A visitor who had already opened the list lands in the open drawer.

Other languages#

Pass title, triggerLabel, closeLabel and navLabel in the page's language. Text direction comes from the document: in dir="rtl" the end side is the left edge, and the panel enters from there.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
itemsDrawerLink[]No[]Primary destinations, { label, href }, set large. Omit when the content snippet supplies the body.
secondaryItemsDrawerLink[]No[]Smaller links under the primary list, such as sign in, help and contact.
ctaDrawerLinkNoNoneThe one primary action, { label, href }, pinned full width to the foot of the panel.
contentSnippet<[{ close: () => void }]>NoNoneCustom body in place of items, for example a mobile navigation accordion. Receives close for controls that are not links.
brandSnippetNoNoneThe site's mark in the panel header. Without it the title is shown there instead; with it the title stays as the dialog's hidden heading.
currentPathstringNoNoneCurrent URL pathname, for example page.url.pathname. The matching link gets aria-current="page", a fill and a heavier weight.
openbooleanNofalseWhether the drawer is open. Bindable, so a parent can open or close it.
side'start' | 'end'No'end'Inline side the panel enters from. End is the right in left-to-right text and the left in right-to-left text.
width'panel' | 'full'No'panel'A 360 px panel over a dimmed page, or the full width of the screen.
titlestringNo'Menu'The dialog's accessible name, shown in the panel header when there is no brand.
triggerLabelstringNo'Menu'Visible text of the Menu button.
closeLabelstringNo'Close menu'Accessible name of the close button.
navLabelstringNo'Main'Accessible name of the nav landmark inside the panel.

Customization#

On this page

Change content through props and snippets, retone the panel through seven --mobile-nav-drawer-* variables, and hide the component at your navigation breakpoint from its parent.

  • Content: items are the large primary links, secondaryItems the smaller ones under them, cta the pinned action. Put anything else for the foot (a language chooser, a phone number) in the footer snippet.
  • Colours: set --mobile-nav-drawer-surface, -ink, -muted, -hairline, -accent, -on-accent and -backdrop on any ancestor. Row hover, current and pressed fills are mixed from the ink, and the call to action's hover from the accent, so they follow a retone.
  • Dark or tinted page: for a dark panel set surface #18181b, ink #fafafa, muted #a1a1aa, hairline #ffffff1a, accent #fafafa, on-accent #18181b and backdrop #00000099; the dark palette is exactly that.
  • Menu button: it takes the text colour of the header it sits in (currentColor), so it needs no token; its hover and pressed fills are mixed from that colour.
  • Breakpoint: wrap the component in an element with lg:hidden (or your breakpoint). Close the drawer from the parent (bind:open) if the viewport can grow past the breakpoint while it is open.
  • Width and side: width="full" fills the screen; the 360 px panel is the max-w-90 utility on the dialog. side="start" moves it to the other edge.
  • Nested navigation: pass a mobile navigation accordion in the content snippet. Links inside close the drawer on their own; call close from the snippet argument for anything else that should.
  • Motion: durations and curves live in the component's style block (250 ms in, 180 ms out); reduced motion swaps the slide for a fade.

Public CSS variables

VariableToken
--mobile-nav-drawer-surfacesurface
--mobile-nav-drawer-inkink
--mobile-nav-drawer-mutedmuted
--mobile-nav-drawer-hairlinehairline
--mobile-nav-drawer-accentaccent
--mobile-nav-drawer-on-accentonAccent
--mobile-nav-drawer-backdropbackdrop

Accessibility#

On this page
  • The Menu button is a native button with visible text, aria-expanded and aria-controls pointing at the dialog.
  • The panel is a native modal dialog opened with showModal(), labelled by its title heading: the page behind is inert, so Tab never leaves the panel, and Escape closes it.
  • Focus moves to the close button, labelled by closeLabel, when the drawer opens and returns to the Menu button whenever it closes: Escape, the close button, a press on the backdrop or a link.
  • The links sit in a nav landmark named by navLabel. The current link gets aria-current="page" and is marked by a fill and a heavier weight, not colour alone.
  • Rows, the close button, the Menu button and the call to action are at least 44 px tall; built-in controls show a 2 px focus outline in the accent colour.
  • Controls you place in the brand, content and footer snippets need their own accessible names and focus styles.
  • IDs come from $props.id(), so two drawers on one page stay independent.

Known limitations

  • Relies on the browser's native modal dialog for inertness and focus containment rather than a scripted focus trap.
  • Focus returns to the Menu button when the drawer closes, unless something else on the page has already taken it (for example a router moving focus after a link click).

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.