Navigation
Navigation drawer
A sheet of destinations at the side of the window: permanent, dismissible or modal.
- COMPONENT_SPEC §15
- CSS · custom elements · React
- Open in the playground
Examples
Permanent
Always shown, in the layout, with headlines, a divider and a trailing badge.
The live demo runs with JavaScript. Its code is below.
import { NavigationDrawer, type NavigationDrawerEntry } from '@kyralabs/m3exp-web/react';
const ITEMS: NavigationDrawerEntry[] = [
{ key: 'inbox', icon: 'inbox', label: 'Inbox', badge: '24' },
{ key: 'outbox', icon: 'send', label: 'Outbox' },
{ key: 'favorites', icon: 'favorite', label: 'Favorites' },
{ key: 'divider', type: 'divider' },
{ key: 'labels', type: 'headline', label: 'Labels' },
{ key: 'family', icon: 'bookmark', label: 'Family' },
{ key: 'work', icon: 'bookmark', label: 'Work' },
];
export default function Standard() {
return (
<div style={{ display: 'flex', blockSize: 'var(--m3e-sys-window-height-medium)' }}>
<NavigationDrawer aria-label="Mail" items={ITEMS} defaultSelectedKey="inbox" />
</div>
);
}
<div style="display: flex; block-size: var(--m3e-sys-window-height-medium)">
<m3e-navigation-drawer label="Mail" selected="inbox">
<m3e-navigation-item value="inbox" icon="inbox" badge="24">Inbox</m3e-navigation-item>
<m3e-navigation-item value="outbox" icon="send">Outbox</m3e-navigation-item>
<m3e-navigation-item value="favorites" icon="favorite">Favorites</m3e-navigation-item>
<hr class="m3e-navigation-drawer__divider" />
<h2 class="m3e-navigation-drawer__headline">Labels</h2>
<m3e-navigation-item value="family" icon="bookmark">Family</m3e-navigation-item>
<m3e-navigation-item value="work" icon="bookmark">Work</m3e-navigation-item>
</m3e-navigation-drawer>
</div>
Run the HTML version (custom elements, no framework).
Dismissible
In the layout, opened and closed by a button: the content beside it moves.
The live demo runs with JavaScript. Its code is below.
import { useState } from 'react';
import {
Icon,
IconButton,
NavigationDrawer,
type NavigationDrawerEntry,
} from '@kyralabs/m3exp-web/react';
const ITEMS: NavigationDrawerEntry[] = [
{ key: 'inbox', icon: 'inbox', label: 'Inbox', badge: '24' },
{ key: 'outbox', icon: 'send', label: 'Outbox' },
{ key: 'favorites', icon: 'favorite', label: 'Favorites' },
];
export default function Dismissible() {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState('inbox');
return (
<div
style={{
display: 'flex',
inlineSize: '100%',
blockSize: 'var(--m3e-sys-window-height-medium)',
overflow: 'hidden',
}}
>
<NavigationDrawer
variant="dismissible"
aria-label="Mail, dismissible"
items={ITEMS}
selectedKey={selected}
onSelectionChange={(key) => {
setSelected(key);
}}
isOpen={open}
onOpenChange={setOpen}
/>
<div style={{ padding: 'var(--m3e-sys-space-200)' }}>
<IconButton
aria-label="Navigation menu"
aria-expanded={open}
onPress={() => {
setOpen((o) => !o);
}}
>
<Icon name="menu" />
</IconButton>
<p>Selected: {selected}</p>
</div>
</div>
);
}
<div
style="
display: flex;
inline-size: 100%;
block-size: var(--m3e-sys-window-height-medium);
overflow: hidden;
"
>
<m3e-navigation-drawer
id="dismissible-drawer"
variant="dismissible"
label="Mail, dismissible"
selected="inbox"
>
<m3e-navigation-item value="inbox" icon="inbox" badge="24">Inbox</m3e-navigation-item>
<m3e-navigation-item value="outbox" icon="send">Outbox</m3e-navigation-item>
<m3e-navigation-item value="favorites" icon="favorite">Favorites</m3e-navigation-item>
</m3e-navigation-drawer>
<div style="padding: var(--m3e-sys-space-200)">
<button
type="button"
id="dismissible-menu"
class="m3e-icon-button m3e-icon-button--standard"
aria-label="Navigation menu"
aria-expanded="false"
>
<span class="m3e-icon" aria-hidden="true">menu</span>
</button>
<p>Selected: <output id="dismissible-selected">inbox</output></p>
</div>
</div>
<script>
const drawer = document.getElementById('dismissible-drawer');
const menu = document.getElementById('dismissible-menu');
menu.addEventListener('click', () => drawer.toggle());
drawer.addEventListener('open-change', (event) => {
if (event.target !== drawer) return; // state events bubble in 1.x (D25)
menu.setAttribute('aria-expanded', String(event.detail.open));
});
drawer.addEventListener('selection-change', (event) => {
if (event.target !== drawer) return;
document.getElementById('dismissible-selected').textContent = event.detail.value;
});
</script>
Run the HTML version (custom elements, no framework).
Modal
Opens over the page as a dialog above a scrim. Drag it, press Escape or select a destination to close it.
The live demo runs with JavaScript. Its code is below.
import { useState } from 'react';
import {
Icon,
IconButton,
NavigationDrawer,
type NavigationDrawerEntry,
} from '@kyralabs/m3exp-web/react';
const ITEMS: NavigationDrawerEntry[] = [
{ key: 'inbox', icon: 'inbox', label: 'Inbox', badge: '24' },
{ key: 'outbox', icon: 'send', label: 'Outbox' },
{ key: 'favorites', icon: 'favorite', label: 'Favorites' },
];
export default function Modal() {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState('inbox');
return (
<div
style={{
display: 'flex',
inlineSize: '100%',
blockSize: 'var(--m3e-sys-window-height-medium)',
overflow: 'hidden',
}}
>
<NavigationDrawer
variant="modal"
aria-label="Mail, modal"
items={ITEMS}
selectedKey={selected}
onSelectionChange={(key) => {
setSelected(key);
setOpen(false);
}}
isOpen={open}
onOpenChange={setOpen}
/>
<div style={{ padding: 'var(--m3e-sys-space-200)' }}>
<IconButton
aria-label="Navigation menu"
aria-expanded={open}
onPress={() => {
setOpen((o) => !o);
}}
>
<Icon name="menu" />
</IconButton>
<p>Selected: {selected}</p>
</div>
</div>
);
}
<div
style="
display: flex;
inline-size: 100%;
block-size: var(--m3e-sys-window-height-medium);
overflow: hidden;
"
>
<m3e-navigation-drawer id="modal-drawer" variant="modal" label="Mail, modal" selected="inbox">
<m3e-navigation-item value="inbox" icon="inbox" badge="24">Inbox</m3e-navigation-item>
<m3e-navigation-item value="outbox" icon="send">Outbox</m3e-navigation-item>
<m3e-navigation-item value="favorites" icon="favorite">Favorites</m3e-navigation-item>
</m3e-navigation-drawer>
<div style="padding: var(--m3e-sys-space-200)">
<button
type="button"
id="modal-menu"
class="m3e-icon-button m3e-icon-button--standard"
aria-label="Navigation menu"
aria-expanded="false"
>
<span class="m3e-icon" aria-hidden="true">menu</span>
</button>
<p>Selected: <output id="modal-selected">inbox</output></p>
</div>
</div>
<script>
const drawer = document.getElementById('modal-drawer');
const menu = document.getElementById('modal-menu');
menu.addEventListener('click', () => drawer.toggle());
drawer.addEventListener('open-change', (event) => {
if (event.target !== drawer) return; // state events bubble in 1.x (D25)
menu.setAttribute('aria-expanded', String(event.detail.open));
});
drawer.addEventListener('selection-change', (event) => {
if (event.target !== drawer) return;
document.getElementById('modal-selected').textContent = event.detail.value;
drawer.close();
});
</script>
Run the HTML version (custom elements, no framework).
API
React
<NavigationDrawer>
Navigation drawer (COMPONENT_SPEC §15; Compose PermanentNavigationDrawer, DismissibleNavigationDrawer and ModalNavigationDrawer with their drawer sheet and NavigationDrawerItems). A <nav> sheet of destinations (the selected one marked aria-current="page"), headlines and dividers. Opening and closing animate on Compose's springs with m3e-elements' NavigationDrawerLayout (the code <m3e-navigation-drawer> runs): drags on the open sheet close it, and the modal drawer is a dialog over a scrim (focus trapped, Escape or the scrim close it, focus returns to the opener). aria-label names the <nav>; other props go to the root. Place permanent / dismissible drawers in a flex row before the content.
| Prop | Type | Default | Description |
|---|---|---|---|
variant
|
'permanent' | 'dismissible' | 'modal'
|
permanent (default): always shown, in the layout. dismissible: in the layout, opened and
closed (the content beside it moves). modal: opens over the page as a dialog above a scrim.
Compose's three drawers; switching it (e.g. by window size class) keeps the selection.
|
|
items
required
|
readonly NavigationDrawerEntry[]
|
Destinations, headlines and dividers, in order. | |
isOpen
|
boolean
|
Open (dismissible / modal; controlled). | |
defaultOpen
|
boolean
|
The initial open state (uncontrolled). | |
header
|
ReactNode
|
Content at the top of the sheet, before the items. | |
dialogLabel
|
string
|
The modal window's accessible name (default "Navigation menu", Compose's pane title). | |
selectedKey
|
string | null | undefined
|
The selected destination (controlled). Exactly one item gets aria-current="page".
|
|
defaultSelectedKey
|
string | null | undefined
|
The initially selected destination (uncontrolled). | |
onOpenChange
|
(isOpen: boolean) => void
|
Called when the drawer asks to change state: Escape, a press on the scrim, or a drag released
past Compose's thresholds. Controlled drawers follow isOpen.
|
|
onSelectionChange
|
((key: string) => void) | undefined
|
Called when an item is pressed. |
Also accepts HTML attributes not listed here.
Custom elements
<m3e-navigation-drawer>
import '@kyralabs/m3e-elements/navigation-drawer.js';
<m3e-navigation-drawer variant="modal" label="Main" selected="inbox"> (COMPONENT_SPEC §15; Compose PermanentNavigationDrawer / DismissibleNavigationDrawer / ModalNavigationDrawer with their drawer sheet and NavigationDrawerItem). Renders the m3e-css .m3e-navigation-drawer contract inside itself (the host is display: contents): a <nav> sheet of its <m3e-navigation-item> children (value, icon optional, href, badge) as .m3e-navigation-drawer-items; other children (a .m3e-navigation-drawer__headline, a .m3e-navigation-drawer__divider, any header content) move into the sheet in their order. Opening and closing animate on Compose's springs, drags on the open sheet / scrim close it, and the modal drawer opens as a dialog over a scrim (m3e-elements NavigationDrawerLayout, shared with m3e-react).
- Attributes:
variant(permanent, the default,dismissibleormodal),open(reflected; ignored by the permanent drawer),label(the<nav>'s name),selected(the selected item's value),dialog-label(the modal window's name, default "Navigation menu", Compose Strings.NavigationMenu). - Methods:
show(),close(),toggle()(DrawerState.open() / close()). - Events:
selection-change(detail: { value }, cancelable);open-change(detail: { open }) from the methods and, cancelable (preventDefault()keeps the state), when Escape, the scrim or a drag ask to change it.
| Attribute | Property | Type | Notes |
|---|---|---|---|
label
|
label
|
string
|
|
selected
|
selected
|
string
|
Reflected. |
variant
|
variant
|
string
|
Reflected. |
open
|
open
|
boolean
|
Reflected. |
dialog-label
|
dialogLabel
|
string
|
| Event | Detail | Description |
|---|---|---|
open-change
|
OpenChangeDetail
|
Opened or closed; cancelable when the drawer asks (scrim, Escape, a drag). |
CSS
navigation-drawer.css
@import '@kyralabs/m3e-css/navigation-drawer.css';
Navigation drawer (COMPONENT_SPEC §15; Compose PermanentNavigationDrawer, DismissibleNavigationDrawer, ModalNavigationDrawer with their drawer sheets and NavigationDrawerItem).
<div class="m3e-navigation-drawer"> (permanent)
<nav class="m3e-navigation-drawer__sheet" aria-label="Main">
<h2 class="m3e-navigation-drawer__headline">Mail</h2> (optional)
<a class="m3e-navigation-drawer-item" href="/inbox" aria-current="page">
<span class="m3e-icon m3e-navigation-drawer-item__icon" aria-hidden="true">inbox</span>
<span class="m3e-navigation-drawer-item__label">Inbox</span>
<span class="m3e-navigation-drawer-item__badge">24</span> (optional)
</a>
<hr class="m3e-navigation-drawer__divider" /> (optional)
…
</nav>
</div>
<div class="m3e-navigation-drawer m3e-navigation-drawer--dismissible m3e-navigation-drawer--open">
<nav class="m3e-navigation-drawer__sheet">…</nav>
</div>
<div class="m3e-navigation-drawer m3e-navigation-drawer--modal m3e-navigation-drawer--open">
<div class="m3e-navigation-drawer__scrim" aria-hidden="true"></div> (under the sheet)
<nav class="m3e-navigation-drawer__sheet">…</nav>
</div>
Permanent: the sheet in the layout (a flex row next to the content), full height. Dismissible: the same, shown with --open (the content beside it moves by the sheet width, as Compose places it at sheet width + offset); closed it takes no space and is hidden. Modal: closed nothing shows; --open puts the sheet at the viewport's start edge over a scrim, outside the layout. Static drawers need no JavaScript. m3e-elements' NavigationDrawerLayout (also run by m3e-react) animates the open / close and drags: per frame it writes only
--_m3e-nd-offset the sheet offset in px (−width closed … 0 open; > 0 while the open
spring overshoots),
--_m3e-nd-stretch max(offset, 0): the container is stretched behind the start edge,
--_m3e-nd-fraction the open fraction (scrim alpha),
--_m3e-nd-inline-size dismissible only: the space the drawer takes (width + offset),
plus [data-animating] ([data-dragging] while a drag lasts); [data-open] is set while the sheet is visible (in the top layer for the modal drawer: popover="manual", fixed positioning without the Popover API).
41 custom properties it reads
| Custom property | Default |
|---|---|
--m3e-comp-divider-thickness
|
1px |
--m3e-comp-navigation-drawer-active-icon-color
|
var(--m3e-sys-color-on-secondary-container) |
--m3e-comp-navigation-drawer-active-indicator-color
|
var(--m3e-sys-color-secondary-container) |
--m3e-comp-navigation-drawer-active-indicator-height
|
56px |
--m3e-comp-navigation-drawer-active-indicator-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-navigation-drawer-active-label-text-color
|
var(--m3e-sys-color-on-secondary-container) |
--m3e-comp-navigation-drawer-container-shape-end-end
|
var(--m3e-sys-shape-corner-large-end-end-end) |
--m3e-comp-navigation-drawer-container-shape-end-start
|
var(--m3e-sys-shape-corner-large-end-end-start) |
--m3e-comp-navigation-drawer-container-shape-start-end
|
var(--m3e-sys-shape-corner-large-end-start-end) |
--m3e-comp-navigation-drawer-container-shape-start-start
|
var(--m3e-sys-shape-corner-large-end-start-start) |
--m3e-comp-navigation-drawer-container-width
|
360px |
--m3e-comp-navigation-drawer-divider-color
|
var(--m3e-sys-color-outline-variant) |
--m3e-comp-navigation-drawer-divider-inset
|
28px |
--m3e-comp-navigation-drawer-headline-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-navigation-drawer-headline-font
|
var(--m3e-sys-typescale-title-small-font) |
--m3e-comp-navigation-drawer-headline-height
|
56px |
--m3e-comp-navigation-drawer-headline-line-height
|
var(--m3e-sys-typescale-title-small-line-height) |
--m3e-comp-navigation-drawer-headline-padding-inline
|
28px |
--m3e-comp-navigation-drawer-headline-size
|
var(--m3e-sys-typescale-title-small-size) |
--m3e-comp-navigation-drawer-headline-tracking
|
var(--m3e-sys-typescale-title-small-tracking) |
--m3e-comp-navigation-drawer-headline-weight
|
var(--m3e-sys-typescale-title-small-weight) |
--m3e-comp-navigation-drawer-icon-size
|
24px |
--m3e-comp-navigation-drawer-inactive-icon-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-navigation-drawer-inactive-label-text-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-navigation-drawer-item-horizontal-padding
|
12px |
--m3e-comp-navigation-drawer-item-icon-label-space
|
12px |
--m3e-comp-navigation-drawer-item-label-badge-space
|
12px |
--m3e-comp-navigation-drawer-item-label-text-font
|
var(--m3e-sys-typescale-body-large-font) |
--m3e-comp-navigation-drawer-item-label-text-line-height
|
var(--m3e-sys-typescale-body-large-line-height) |
--m3e-comp-navigation-drawer-item-label-text-size
|
var(--m3e-sys-typescale-body-large-size) |
--m3e-comp-navigation-drawer-item-label-text-tracking
|
var(--m3e-sys-typescale-body-large-tracking) |
--m3e-comp-navigation-drawer-item-label-text-weight
|
var(--m3e-sys-typescale-body-large-weight) |
--m3e-comp-navigation-drawer-item-padding-end
|
24px |
--m3e-comp-navigation-drawer-item-padding-start
|
16px |
--m3e-comp-navigation-drawer-min-width
|
240px |
--m3e-comp-navigation-drawer-modal-container-color
|
var(--m3e-sys-color-surface-container-low) |
--m3e-comp-navigation-drawer-scrim-color
|
var(--m3e-sys-color-scrim) |
--m3e-comp-navigation-drawer-scrim-opacity
|
0.32 |
--m3e-comp-navigation-drawer-standard-container-color
|
var(--m3e-sys-color-surface) |
--m3e-comp-navigation-drawer-standard-container-shape
|
0px |
--m3e-comp-navigation-drawer-top-padding
|
12px |
Accessibility
<nav aria-label>; items are links (or buttons) with aria-current="page" (Compose gives items Role.Tab with selected; on the web destinations are links, as §13/§14); modal role="dialog" + aria-modal + label with a focus trap; the scrim is aria-hidden (Escape closes it from the keyboard; Compose's scrim description "Close navigation menu" is not needed).
Every interactive part has a target of at least 48 × 48 px, a visible focus ring for
keyboard focus only, and works under forced-colors and
prefers-reduced-motion.
Differences from Compose
no predictive back and no swipe-to-open from the content (see Out of scope); the release velocity is an average over the trailing samples, not VelocityTracker's least-squares fit; labels are one line (ellipsized; Compose wraps and grows the item); the overshoot fill is a shadow, not a scale (the content is not counter-scaled because it is not scaled); badges are text (Compose takes any composable; the Badge component is §34).
Sources
Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from
m3.material.io, as cited below from COMPONENT_SPEC §15 and the m3e-css sources.
NavigationDrawer.kt, MaterialTheme.kt (vendored), DrawerSamples.kt (vendored), NavigationDrawerTokens.kt, ScrimTokens.kt; read: Scrim.kt, foundation gestures/AnchoredDraggable.kt (settle, computeTarget, animateToWithDecay), animation-core FloatDecayAnimationSpec.kt, material3 androidMain internal/Strings.android.kt L270-274 and compose/ui androidMain res/values/strings.xml L38, L44; m3.material.io Navigation drawer (Specs, Guidelines, Accessibility; token DB md.comp.navigation-drawer).
NavigationDrawerTokens.kt@19991f2c (generated navigation-drawer.css) as read by NavigationDrawer.kt, plus components/navigation-drawer-defaults.css (PARITY.md lines):
navigation-drawer.css sources
(compose/material3 @ 19991f2c; values: navigation-drawer.css and navigation-drawer-defaults.css, lines in PARITY.md):
- NavigationDrawer.kt DrawerSheet (L841-902): at least MinimumDrawerWidth, at most NavigationDrawerTokens.ContainerWidth (the default width: the items fill it), full height; standard sheets (Dismissible / Permanent, L733-839): RectangleShape, StandardContainerColor; the modal sheet (L644-664): DrawerDefaults.shape (ContainerShape, large end corners), modalContainerColor (ModalContainerColor), ModalDrawerElevation Level0 (no shadow). Content color contentColorFor(container) = on-surface. horizontalScaleUp (L904-922): when the open spring overshoots, the container is stretched so no gap shows at the start edge (here: a container-colored shadow offset towards the start edge, the content does not move).
- ModalNavigationDrawer (L334-466): the scrim (Scrim.kt) is DrawerDefaults.scrimColor (ScrimTokens.ContainerColor at ContainerOpacity) × the open fraction; the sheet at the start edge, offset by the drawer state. DismissibleNavigationDrawer (L488-592): content at sheet width + offset. PermanentNavigationDrawer (L612-622): a Row.
- NavigationDrawerItem (L1122-1166): at least ActiveIndicatorHeight tall, filling the width less NavigationDrawerItemDefaults.ItemPadding (12dp each side, as the samples apply it), shape ActiveIndicatorShape, padding 16dp start / 24dp end, icon (IconSize) then 12dp, the label (weight 1), then 12dp and the badge; colors NavigationDrawerItemDefaults.colors(): selected ActiveIndicatorColor container, ActiveIconColor / ActiveLabelTextColor, unselected transparent, InactiveIconColor / InactiveLabelTextColor; the badge takes the label color. Compose sets no text style, so the label inherits MaterialTheme's bodyLarge (LabelTextFont is not read). The state layer is the Surface ripple in its content color (on-secondary-container selected, on-surface otherwise); the focus ring is drawn inside the shape.
- DrawerSamples.kt: 12dp above the first item.
- m3.material.io (no Compose source): headline / section label rows (HeadlineFont, HeadlineColor), the divider inset and color.