Navigation drawer

A navigation drawer is the top level navigation for wide screens, with room for labels, groups and counts. There are two kinds. Without modal it is a standard drawer: part of the layout, always visible next to the content. With modal it is a popover that slides in over the page and can be dismissed. Use it on narrow screens. It moves like the modal navigation rail: it springs open on --np-motion-expressive-default-spatial and slides back on --np-motion-standard-fast-spatial.

The examples below show both. The first is modal and opens from a button, the second is a standard drawer with its height capped so it fits on this page.

Usage

<script lang="ts">
	import { Button, NavigationDrawer, NavigationDrawerItem } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

	let selection = $state(1)
</script>

<Button command="show-modal" commandfor="demo-drawer">Open nav</Button>
<NavigationDrawer id="demo-drawer" modal>
	<NavigationDrawerItem
		selected={selection === 1}
		onclick={() => {
			selection = 1
		}}
		label="Videos"
		badgeLabel="+100"
	>
		{#snippet icon()}<Icon>videocam</Icon>{/snippet}
	</NavigationDrawerItem>
	<NavigationDrawerItem
		selected={selection === 2}
		onclick={() => {
			selection = 2
		}}
		label="Styles"
	>
		{#snippet icon()}<Icon>palette</Icon>{/snippet}
	</NavigationDrawerItem>
	<NavigationDrawerItem
		selected={selection === 3}
		onclick={() => {
			selection = 3
		}}
		label="Settings"
	>
		{#snippet icon()}<Icon>settings</Icon>{/snippet}
	</NavigationDrawerItem>
</NavigationDrawer>
<script lang="ts">
	import { NavigationDrawer, NavigationDrawerItem } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

	let selection = $state(1)
</script>

<NavigationDrawer --np-navigation-drawer-height="200px">
	<NavigationDrawerItem
		selected={selection === 1}
		onclick={() => {
			selection = 1
		}}
		label="Videos"
		badgeLabel="+100"
	>
		{#snippet icon()}<Icon>videocam</Icon>{/snippet}
	</NavigationDrawerItem>
	<NavigationDrawerItem
		selected={selection === 2}
		onclick={() => {
			selection = 2
		}}
		label="Styles"
	>
		{#snippet icon()}<Icon>palette</Icon>{/snippet}
	</NavigationDrawerItem>
	<NavigationDrawerItem
		selected={selection === 3}
		onclick={() => {
			selection = 3
		}}
		label="Settings"
	>
		{#snippet icon()}<Icon>settings</Icon>{/snippet}
	</NavigationDrawerItem>
</NavigationDrawer>

Opening a modal drawer

A modal drawer is a native <dialog>. To open it, set command="show-modal" and commandfor on the trigger, as in the example above. To open or close it from code, bind the element and call the popover methods on it.

<script lang="ts">
	let drawer = $state<HTMLElement>()
</script>

<Button onclick={() => drawer?.show()}>Menu</Button>
<NavigationDrawer bind:element={drawer} modal>
	<NavigationDrawerItem label="Videos" onclick={() => drawer?.close()} />
</NavigationDrawer>

A scrim dims the page behind the drawer, and a click on it closes the drawer. backdrop=false leaves the page undimmed. Set direction="rtl" to make the drawer slide in from the other edge. Use this when it sits at the end of the layout. In a right-to-left page it slides in from the right by default.

Dismissible standard drawer

A standard drawer is always visible until you set open. Then it can be dismissed: open=false slides it out to the start and gives its width back to the content. true brings it back. Toggle it from a menu button that stays visible, as Material 3 asks.

The content takes the space the drawer gives back.

<script lang="ts">
	import { IconButton, NavigationDrawer, NavigationDrawerItem } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

	const destinations = [
		{ value: 'videos', label: 'Videos', icon: 'videocam' },
		{ value: 'styles', label: 'Styles', icon: 'palette' },
		{ value: 'settings', label: 'Settings', icon: 'settings' },
	]

	let open = $state(true)
	let selected = $state('videos')
</script>

<div style="display:flex;width:40rem;max-width:100%;height:16rem">
	<NavigationDrawer {open} aria-label="Main, dismissible" --np-navigation-drawer-height="100%">
		{#each destinations as destination (destination.value)}
			<NavigationDrawerItem
				label={destination.label}
				selected={selected === destination.value}
				onclick={() => (selected = destination.value)}
			>
				{#snippet icon()}<Icon>{destination.icon}</Icon>{/snippet}
			</NavigationDrawerItem>
		{/each}
	</NavigationDrawer>
	<div style="flex:1;padding:0.5rem 1rem">
		<IconButton aria-label="Navigation" aria-expanded={open} onclick={() => (open = !open)}>
			<Icon>{open ? 'menu_open' : 'menu'}</Icon>
		</IconButton>
		<p>The content takes the space the drawer gives back.</p>
	</div>
</div>

Headline and sections

headline puts a title above the destinations. It names the navigation unless you pass an aria-label. NavigationDrawerSection groups related destinations under a divider and a label. A screen reader announces the group by that label.

<script lang="ts">
	import { NavigationDrawer, NavigationDrawerItem, NavigationDrawerSection } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

	const mail = [
		{ value: 'inbox', label: 'Inbox', icon: 'mail', badgeLabel: 24 },
		{ value: 'sent', label: 'Sent', icon: 'send' },
		{ value: 'favorites', label: 'Favorites', icon: 'favorite' },
		{ value: 'trash', label: 'Trash', icon: 'delete' },
	]
	const labels = [
		{ value: 'work', label: 'Work', icon: 'work' },
		{ value: 'saved', label: 'Saved', icon: 'bookmark' },
	]

	let selected = $state('inbox')
</script>

{#snippet item(destination: { value: string; label: string; icon: string; badgeLabel?: number })}
	<NavigationDrawerItem
		label={destination.label}
		badgeLabel={destination.badgeLabel}
		selected={selected === destination.value}
		onclick={() => (selected = destination.value)}
	>
		{#snippet icon()}<Icon>{destination.icon}</Icon>{/snippet}
	</NavigationDrawerItem>
{/snippet}

<NavigationDrawer headline="Mail" --np-navigation-drawer-height="34rem">
	{#each mail as destination (destination.value)}
		{@render item(destination)}
	{/each}
	<NavigationDrawerSection label="Labels">
		{#each labels as destination (destination.value)}
			{@render item(destination)}
		{/each}
	</NavigationDrawerSection>
</NavigationDrawer>

Accessibility

The drawer renders a <nav>, and the selected item gets aria-current="page". Give the <nav> an aria-label when the page has more than one navigation landmark.

The items share one tab stop. Tab moves into and out of the drawer. ↑ and ↓ move between the destinations and wrap at the ends. Home and End jump to the first and the last one.

While a modal drawer is open, the rest of the page is inert. This keeps the keyboard and the screen reader cursor inside the drawer. Focus moves to the first item on open and back to the trigger on close. Escape closes the drawer.

Theming

TokenDefault value
--np-navigation-drawer-background--np-color-surface-container-low
--np-navigation-drawer-width22.5rem
--np-navigation-drawer-height100dvh
--np-navigation-drawer-padding0.75rem
--np-navigation-drawer-item-container-shape--np-shape-corner-full
--np-navigation-drawer-item-font-size0.875rem
--np-navigation-drawer-item-font-weight500
--np-navigation-drawer-item-selected-font-weight500

Use --np-navigation-drawer-height when the drawer does not fill the full viewport height, as in the standard example above. The selected item uses secondary-container and on-secondary-container, the rest with on-surface-variant.

Example

<script lang="ts">
	import { NavigationDrawer, NavigationDrawerItem } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'
</script>

<NavigationDrawer
	--np-navigation-drawer-height="200px"
	--np-navigation-drawer-width="16rem"
	--np-navigation-drawer-background="var(--np-color-surface-container-highest)"
	--np-navigation-drawer-item-container-shape="0.5rem"
>
	<NavigationDrawerItem selected label="Videos">
		{#snippet icon()}<Icon>videocam</Icon>{/snippet}
	</NavigationDrawerItem>
	<NavigationDrawerItem label="Styles">
		{#snippet icon()}<Icon>palette</Icon>{/snippet}
	</NavigationDrawerItem>
</NavigationDrawer>

API

All other attributes go to the root element, including id, class, style and ontoggle. The root is the <nav> for a standard drawer and the <dialog> for a modal one. aria-label and aria-labelledby always go to the <nav>, so they name the navigation landmark.

AttributeTypeDefaultDescription
modalbooleanfalseTurns the drawer into a popover that slides in over the page and traps focus.
backdropbooleantrueDims the page behind a modal drawer. Clicking the scrim closes it.
openboolean | undefinedundefinedBindable. Whether a modal drawer is open. On a standard drawer, left out means always visible, and a value makes it dismissible.
headlinestring | undefinedundefinedA title above the destinations, naming the navigation unless it has an aria-label.
direction'ltr' | 'rtl' | undefinedundefinedEdge the drawer slides in from: ltr from the left, rtl from the right. Left out, it follows the writing direction.
elementHTMLElement | undefinedundefinedBindable reference to the <nav>. Use it to call show() and close().

All other attributes go to the underlying <button> or <a>, including onclick and href.

AttributeTypeDefaultDescription
labelstringRequired. Text of the destination.
iconSnippet | undefinedundefinedLeading icon. It is filled while the item is selected.
selectedboolean | undefinedundefinedMarks the current destination and sets aria-current="page".
badgeLabelstring | number | undefinedundefinedTrailing text, for a count such as "+100".
hrefstring | undefinedundefinedRenders the item as a link instead of a button.
type'submit' | 'reset' | 'button' | nullundefinedButton type, for items inside a form.
AttributeTypeDefaultDescription
labelstringThe section label, shown under the divider and naming the group.