Material Next
On this page

Overview

<mnw-app-shell> is the frame around an application's screens, extracted from Android Insider's admin console:

  • a navigation drawer with a brand, destinations (<mnw-navigation-item>) and a footer;
  • a top app bar with a leading menu or back button, the screen's title and trailing actions;
  • the screen, in a <main> landmark.

Below permanent-from (900px of the shell's width by default) the drawer is a modal sheet over a scrim, opened from the menu button. From that width it is a permanent column: drawer and app bar become one surface, and the screen sits in it as a pane with a rounded top-start corner. The navigation is one DOM tree in both layouts, so its state and listeners survive every resize.

The shell owns layout, drawer state, focus and motion. The app owns routing: the shell never changes the URL or reads history. It reports a tap on a destination with a cancelable navigation-request, and the app tells it where it is with active-path, heading and leading.

Live demo

A small admin console: Feed, an article (with Back), Studio Bot, Lessons and Settings. Narrow the frame to get the modal drawer. In Lessons, type something, then pick another destination: the app's router asks before leaving. Every route is its own URL; reload or use Back and Forward inside the frame.

Connected (Insider) and Modal (Dev Tools) switch the drawer presentation in place: the page only sets navigation-mode on the shell, so the route, the text you typed and the navigation nodes stay as they are.

Open the console in a new tab

Desktop with a closed drawer

This separate example starts at a real desktop width with navigation-mode="modal". The menu opens an overlay; the content remains full width, as in GitHub Dev Tools.

Desktop · drawer closed by default

Open the closed-drawer console in a new tab
Desktop, closed drawer. Open the menu to see the theme controls and policy links.

The console's markup:

<mnw-app-shell heading="Feed">
  <div slot="brand" class="console-brand">
    <img
      src="../demos/assets/android-insider-icon.svg"
      alt=""
      width="36"
      height="36"
    />
    <div>
      <p class="console-brand__name">Android Insider</p>
      <p class="console-brand__role">Admin console</p>
    </div>
  </div>

  <mnw-navigation-item slot="navigation" href="feed/" icon="dynamic_feed"
    >Feed</mnw-navigation-item
  >
  <mnw-navigation-item slot="navigation" href="studio-bot/" icon="smart_toy"
    >Studio Bot</mnw-navigation-item
  >
  <mnw-navigation-item slot="navigation" href="lessons/" icon="school"
    >Lessons
    <span slot="badge">3</span>
  </mnw-navigation-item>
  <mnw-navigation-item slot="navigation" href="settings/" icon="settings"
    >Settings</mnw-navigation-item
  >

  <mnw-app-shell-footer
    slot="navigation-footer"
    privacy-href="https://mihaicristiancondrea.github.io/profile/#privacy-policy"
    code-of-conduct-href="https://mihaicristiancondrea.github.io/profile/#code-of-conduct"
  ></mnw-app-shell-footer>

  <mnw-product-launcher
    slot="actions"
    current-product="console:tutorials-admin"
    storage-key="mnw-docs-demo:app-shell-launcher"
  ></mnw-product-launcher>

  <div id="screen" class="console-screen"></div>
</mnw-app-shell>

Its router, which is app code, not part of the library:

interface Screen {
  heading: string;
  leading: 'menu' | 'back';
  render(): string;
}

const ARTICLES: Record<string, string> = {
  'android-16': 'Android 16 is out: what changes for your apps',
};

const screens: Array<[RegExp, (match: RegExpMatchArray) => Screen]> = [
  [
    /^(feed/)?$/,
    () => ({
      heading: 'Feed',
      leading: 'menu',
      render: () => &lt;p&gt;Published articles, newest first.&lt;/p&gt;&#10;        &lt;ul class=&quot;console-list&quot;&gt;${Object.entries(ARTICLES)&#10;          .map(([id, title]) =&gt; <li><a href="feed/${id}/">${title}</a></li>)&#10;          .join('')}&lt;/ul&gt;,
    }),
  ],
  [
    /^feed/([a-z0-9-]+)/$/,
    ([, id]) => ({
      heading: ARTICLES[id] ?? 'Article',
      leading: 'back',
      render: () =>
        '<p>An article opened from the Feed: the shell leads with Back, and Feed stays the current destination.</p>',
    }),
  ],
  [
    /^studio-bot/$/,
    () => ({
      heading: 'Studio Bot',
      leading: 'menu',
      render: () => '<p>Conversations and prompts.</p>',
    }),
  ],
  [
    /^lessons/$/,
    () => ({
      heading: 'Lessons',
      leading: 'menu',
      render:
        () => &lt;p&gt;Type in the lesson, then pick another destination: the console asks first.&lt;/p&gt;&#10;        &lt;label class=&quot;console-field&quot;&gt;Lesson notes&#10;          &lt;textarea id=&quot;lesson&quot; rows=&quot;5&quot;&gt;&lt;/textarea&gt;&lt;/label&gt;,
    }),
  ],
  [
    /^settings/$/,
    () => ({
      heading: 'Settings',
      leading: 'menu',
      render: () => '<p>Console preferences.</p>',
    }),
  ],
];

const shell = document.querySelector<MaterialNextAppShell>('mnw-app-shell')!;
const outlet = document.querySelector<HTMLElement>('#screen')!;
const dialog = document.querySelector<HTMLDialogElement>('#leave-dialog')!;
// <base href> is the app's root: every route is relative to it.
const appRoot = new URL(document.baseURI);
const fixedModal =
  new URL(location.href).searchParams.get('drawer') === 'modal';
let unsaved = false;
/** The URL of the screen on show. /
let shown = location.href;

const show = (url: URL) => {
  shown = url.href;
  const route = url.pathname.slice(appRoot.pathname.length);
  const entry = screens.find(([pattern]) => pattern.test(route));
  let screen: Screen = {
    heading: 'Not found',
    leading: 'back',
    render: () => '<p>No such page.</p>',
  };
  if (entry) {
    const [pattern, screenFor] = entry;
    screen = screenFor(route.match(pattern)!);
  }
  // The app tells the shell where it is; the shell never reads the router.
  shell.heading = screen.heading;
  shell.leading = screen.leading;
  shell.activePath = url.pathname;
  document.title = ${screen.heading} · Android Insider admin (demo);
  outlet.innerHTML = screen.render();
  unsaved = false;
};

/* Resolves true when the user may leave the screen on show. */
const mayLeave = () =>
  !unsaved
    ? Promise.resolve(true)
    : new Promise<boolean>((resolve) => {
        dialog.addEventListener(
          'close',
          () => resolve(dialog.returnValue === 'leave'),
          {once: true}
        );
        dialog.returnValue = '';
        dialog.showModal();
      });

const go = async (url: URL) => {
  if (url.href === shown || !(await mayLeave())) return;
  if (fixedModal) url.searchParams.set('drawer', 'modal');
  history.pushState(null, '', url);
  show(url);
};

// Material buttons do not submit a dialog form, so they close it themselves.
dialog.addEventListener('click', (event) => {
  const answer = (event.target as Element).closest<HTMLElement>('[data-answer]')
    ?.dataset.answer;
  if (answer) dialog.close(answer);
});

// A destination in the drawer: the router takes over from the link.
shell.addEventListener('navigation-request', (event) => {
  event.preventDefault();
  void go(new URL(event.detail.url));
});

// Up from an article: back to the Feed.
shell.addEventListener('back-request', () => {
  void go(new URL('feed/', appRoot));
});

// Links inside screens go through the same router.
outlet.addEventListener('click', (event) => {
  const link = (event.target as Element).closest('a');
  if (!link || link.target || event.metaKey || event.ctrlKey) return;
  event.preventDefault();
  void go(new URL(link.href));
});

outlet.addEventListener('input', () => {
  unsaved = true;
});

// Back and Forward: the browser has already moved; ask, and step back to
// the screen on show if the user stays.
window.addEventListener('popstate', async () => {
  if (await mayLeave()) show(new URL(location.href));
  else history.pushState(null, '', shown);
});

if (new URL(location.href).searchParams.get('drawer') === 'modal')
  shell.navigationMode = 'modal';
show(new URL(location.href));

Usage

<script type="module">
  import 'material-next-web-components/app-shell.js';
</script>

<mnw-app-shell heading="Apps" active-path="/admin" permanent-from="800">
  <div slot="brand">Android Apps Metadata</div>
  <mnw-navigation-item slot="navigation" href="/admin" icon="apps" match="exact"
    >Apps</mnw-navigation-item
  >
  <mnw-navigation-item
    slot="navigation"
    href="/admin/categories"
    icon="category"
    >Categories</mnw-navigation-item
  >
  <mnw-product-launcher slot="actions"></mnw-product-launcher>
  <apps-dashboard></apps-dashboard>
</mnw-app-shell>

Routing

Without a router, destinations are ordinary links: the browser follows them and the shell marks the current one from location.pathname.

With a router, handle navigation-request:

shell.addEventListener('navigation-request', async (event) => {
  event.preventDefault(); // the shell will not follow the link
  if (!(await editor.mayLeave())) return; // unsaved changes stay
  history.pushState(null, '', event.detail.url);
  render(new URL(event.detail.url));
});

function render(url: URL) {
  shell.heading = titleFor(url);
  shell.leading = isTopLevel(url) ? 'menu' : 'back';
  shell.activePath = url.pathname;
  // ...draw the screen
}
  • Clicks with a modifier key, a middle click and items with target="_blank" are left to the browser and fire nothing, so opening a destination in a new tab still works.
  • A request from the modal drawer closes it. If you handled it, focus moves to the screen's title, so keyboard and screen reader users land on the new screen; focusHeading() does the same after any navigation.
  • back-request fires when the back button is pressed; go where Up should go, or call history.back().

The current destination

active-path (or location.pathname) marks one destination: the longest href that is the path or a parent of it, so /admin/apps is current on /admin/apps/com.example. match="exact" limits an item to its own path, which suits a root destination such as /admin. Plain links in the navigation slot get aria-current="page" the same way.

Layout policy

permanent-from sets the breakpoint. It is measured on the shell's own width, so a shell inside a narrower container switches on its own. navigation-mode="modal" or "permanent" forces a layout. The layout attribute says which one is in use, for page CSS, and navigation-mode-change reports a switch.

For layouts with more rules, pass a policy:

import {createLayoutPolicy} from 'material-next-web-components/app-shell.js';

shell.layoutPolicy = createLayoutPolicy([
  {from: 0, mode: 'modal'},
  {from: 1200, mode: 'permanent'},
]);

Rules hold a width and a mode. Modal and permanent are the modes today; the rule list is where a rail or a bottom bar would go, without changing how policies are written.

Choosing the drawer presentation

github-dev-tools uses a modal drawer that starts closed at every width. That presentation already maps to navigation-mode="modal": the top app bar and screen use the full width until the menu opens an overlay. It should not reserve a permanent empty drawer column.

Android Insider uses the connected frame beside an always-visible drawer. Keep navigation-mode="auto" and the default permanent-from="900" for that layout; below the breakpoint it becomes modal. A forced navigation-mode="permanent" is useful only when the host guarantees enough room.

Switching between them, or resizing across the breakpoint, keeps the same navigation nodes and the screen. Focus follows: on a destination that is still on screen it stays there; when the drawer is put away it moves to the menu button. The demo above switches both ways, and the documentation checks drive it: the switch, resizing, Escape, the scrim, focus and the route.

Next steps for consumers:

  1. Migrate Insider with the automatic modal/permanent policy, preserving deep links and unsaved-change guards.
  2. Convert metadata to a SPA behind a preview route, then use the same policy with its own breakpoint before replacing /admin.
  3. Migrate GitHub Dev Tools with a modal drawer at all widths, preserving its full-width content, theme controls and footer links.
  4. Add a user-collapsible persistent drawer only when a consumer needs it. Keep open/closed preference with the host; the shell should not infer it from the repository name. A rail or bottom navigation remains a separate future mode.

Outlined appearance and a plain drawer title

A site without a product brand (a personal site, a small tool) can use flat surfaces divided by lines and a plain Menu title, as github-dev-tools does:

<mnw-app-shell
  appearance="outlined"
  drawer-heading="Menu"
  navigation-mode="modal"
>
  …
</mnw-app-shell>
  • appearance="outlined" draws an outline-variant line under the top app bar, under the drawer header and along the drawer's edge. The bar keeps one surface while content scrolls, and the permanent layout has no rounded pane.
  • drawer-heading puts a title row at the top of the drawer, as tall as the top app bar so their lines meet, with a close button in the modal layout. The brand slot still shows under it when filled.

Destination sizes and trailing icons

navigation-size="large" gives every destination and category 56px rows and 16px labels, as Material Components' navigation drawer draws them; the default standard rows are 48px. A destination's trailing-icon (or trailing slot) sits at its end edge; use open_in_new for links that leave the site:

<mnw-navigation-item
  href="https://blog.example.com/"
  target="_blank"
  icon="article"
  trailing-icon="open_in_new"
>
  Blog
</mnw-navigation-item>

Each destination and category pads itself by 2px above and below, so the rhythm holds when you wrap them in your own element. Only the destinations scroll: the drawer header, brand and navigation-footer stay in place.

Theming

The shell reads Material's --md-sys-color-* tokens: surface for the screen and compact app bar, surface-container for the compact bar once content scrolls under it, surface-container-low for the drawer and the connected frame, secondary-container for the current destination. Sizes are custom properties (see below).

API

<mnw-app-shell>

An application's frame: a navigation drawer, a top app bar with the screen's title and actions, and the screen. Below permanent-from (900px of the shell's own width by default) the drawer is a modal sheet opened from the menu button; from it, a permanent column beside a connected app bar and content pane. The navigation is one DOM tree in both layouts, so its state and listeners survive every resize.

The shell never changes the URL. A tap on a destination fires a cancelable navigation-request: a router calls preventDefault() and navigates itself (or asks about unsaved changes first); without one, the link is followed as usual. The app tells the shell where it is with active-path, heading and leading.

<mnw-app-shell> properties
PropertyAttributeTypeDefaultDescription
headingheadingstring''The current screen's title, in the top app bar.
headingLevelheading-levelnumber1The heading level of the title.
leadingleadingAppShellLeading'menu'menu opens the modal drawer (hidden beside the permanent drawer), back fires back-request, none shows nothing.
activePathactive-pathstring''The current path; defaults to location.pathname. Marks the current destination.
navigationModenavigation-mode'auto' | 'none' | AppShellNavigationMode'auto'auto follows the layout policy; modal and permanent force a layout; none has no drawer and no menu button, for single-screen pages.
permanentFrompermanent-fromDEFAULT_PERMANENT_DRAWER_FROMThe width, in CSS pixels of the shell, from which the drawer is permanent.
layoutPolicyProperty onlyAppShellLayoutPolicy | undefinedReplaces permanent-from with a full policy, for layouts with more rules.
drawerOpendrawer-openbooleanfalseThe modal drawer is open. Always false beside a permanent drawer.
layoutlayoutAppShellNavigationMode'modal'The layout in use, modal or permanent. Set by the shell; read it, or style on it.
scrolledscrolledbooleanfalseContent has scrolled under the top app bar. Set by the shell.
navigationLabelnavigation-labelstring'Navigation'Accessible name of the navigation landmark.
appearanceappearanceAppShellAppearance'tonal'tonal (default): the bar takes a tone once content scrolls under it, and the permanent drawer frames a rounded content pane. outlined: flat surfaces divided by outline-variant lines, under the bar and the drawer header and along the drawer's edge.
drawerHeadingdrawer-headingstring''A plain title at the top of the drawer, for example Menu, with a close button in the modal layout. For sites without a product brand; the brand slot still shows below it when filled.
navigationSizenavigation-sizeAppShellNavigationSize'standard'standard 48px destinations; large 56px ones with 16px labels, as Material Components draws them.
<mnw-app-shell> events
EventDescription
navigation-mode-change{mode}: the layout switched between modal and permanent.
drawer-toggle{open}: the modal drawer opened or closed.
back-requestThe back button was pressed (leading="back").
navigation-requestCancelable. {href, url, item}: a destination was activated. preventDefault() keeps the browser from following the link.
<mnw-app-shell> slots
SlotDescription
DefaultThe screen.
brandThe top of the drawer: a logo and the app's name.
navigation<mnw-navigation-item> elements, links, or groups of them with your own headings.
footerA page footer, below the main content.
navigation-footerThe bottom of the drawer: a theme picker, legal links.
actionsThe top app bar's trailing actions, for example <mnw-product-launcher>.
<mnw-app-shell> CSS custom properties
Custom propertyDescription
--mnw-app-shell-drawer-widthWidth of the permanent drawer. Default 264px.
--mnw-app-shell-modal-drawer-widthWidth of the modal drawer. Default 320px, at most the window less 56px.
--mnw-app-shell-top-app-bar-heightHeight of the top app bar. Default 72px, 64px when the shell is narrower than 600px.
--mnw-app-shell-pane-radiusThe content pane's top-start corner beside the permanent drawer. Default 28px.
<mnw-app-shell> CSS parts
PartDescription
drawerThe navigation drawer.
drawer-headerThe row with drawer-heading and the close button.
top-app-barThe top app bar.
headlineThe screen's title.
contentThe <main> element around the screen.

<mnw-navigation-item>

One destination in an app shell's navigation drawer: an icon, a label and an optional badge, as a link. <mnw-app-shell> marks the current one (active) from its active-path, and asks before following it.

<mnw-navigation-item> properties
PropertyAttributeTypeDefaultDescription
hrefhrefstring''Where the item goes.
labellabelstring''The label, when the default slot is empty.
iconiconstring''A Material Symbols name, when the icon slot is empty.
trailingIcontrailing-iconstring''A Material Symbols name shown at the end, when the trailing slot is empty: open_in_new for a link that leaves the site.
targettargetstring''_blank opens a new tab; the shell then lets the link go without asking.
matchmatchDestinationMatch'prefix'exact is current only on its own path; prefix also on paths under it.
activeactivebooleanfalseThe current destination. Set by <mnw-app-shell>.
<mnw-navigation-item> slots
SlotDescription
DefaultThe label. Overrides label.
iconA custom icon, for example an image or an SVG. Overrides icon.
badgeTrailing text, for example a count.
trailingA custom trailing icon. Overrides trailing-icon.
<mnw-navigation-item> CSS custom properties
Custom propertyDescription
--mnw-navigation-item-heightMinimum height. Default 48px; 56px in a shell with navigation-size="large".
--mnw-navigation-item-heightHeight of the item. Default 48px.
<mnw-navigation-item> CSS parts
PartDescription
linkThe link.

Methods: openDrawer(), closeDrawer(), toggleDrawer() and focusHeading().

Module exports

Everything below comes from material-next-web-components/app-shell.js; app-shell/<path>.js imports one layer.

Layer Exports
Domain: layout createLayoutPolicy, appShellLayoutPolicy, resolveNavigationMode, DEFAULT_APP_SHELL_POLICY, DEFAULT_PERMANENT_DRAWER_FROM (900), APP_SHELL_NAVIGATION_MODES, types AppShellLayoutPolicy, AppShellLayoutRule, AppShellNavigationMode
Domain: destinations findActiveDestination, destinationPath, types Destination, DestinationMatch
UI MaterialNextAppShell (<mnw-app-shell>), MaterialNextNavigationItem (<mnw-navigation-item>), MaterialNextAppShellFooter (<mnw-app-shell-footer>), types AppShellLeading, NavigationRequestDetail, NavigationRequestEvent, DrawerToggleEvent, NavigationModeChangeEvent, AppShellTheme, AppShellThemeChangeEvent

Accessibility

  • A Skip to content link is the shell's first tab stop; it moves focus to the <main> landmark.
  • The drawer is a <nav> landmark named by navigation-label. The current destination has aria-current="page".
  • The menu button reports aria-expanded. The modal drawer opens in the top layer, focuses the current destination and makes the rest of the shell inert. Escape, a tap on the scrim, or the menu button closes it and returns focus to the menu button.
  • The title is a heading (heading-level, default 1) that can take focus after a navigation.
  • With reduced motion, the drawer, scrim and title change without animation.

Limitations

  • Two layouts: modal and permanent drawers. There is no navigation rail or bottom bar yet.
  • The permanent drawer cannot be collapsed by the user.
  • The modal drawer needs the Popover API (all current browsers) for the top layer; without it, it is a fixed layer that a transformed ancestor can clip.
  • Content outside the shell stays focusable while the modal drawer is open; keep the shell as the page's root.

MaterialNextNavigationGroup is exported by app-shell.js. Use mnw-navigation-group around destinations to create expandable categories. active-path="#projects" supports hash routers; ordinary path routing keeps its existing matching. The footer slot accepts a page footer such as mnw-site-footer, outside the screen's main landmark. The drawer's own footer remains in navigation-footer.

mail