Navigation rail

A navigation rail is the top level navigation for medium sized screens. It is a narrow vertical strip of destinations that stays visible next to the content. Use it for three to seven destinations. For fewer, use a tab bar. The expanded rail replaces the navigation drawer, which Material 3 no longer recommends.

Each item needs an icon and a label. Mark the current destination with selected. This fills the icon, gives the label the stronger colour and draws the pill behind the icon.

Usage

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

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

<NavigationRail>
	<NavigationRailItem
		selected={selection === 1}
		onclick={() => {
			selection = 1
		}}
		label="Videos"
	>
		{#snippet icon()}<Icon>videocam</Icon>{/snippet}
	</NavigationRailItem>
	<NavigationRailItem
		selected={selection === 2}
		onclick={() => {
			selection = 2
		}}
		label="Styles"
	>
		{#snippet icon()}<Icon>palette</Icon>{/snippet}
	</NavigationRailItem>
	<NavigationRailItem
		selected={selection === 3}
		onclick={() => {
			selection = 3
		}}
		label="Settings"
	>
		{#snippet icon()}<Icon>settings</Icon>{/snippet}
	</NavigationRailItem>
</NavigationRail>

Expanded

The rail has two widths. Collapsed, it is 96dp wide with the label under each icon. Expanded, it is 220dp to 360dp wide, shows the label beside the icon inside the indicator and pushes the content aside. menu adds a button at the top that toggles expanded. You can also bind expanded.

Pass a FAB through the fab snippet. It sits above the destinations. The snippet receives expanded, so an ExtendedFab can show its label only while the rail is expanded.

When the rail changes width, the destinations move into place, the indicator grows around the label and the labels cross-fade. --np-motion-standard-fast-spatial sets the timing. The modal rail's sheet uses --np-motion-expressive-default-spatial, so it settles a moment after its items. With reduced motion the rail switches at once.

alignment="center" groups the destinations in the vertical centre of the rail. This is easier to reach on a tablet. The menu button and the FAB stay at the top.

Content moves aside as the rail expands.
<script lang="ts">
	import { ExtendedFab, NavigationRail, NavigationRailItem } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

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

	let expanded = $state(false)
	let selected = $state('videos')
</script>

<div style="display:flex;height:30rem;width:36rem;max-width:100%">
	<NavigationRail menu bind:expanded aria-label="Main">
		{#snippet fab({ expanded })}
			<ExtendedFab collapsed={!expanded} label="New video">
				{#snippet icon()}<Icon>add</Icon>{/snippet}
			</ExtendedFab>
		{/snippet}
		{#each destinations as destination (destination.value)}
			<NavigationRailItem
				label={destination.label}
				selected={selected === destination.value}
				badge={destination.badgeLabel !== undefined}
				badgeLabel={destination.badgeLabel}
				onclick={() => (selected = destination.value)}
			>
				{#snippet icon()}<Icon>{destination.icon}</Icon>{/snippet}
			</NavigationRailItem>
		{/each}
	</NavigationRail>
	<div style="flex:1;padding:1.5rem">Content moves aside as the rail expands.</div>
</div>

Sections

NavigationRailSection holds secondary destinations under a header. They show only in the expanded rail and fade in as it opens. In the collapsed rail, the section is hidden from the keyboard and screen readers too. In the expanded rail, screen readers announce it as a group named by its header.

Collapse the rail and the library goes with it.
<script lang="ts">
	import { NavigationRail, NavigationRailItem, NavigationRailSection } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

	type Destination = { value: string; label: string; icon: string }

	const main: Destination[] = [
		{ value: 'home', label: 'Home', icon: 'home' },
		{ value: 'search', label: 'Search', icon: 'search' },
		{ value: 'mail', label: 'Mail', icon: 'mail' },
	]
	const library: Destination[] = [
		{ value: 'saved', label: 'Saved', icon: 'bookmark' },
		{ value: 'favorites', label: 'Favorites', icon: 'favorite' },
		{ value: 'downloads', label: 'Downloads', icon: 'download' },
	]

	let expanded = $state(true)
	let selected = $state('home')
</script>

{#snippet item(destination: Destination)}
	<NavigationRailItem
		label={destination.label}
		selected={selected === destination.value}
		onclick={() => (selected = destination.value)}
	>
		{#snippet icon()}<Icon>{destination.icon}</Icon>{/snippet}
	</NavigationRailItem>
{/snippet}

<div style="display:flex;height:36rem;width:36rem;max-width:100%">
	<NavigationRail menu bind:expanded aria-label="Main, with sections">
		{#each main as destination (destination.value)}
			{@render item(destination)}
		{/each}
		<NavigationRailSection label="Library">
			{#each library as destination (destination.value)}
				{@render item(destination)}
			{/each}
		</NavigationRailSection>
	</NavigationRail>
	<div style="flex:1;padding:1.5rem">Collapse the rail and the library goes with it.</div>
</div>

With modal, the expanded rail opens over the content behind a scrim instead of pushing it aside. Use it in smaller windows. Focus moves into the rail. Esc or a click on the scrim closes it.

The content stays put. The expanded rail opens over it.
<script lang="ts">
	import { NavigationRail, NavigationRailItem } 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 expanded = $state(false)
	let selected = $state('videos')
</script>

<div style="display:flex;height:22rem;width:100%">
	<NavigationRail modal menu bind:expanded aria-label="Main, modal">
		{#each destinations as destination (destination.value)}
			<NavigationRailItem
				label={destination.label}
				selected={selected === destination.value}
				onclick={() => {
					selected = destination.value
					expanded = false
				}}
			>
				{#snippet icon()}<Icon>{destination.icon}</Icon>{/snippet}
			</NavigationRailItem>
		{/each}
	</NavigationRail>
	<div style="flex:1;padding:1.5rem">The content stays put. The expanded rail opens over it.</div>
</div>

Hide when collapsed

hideWhenCollapsed removes the collapsed rail, so the content gets the full width. Open the rail with your own menu button that sets expanded. The menu button inside the open rail closes it.

Collapsed, the rail is gone and the content has the full width.

<script lang="ts">
	import { IconButton, NavigationRail, NavigationRailItem } 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 expanded = $state(false)
	let selected = $state('videos')
</script>

<div style="display:flex;align-items:flex-start;gap:0.5rem;width:100%">
	<IconButton
		aria-label="Open navigation"
		aria-expanded={expanded}
		onclick={() => (expanded = true)}
	>
		<Icon>menu</Icon>
	</IconButton>
	<NavigationRail modal hideWhenCollapsed menu bind:expanded aria-label="Main, hidden">
		{#each destinations as destination (destination.value)}
			<NavigationRailItem
				label={destination.label}
				selected={selected === destination.value}
				onclick={() => (selected = destination.value)}
			>
				{#snippet icon()}<Icon>{destination.icon}</Icon>{/snippet}
			</NavigationRailItem>
		{/each}
	</NavigationRail>
	<p style="margin:0.5rem 0">Collapsed, the rail is gone and the content has the full width.</p>
</div>

Pass an href and the item renders as an <a> instead of a <button>. Use links for real navigation, so people can open a destination in a new tab. In a routed app, set selected from the current URL, for example page.url.pathname === href with SvelteKit's page from $app/state.

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

<NavigationRail>
	<NavigationRailItem href="#videos" selected label="Videos">
		{#snippet icon()}<Icon>videocam</Icon>{/snippet}
	</NavigationRailItem>
	<NavigationRailItem href="#styles" label="Styles">
		{#snippet icon()}<Icon>palette</Icon>{/snippet}
	</NavigationRailItem>
</NavigationRail>

Accessibility

The rail renders a <nav>, and the selected item gets aria-current="page", so assistive technology announces the current destination.

The items share one tab stop. Tab moves into and out of the rail. ↑ and ↓ move between the destinations and wrap around at the ends. Home and End jump to the first and the last one. The menu button and the FAB come before them, each with its own tab stop. The menu button reports aria-expanded. When a page has several navigations, give each <nav> an aria-label so they can be told apart.

Theming

TokenDefault value
--np-navigation-rail-container-color--np-color-surface
--np-navigation-rail-modal-container-color--np-color-surface-container
--np-navigation-rail-modal-container-shape--np-shape-corner-large
--np-navigation-rail-padding-topmax(0.5rem, env(safe-area-inset-top))
--np-navigation-rail-item-active-indicator-color--np-color-secondary-container
--np-navigation-rail-item-font-weight500
--np-navigation-rail-item-selected-font-weight500

The colours come from the theme. The rail uses surface, or surface-container where it should stand apart from the content. Idle icons and labels use on-surface-variant. The pill behind the selected icon uses secondary-container with on-secondary-container. Raise --np-navigation-rail-item-selected-font-weight to make the current destination stand out more.

Example

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

<NavigationRail --np-navigation-rail-item-selected-font-weight="700">
	<NavigationRailItem selected label="Videos">
		{#snippet icon()}<Icon>videocam</Icon>{/snippet}
	</NavigationRailItem>
	<NavigationRailItem label="Styles">
		{#snippet icon()}<Icon>palette</Icon>{/snippet}
	</NavigationRailItem>
</NavigationRail>

API

AttributeTypeDefaultDescription
expandedbooleanfalseShows the expanded rail, with the label beside each icon. Bindable.
modalbooleanfalseOpens the expanded rail over the content, behind a scrim.
hideWhenCollapsedbooleanfalseHides the collapsed rail. Set expanded from a button of your own.
menubooleanfalseAdds a menu button at the top that toggles expanded.
menuLabelstring'Menu'The name of the menu button. It shows no tooltip.
alignment'top' | 'center''top'Groups the destinations at the top or in the vertical centre.
fabSnippet<[{ expanded: boolean }]>undefinedA FAB above the destinations, resting at elevation 0.

Other attributes, such as aria-label, class and style, go to the <nav> element.

Other attributes, including onclick and href, go to the <button> or <a>.

AttributeTypeDefaultDescription
iconSnippetRequired. The destination's icon.
labelstringRequired. Text below the icon.
selectedboolean | undefinedundefinedMarks the current destination and sets aria-current="page".
hrefstring | undefinedundefinedRenders the item as a link instead of a button.
type'submit' | 'reset' | 'button' | nullundefinedButton type, for an item inside a form.
AttributeTypeDefaultDescription
labelstringThe section header, shown in the expanded rail and naming the group.