App shell
The frame of a web app or admin console: a navigation drawer that is modal or permanent by width, a top app bar with the screen's title and actions, and the screen.
import 'material-next-web-components/app-shell.js';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.
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.
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: () => <p>Published articles, newest first.</p> <ul class="console-list">${Object.entries(ARTICLES) .map(([id, title]) => <li><a href="feed/${id}/">${title}</a></li>) .join('')}</ul>,
}),
],
[
/^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:
() => <p>Type in the lesson, then pick another destination: the console asks first.</p> <label class="console-field">Lesson notes <textarea id="lesson" rows="5"></textarea></label>,
}),
],
[
/^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-requestfires when the back button is pressed; go where Up should go, or callhistory.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:
- Migrate Insider with the automatic modal/permanent policy, preserving deep links and unsaved-change guards.
- Convert metadata to a SPA behind a preview route, then use the same policy
with its own breakpoint before replacing
/admin. - Migrate GitHub Dev Tools with a modal drawer at all widths, preserving its full-width content, theme controls and footer links.
- 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-headingputs 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. Thebrandslot 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.
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
heading | heading | string | '' | The current screen's title, in the top app bar. |
headingLevel | heading-level | number | 1 | The heading level of the title. |
leading | leading | AppShellLeading | 'menu' | menu opens the modal drawer (hidden beside the permanent drawer),
back fires back-request, none shows nothing. |
activePath | active-path | string | '' | The current path; defaults to location.pathname. Marks the current destination. |
navigationMode | navigation-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. |
permanentFrom | permanent-from | DEFAULT_PERMANENT_DRAWER_FROM | The width, in CSS pixels of the shell, from which the drawer is permanent. | |
layoutPolicy | Property only | AppShellLayoutPolicy | undefined | Replaces permanent-from with a full policy, for layouts with more rules. | |
drawerOpen | drawer-open | boolean | false | The modal drawer is open. Always false beside a permanent drawer. |
layout | layout | AppShellNavigationMode | 'modal' | The layout in use, modal or permanent. Set by the shell; read it, or style on it. |
scrolled | scrolled | boolean | false | Content has scrolled under the top app bar. Set by the shell. |
navigationLabel | navigation-label | string | 'Navigation' | Accessible name of the navigation landmark. |
appearance | appearance | AppShellAppearance | '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. |
drawerHeading | drawer-heading | string | '' | 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. |
navigationSize | navigation-size | AppShellNavigationSize | 'standard' | standard 48px destinations; large 56px ones with 16px labels, as Material Components draws them. |
| Event | Description |
|---|---|
navigation-mode-change | {mode}: the layout switched between modal and permanent. |
drawer-toggle | {open}: the modal drawer opened or closed. |
back-request | The back button was pressed (leading="back"). |
navigation-request | Cancelable. {href, url, item}: a destination was activated. preventDefault() keeps the browser from following the link. |
| Slot | Description |
|---|---|
| Default | The screen. |
brand | The 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. |
footer | A page footer, below the main content. |
navigation-footer | The bottom of the drawer: a theme picker, legal links. |
actions | The top app bar's trailing actions, for example <mnw-product-launcher>. |
| Custom property | Description |
|---|---|
--mnw-app-shell-drawer-width | Width of the permanent drawer. Default 264px. |
--mnw-app-shell-modal-drawer-width | Width of the modal drawer. Default 320px, at most the window less 56px. |
--mnw-app-shell-top-app-bar-height | Height of the top app bar. Default 72px, 64px when the shell is narrower than 600px. |
--mnw-app-shell-pane-radius | The content pane's top-start corner beside the permanent drawer. Default 28px. |
| Part | Description |
|---|---|
drawer | The navigation drawer. |
drawer-header | The row with drawer-heading and the close button. |
top-app-bar | The top app bar. |
headline | The screen's title. |
content | The <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.
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
href | href | string | '' | Where the item goes. |
label | label | string | '' | The label, when the default slot is empty. |
icon | icon | string | '' | A Material Symbols name, when the icon slot is empty. |
trailingIcon | trailing-icon | string | '' | A Material Symbols name shown at the end, when the trailing slot is
empty: open_in_new for a link that leaves the site. |
target | target | string | '' | _blank opens a new tab; the shell then lets the link go without asking. |
match | match | DestinationMatch | 'prefix' | exact is current only on its own path; prefix also on paths under it. |
active | active | boolean | false | The current destination. Set by <mnw-app-shell>. |
| Slot | Description |
|---|---|
| Default | The label. Overrides label. |
icon | A custom icon, for example an image or an SVG. Overrides icon. |
badge | Trailing text, for example a count. |
trailing | A custom trailing icon. Overrides trailing-icon. |
| Custom property | Description |
|---|---|
--mnw-navigation-item-height | Minimum height. Default 48px; 56px in a shell with navigation-size="large". |
--mnw-navigation-item-height | Height of the item. Default 48px. |
| Part | Description |
|---|---|
link | The 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 bynavigation-label. The current destination hasaria-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.
Drawer categories and page footer
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.