Skip to content

Navigation

Navigation drawer

A sheet of destinations at the side of the window: permanent, dismissible or modal.

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.

React HTML
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.

React HTML
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.

React HTML
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, dismissible or modal), 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.