Responsive app shell
Every screen starts with the chrome. This recipe assembles the standard
Material 3 application frame: md-app-bar in its
search variant hosting a docked md-search and an
avatar-triggered account menu, md-navigation-rail
for medium-and-up windows, md-navigation-bar
for compact ones, and md-breadcrumbs heading the
content region. Every piece renders at a fixed, token-driven size — 64px app
bar row, 80px rail, 64px bottom bar — so server-rendered markup paints in its
final position with zero layout shift. The demo shows the rail and the bar
at once so you can compare them; in production one media query swaps them at
the 600px compact breakpoint.
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<!-- The chrome renders at fixed, token-driven sizes: no layout shift on first paint. -->
<!-- A slotted md-search draws its own pill, hover overlay and press ripple —
blank the app bar's built-in ones (container via custom property, hover
state layer via its exported part, ripple via the opacity token that
cascades through the part): -->
<style>
md-app-bar::part(search-state-layer) { display: none; }
md-app-bar::part(search) { --md-ripple-opacity: 0; }
</style>
<md-app-bar variant="search" leading-icon="menu" leading-icon-label="Toggle navigation"
style="--md-app-bar-search-container-color: transparent; --md-app-bar-search-container-color-scrolled: transparent;">
<md-search slot="search" layout="docked" trigger="bar" full-width debounce="200"
placeholder="Search projects, files, and people">
<md-list slot="results" label="Search results"></md-list>
</md-search>
<md-icon-button slot="trailing" id="account" aria-haspopup="menu" aria-label="Account: Priya Raman">
<md-avatar initials="PR"></md-avatar>
</md-icon-button>
</md-app-bar>
<md-menu anchor="account" placement="bottom-end">
<md-menu-item headline="Profile"></md-menu-item>
<md-menu-item headline="Workspace settings" divider></md-menu-item>
<md-menu-item headline="Sign out"></md-menu-item>
</md-menu>
<!-- Desktop: side rail. Hide with a media query below 600px. -->
<md-navigation-rail class="wide-only" label="Main navigation" active-index="1">
<md-navigation-rail-tab icon="home" label="Home" value="home"></md-navigation-rail-tab>
<md-navigation-rail-tab icon="folder" label="Projects" value="projects"></md-navigation-rail-tab>
<md-navigation-rail-tab icon="rate_review" label="Reviews" value="reviews" badge-value="3"></md-navigation-rail-tab>
<md-navigation-rail-tab icon="settings" label="Settings" value="settings" badge></md-navigation-rail-tab>
</md-navigation-rail>
<main>
<md-breadcrumbs label="Breadcrumb">
<md-breadcrumb-item href="/" icon="home">Home</md-breadcrumb-item>
<md-breadcrumb-item href="/projects">Projects</md-breadcrumb-item>
<md-breadcrumb-item>Atlas ingestion pipeline</md-breadcrumb-item>
</md-breadcrumbs>
<h1>Atlas ingestion pipeline</h1>
</main>
<!-- Mobile: bottom bar. Show only below 600px. -->
<md-navigation-bar class="compact-only" aria-label="Main navigation" active-index="1">
<md-navigation-tab icon="home" active-icon="home" label="Home"></md-navigation-tab>
<md-navigation-tab icon="folder" active-icon="folder" label="Projects"></md-navigation-tab>
<md-navigation-tab icon="rate_review" label="Reviews" badge-value="3"></md-navigation-tab>
<md-navigation-tab icon="settings" label="Settings" badge></md-navigation-tab>
</md-navigation-bar>
<script type="module">
const appBar = document.querySelector('[data-appbar]');
const rail = document.querySelector('[data-rail]');
const bottomNav = document.querySelector('[data-bottom-nav]');
const crumbs = document.querySelector('[data-crumbs]');
const pageTitle = document.querySelector('[data-title]');
const content = document.querySelector('[data-content]');
const search = document.querySelector('[data-search]');
const results = document.querySelector('[data-results]');
const accountBtn = document.getElementById('shell-account');
const accountMenu = document.querySelector('[data-account-menu]');
const VALUES = ['home', 'projects', 'reviews', 'settings'];
const LABELS = { home: 'Home', projects: 'Projects', reviews: 'Reviews', settings: 'Settings' };
// Rebuild the breadcrumb trail and content header for a destination.
function setSection(value) {
let trail;
let heading;
if (value === 'home') {
trail = '<md-breadcrumb-item icon="home">Home</md-breadcrumb-item>';
heading = 'Home';
} else if (value === 'projects') {
trail =
'<md-breadcrumb-item href="#" icon="home">Home</md-breadcrumb-item>' +
'<md-breadcrumb-item href="#">Projects</md-breadcrumb-item>' +
'<md-breadcrumb-item>Atlas ingestion pipeline</md-breadcrumb-item>';
heading = 'Atlas ingestion pipeline';
} else {
trail =
'<md-breadcrumb-item href="#" icon="home">Home</md-breadcrumb-item>' +
'<md-breadcrumb-item>' + LABELS[value] + '</md-breadcrumb-item>';
heading = LABELS[value];
}
crumbs.innerHTML = trail;
pageTitle.textContent = heading;
// Swap the visible content pane to match — a destination change that
// only retitles the page reads as a bug, not a navigation.
document.querySelectorAll('[data-pane]').forEach((pane) => {
pane.style.display = pane.getAttribute('data-pane') === value ? 'grid' : 'none';
});
content.scrollTop = 0;
}
// Keep rail and bottom bar in lockstep. select() and a property write are
// both no-ops when the destination is already current, so this cannot loop.
rail.addEventListener('mdTabChange', (e) => {
bottomNav.select(VALUES.indexOf(e.detail.value));
setSection(e.detail.value);
});
bottomNav.addEventListener('mdChange', (e) => {
rail.activeIndex = e.detail.index;
setSection(VALUES[e.detail.index]);
});
// The app bar's menu button expands the rail to its labelled drawer form.
appBar.addEventListener('mdLeadingClick', () => { rail.toggle(); });
// scrolled is a prop you drive: swap to the tonal container on scroll.
content.addEventListener('scroll', () => {
appBar.scrolled = content.scrollTop > 0;
}, { passive: true });
// SPA-style breadcrumb interception: mdSelect is cancelable.
crumbs.addEventListener('mdSelect', (e) => { e.preventDefault(); });
// Account menu on the avatar button.
accountBtn.addEventListener('mdClick', () => { accountMenu.show(); });
// Filter the static suggestion list on the debounced query event.
search.addEventListener('mdSearch', (e) => {
const q = e.detail.value.toLowerCase();
results.querySelectorAll('md-list-item').forEach((item) => {
const text = (item.getAttribute('headline') || '').toLowerCase();
item.hidden = q !== '' && text.indexOf(q) === -1;
});
});
</script>import { useMemo, useRef, useState } from 'react';
import {
MdAppBar, MdAvatar, MdBreadcrumbItem, MdBreadcrumbs, MdIconButton,
MdList, MdListItem, MdMenu, MdMenuItem, MdNavigationBar,
MdNavigationRail, MdNavigationRailTab, MdNavigationTab, MdSearch,
} from '@awc-ui/react';
const DESTINATIONS = [
{ value: 'home', icon: 'home', label: 'Home' },
{ value: 'projects', icon: 'folder', label: 'Projects' },
{ value: 'reviews', icon: 'rate_review', label: 'Reviews', badgeValue: '3' },
{ value: 'settings', icon: 'settings', label: 'Settings', badge: true },
];
const SUGGESTIONS = [
{ headline: 'Atlas ingestion pipeline', supportingText: 'Project - updated 2h ago' },
{ headline: 'Billing dashboard', supportingText: 'Project - updated yesterday' },
{ headline: 'Q3 usage report.pdf', supportingText: 'File - 1.8 MB' },
{ headline: 'Priya Raman', supportingText: 'Person - Data Platform' },
];
const PANE_TEXT = {
home: 'Good morning, Priya — run #482 passed 12 minutes ago.',
projects: 'Last run passed in 3m 41s - 12.4M rows today - 3 open reviews.',
reviews: 'Schema change SC-119 is awaiting your review, opened yesterday.',
settings: 'Workspace Meridian Robotics - data region eu-central.',
};
export default function AppShell() {
// One state field drives rail, bottom bar, breadcrumbs, and the visible pane.
const [activeIndex, setActiveIndex] = useState(1);
const [scrolled, setScrolled] = useState(false);
const [query, setQuery] = useState('');
const railRef = useRef(null);
const menuRef = useRef(null);
const contentRef = useRef(null);
const section = DESTINATIONS[activeIndex];
const heading = section.value === 'projects' ? 'Atlas ingestion pipeline' : section.label;
const results = useMemo(() => {
const q = query.toLowerCase();
return SUGGESTIONS.filter((s) => q === '' || s.headline.toLowerCase().includes(q));
}, [query]);
const navigate = (index) => {
setActiveIndex(index);
if (contentRef.current) contentRef.current.scrollTop = 0;
};
const stay = (e) => e.preventDefault(); // SPA routing hook: mdSelect is cancelable
return (
<div className="shell">
<MdAppBar variant="search" leadingIcon="menu" leadingIconLabel="Toggle navigation"
scrolled={scrolled} onMdLeadingClick={() => railRef.current.toggle()}>
<MdSearch slot="search" layout="docked" trigger="bar" fullWidth debounce={200}
placeholder="Search projects, files, and people"
noResultsLabel="No matches in this workspace"
onMdSearch={(e) => setQuery(e.detail.value)}>
<MdList slot="results" label="Search results">
{results.map((s) => (
<MdListItem key={s.headline} type="button" headline={s.headline}
supportingText={s.supportingText} />
))}
</MdList>
</MdSearch>
<MdIconButton slot="trailing" id="account" aria-haspopup="menu"
aria-label="Account: Priya Raman" onMdClick={() => menuRef.current.show()}>
<MdAvatar initials="PR" />
</MdIconButton>
</MdAppBar>
<MdMenu ref={menuRef} anchor="account" placement="bottom-end">
<MdMenuItem headline="Profile" />
<MdMenuItem headline="Workspace settings" divider />
<MdMenuItem headline="Sign out" />
</MdMenu>
<div style={{ display: 'flex', height: 400 }}>
{/* Desktop destinations; hide below the 600px breakpoint in production */}
<MdNavigationRail ref={railRef} label="Main navigation" activeIndex={activeIndex}
onMdTabChange={(e) => navigate(e.detail.index)}>
{DESTINATIONS.map((d) => (
<MdNavigationRailTab key={d.value} icon={d.icon} label={d.label}
value={d.value} badge={d.badge} badgeValue={d.badgeValue} />
))}
</MdNavigationRail>
<main ref={contentRef} style={{ flex: 1, overflowY: 'auto' }}
onScroll={(e) => setScrolled(e.currentTarget.scrollTop > 0)}>
<MdBreadcrumbs label="Breadcrumb">
{section.value === 'home' ? (
<MdBreadcrumbItem icon="home">Home</MdBreadcrumbItem>
) : (
<>
<MdBreadcrumbItem href="/" icon="home" onMdSelect={stay}>Home</MdBreadcrumbItem>
{section.value === 'projects' && (
<MdBreadcrumbItem href="/projects" onMdSelect={stay}>Projects</MdBreadcrumbItem>
)}
<MdBreadcrumbItem>{heading}</MdBreadcrumbItem>
</>
)}
</MdBreadcrumbs>
<h1>{heading}</h1>
<p>{PANE_TEXT[section.value]}</p>
</main>
</div>
{/* Compact windows swap the rail for this bottom bar via a media query */}
<MdNavigationBar aria-label="Main navigation" activeIndex={activeIndex}
onMdChange={(e) => navigate(e.detail.index)}>
{DESTINATIONS.map((d) => (
<MdNavigationTab key={d.value} icon={d.icon} label={d.label}
badge={d.badge} badgeValue={d.badgeValue} />
))}
</MdNavigationBar>
</div>
);
}import { Component } from '@angular/core';
import {
MdAppBar, MdAvatar, MdBreadcrumbItem, MdBreadcrumbs, MdIconButton,
MdList, MdListItem, MdMenu, MdMenuItem, MdNavigationBar,
MdNavigationRail, MdNavigationRailTab, MdNavigationTab, MdSearch,
} from '@awc-ui/angular';
interface Destination {
value: string;
icon: string;
label: string;
badge?: boolean;
badgeValue?: string;
}
@Component({
selector: 'app-shell',
standalone: true,
imports: [
MdAppBar, MdAvatar, MdBreadcrumbItem, MdBreadcrumbs, MdIconButton,
MdList, MdListItem, MdMenu, MdMenuItem, MdNavigationBar,
MdNavigationRail, MdNavigationRailTab, MdNavigationTab, MdSearch,
],
// The proxies bind camelCase inputs and re-emit the component events, so the
// template stays declarative; #rail / #accountMenu expose toggle() / show().
template:
'<md-app-bar variant="search" leading-icon="menu" leading-icon-label="Toggle navigation"' +
' [scrolled]="scrolled" (mdLeadingClick)="rail.toggle()">' +
' <md-search slot="search" layout="docked" trigger="bar" full-width debounce="200"' +
' placeholder="Search projects, files, and people"' +
' no-results-label="No matches in this workspace"' +
' (mdSearch)="query = $event.detail.value">' +
' <md-list slot="results" label="Search results">' +
' @for (s of results; track s.headline) {' +
' <md-list-item type="button" [headline]="s.headline" [supportingText]="s.supportingText"></md-list-item>' +
' }' +
' </md-list>' +
' </md-search>' +
' <md-icon-button slot="trailing" id="account" aria-haspopup="menu"' +
' aria-label="Account: Priya Raman" (mdClick)="accountMenu.show()">' +
' <md-avatar initials="PR"></md-avatar>' +
' </md-icon-button>' +
'</md-app-bar>' +
'<md-menu #accountMenu anchor="account" placement="bottom-end">' +
' <md-menu-item headline="Profile"></md-menu-item>' +
' <md-menu-item headline="Workspace settings" divider></md-menu-item>' +
' <md-menu-item headline="Sign out"></md-menu-item>' +
'</md-menu>' +
'<md-navigation-rail #rail label="Main navigation" [activeIndex]="activeIndex"' +
' (mdTabChange)="navigate($event.detail.index, content)">' +
' @for (d of destinations; track d.value) {' +
' <md-navigation-rail-tab [icon]="d.icon" [label]="d.label" [value]="d.value"' +
' [badge]="d.badge" [badgeValue]="d.badgeValue"></md-navigation-rail-tab>' +
' }' +
'</md-navigation-rail>' +
'<main #content (scroll)="scrolled = content.scrollTop > 0">' +
' <md-breadcrumbs label="Breadcrumb">' +
' @if (isHome) {' +
' <md-breadcrumb-item icon="home">Home</md-breadcrumb-item>' +
' } @else {' +
' <md-breadcrumb-item href="/" icon="home" (mdSelect)="$event.preventDefault()">Home</md-breadcrumb-item>' +
' @if (isProjects) {' +
' <md-breadcrumb-item href="/projects" (mdSelect)="$event.preventDefault()">Projects</md-breadcrumb-item>' +
' }' +
' <md-breadcrumb-item>{{ heading }}</md-breadcrumb-item>' +
' }' +
' </md-breadcrumbs>' +
' <h1>{{ heading }}</h1>' +
' <p>{{ paneText[section.value] }}</p>' +
'</main>' +
'<md-navigation-bar aria-label="Main navigation" [activeIndex]="activeIndex"' +
' (mdChange)="navigate($event.detail.index, content)">' +
' @for (d of destinations; track d.value) {' +
' <md-navigation-tab [icon]="d.icon" [label]="d.label"' +
' [badge]="d.badge" [badgeValue]="d.badgeValue"></md-navigation-tab>' +
' }' +
'</md-navigation-bar>',
})
export class AppShellComponent {
activeIndex = 1;
scrolled = false;
query = '';
destinations: Destination[] = [
{ value: 'home', icon: 'home', label: 'Home' },
{ value: 'projects', icon: 'folder', label: 'Projects' },
{ value: 'reviews', icon: 'rate_review', label: 'Reviews', badgeValue: '3' },
{ value: 'settings', icon: 'settings', label: 'Settings', badge: true },
];
suggestions = [
{ headline: 'Atlas ingestion pipeline', supportingText: 'Project - updated 2h ago' },
{ headline: 'Billing dashboard', supportingText: 'Project - updated yesterday' },
{ headline: 'Q3 usage report.pdf', supportingText: 'File - 1.8 MB' },
{ headline: 'Priya Raman', supportingText: 'Person - Data Platform' },
];
paneText: Record<string, string> = {
home: 'Good morning, Priya — run #482 passed 12 minutes ago.',
projects: 'Last run passed in 3m 41s - 12.4M rows today - 3 open reviews.',
reviews: 'Schema change SC-119 is awaiting your review, opened yesterday.',
settings: 'Workspace Meridian Robotics - data region eu-central.',
};
get section(): Destination {
return this.destinations[this.activeIndex];
}
get isHome(): boolean {
return this.section.value === 'home';
}
get isProjects(): boolean {
return this.section.value === 'projects';
}
get heading(): string {
return this.isProjects ? 'Atlas ingestion pipeline' : this.section.label;
}
get results() {
const q = this.query.toLowerCase();
return this.suggestions.filter((s) => q === '' || s.headline.toLowerCase().includes(q));
}
navigate(index: number, content: HTMLElement) {
this.activeIndex = index;
content.scrollTop = 0;
}
}<script setup lang="ts">
import { computed, ref } from 'vue';
import {
MdAppBar, MdAvatar, MdBreadcrumbItem, MdBreadcrumbs, MdIconButton,
MdList, MdListItem, MdMenu, MdMenuItem, MdNavigationBar,
MdNavigationRail, MdNavigationRailTab, MdNavigationTab, MdSearch,
} from '@awc-ui/vue';
const destinations = [
{ value: 'home', icon: 'home', label: 'Home' },
{ value: 'projects', icon: 'folder', label: 'Projects' },
{ value: 'reviews', icon: 'rate_review', label: 'Reviews', badgeValue: '3' },
{ value: 'settings', icon: 'settings', label: 'Settings', badge: true },
];
const suggestions = [
{ headline: 'Atlas ingestion pipeline', supportingText: 'Project - updated 2h ago' },
{ headline: 'Billing dashboard', supportingText: 'Project - updated yesterday' },
{ headline: 'Q3 usage report.pdf', supportingText: 'File - 1.8 MB' },
{ headline: 'Priya Raman', supportingText: 'Person - Data Platform' },
];
const paneText: Record<string, string> = {
home: 'Good morning, Priya — run #482 passed 12 minutes ago.',
projects: 'Last run passed in 3m 41s - 12.4M rows today - 3 open reviews.',
reviews: 'Schema change SC-119 is awaiting your review, opened yesterday.',
settings: 'Workspace Meridian Robotics - data region eu-central.',
};
const activeIndex = ref(1);
const scrolled = ref(false);
const query = ref('');
const rail = ref();
const accountMenu = ref();
const content = ref<HTMLElement>();
const section = computed(() => destinations[activeIndex.value]);
const heading = computed(() =>
section.value.value === 'projects' ? 'Atlas ingestion pipeline' : section.value.label,
);
const results = computed(() => {
const q = query.value.toLowerCase();
return suggestions.filter((s) => q === '' || s.headline.toLowerCase().includes(q));
});
function navigate(index: number) {
activeIndex.value = index;
if (content.value) content.value.scrollTop = 0;
}
</script>
<template>
<MdAppBar variant="search" leading-icon="menu" leading-icon-label="Toggle navigation"
:scrolled="scrolled" @md-leading-click="rail.$el.toggle()">
<MdSearch slot="search" layout="docked" trigger="bar" full-width :debounce="200"
placeholder="Search projects, files, and people"
no-results-label="No matches in this workspace"
@md-search="query = $event.detail.value">
<MdList slot="results" label="Search results">
<MdListItem v-for="s in results" :key="s.headline" type="button"
:headline="s.headline" :supporting-text="s.supportingText" />
</MdList>
</MdSearch>
<MdIconButton slot="trailing" id="account" aria-haspopup="menu"
aria-label="Account: Priya Raman" @md-click="accountMenu.$el.show()">
<MdAvatar initials="PR" />
</MdIconButton>
</MdAppBar>
<MdMenu ref="accountMenu" anchor="account" placement="bottom-end">
<MdMenuItem headline="Profile" />
<MdMenuItem headline="Workspace settings" divider />
<MdMenuItem headline="Sign out" />
</MdMenu>
<!-- Desktop destinations; hide below the 600px breakpoint in production -->
<MdNavigationRail ref="rail" label="Main navigation"
:active-index="activeIndex" @md-tab-change="navigate($event.detail.index)">
<MdNavigationRailTab v-for="d in destinations" :key="d.value" :icon="d.icon"
:label="d.label" :value="d.value" :badge="d.badge" :badge-value="d.badgeValue" />
</MdNavigationRail>
<main ref="content" @scroll.passive="scrolled = (content?.scrollTop ?? 0) > 0">
<MdBreadcrumbs label="Breadcrumb">
<MdBreadcrumbItem v-if="section.value === 'home'" icon="home">Home</MdBreadcrumbItem>
<template v-else>
<MdBreadcrumbItem href="/" icon="home" @md-select="$event.preventDefault()">Home</MdBreadcrumbItem>
<MdBreadcrumbItem v-if="section.value === 'projects'" href="/projects"
@md-select="$event.preventDefault()">Projects</MdBreadcrumbItem>
<MdBreadcrumbItem>{{ heading }}</MdBreadcrumbItem>
</template>
</MdBreadcrumbs>
<h1>{{ heading }}</h1>
<p>{{ paneText[section.value] }}</p>
</main>
<!-- Compact windows swap the rail for this bottom bar via a media query -->
<MdNavigationBar aria-label="Main navigation"
:active-index="activeIndex" @md-change="navigate($event.detail.index)">
<MdNavigationTab v-for="d in destinations" :key="d.value" :icon="d.icon"
:label="d.label" :badge="d.badge" :badge-value="d.badgeValue" />
</MdNavigationBar>
</template><script>
// The svelte package registers the raw md-* elements — client-only.
// In SvelteKit guard it: if (browser) defineCustomElements(window);
import { defineCustomElements } from '@awc-ui/svelte';
import { onMount } from 'svelte';
onMount(() => defineCustomElements(window));
let rail;
let accountMenu;
let content;
let activeIndex = 1;
let scrolled = false;
let query = '';
const destinations = [
{ value: 'home', icon: 'home', label: 'Home' },
{ value: 'projects', icon: 'folder', label: 'Projects' },
{ value: 'reviews', icon: 'rate_review', label: 'Reviews', badgeValue: '3' },
{ value: 'settings', icon: 'settings', label: 'Settings', badge: true },
];
const suggestions = [
{ headline: 'Atlas ingestion pipeline', supportingText: 'Project - updated 2h ago' },
{ headline: 'Billing dashboard', supportingText: 'Project - updated yesterday' },
{ headline: 'Q3 usage report.pdf', supportingText: 'File - 1.8 MB' },
{ headline: 'Priya Raman', supportingText: 'Person - Data Platform' },
];
const paneText = {
home: 'Good morning, Priya — run #482 passed 12 minutes ago.',
projects: 'Last run passed in 3m 41s - 12.4M rows today - 3 open reviews.',
reviews: 'Schema change SC-119 is awaiting your review, opened yesterday.',
settings: 'Workspace Meridian Robotics - data region eu-central.',
};
$: section = destinations[activeIndex];
$: heading = section.value === 'projects' ? 'Atlas ingestion pipeline' : section.label;
$: results = suggestions.filter(
(s) => query === '' || s.headline.toLowerCase().includes(query.toLowerCase()),
);
function navigate(index) {
activeIndex = index;
if (content) content.scrollTop = 0;
}
</script>
<md-app-bar variant="search" leading-icon="menu" leading-icon-label="Toggle navigation"
scrolled={scrolled} on:mdLeadingClick={() => rail.toggle()}>
<md-search slot="search" layout="docked" trigger="bar" full-width debounce="200"
placeholder="Search projects, files, and people"
no-results-label="No matches in this workspace"
on:mdSearch={(e) => (query = e.detail.value)}>
<md-list slot="results" label="Search results">
{#each results as s (s.headline)}
<md-list-item type="button" headline={s.headline}
supporting-text={s.supportingText}></md-list-item>
{/each}
</md-list>
</md-search>
<md-icon-button slot="trailing" id="account" aria-haspopup="menu"
aria-label="Account: Priya Raman" on:mdClick={() => accountMenu.show()}>
<md-avatar initials="PR"></md-avatar>
</md-icon-button>
</md-app-bar>
<md-menu bind:this={accountMenu} anchor="account" placement="bottom-end">
<md-menu-item headline="Profile"></md-menu-item>
<md-menu-item headline="Workspace settings" divider></md-menu-item>
<md-menu-item headline="Sign out"></md-menu-item>
</md-menu>
<!-- Desktop destinations; hide below the 600px breakpoint in production -->
<md-navigation-rail bind:this={rail} label="Main navigation"
active-index={activeIndex} on:mdTabChange={(e) => navigate(e.detail.index)}>
{#each destinations as d (d.value)}
<md-navigation-rail-tab icon={d.icon} label={d.label} value={d.value}
badge={d.badge} badge-value={d.badgeValue}></md-navigation-rail-tab>
{/each}
</md-navigation-rail>
<main bind:this={content} on:scroll={() => (scrolled = content.scrollTop > 0)}>
<md-breadcrumbs label="Breadcrumb">
{#if section.value === 'home'}
<md-breadcrumb-item icon="home">Home</md-breadcrumb-item>
{:else}
<md-breadcrumb-item href="/" icon="home"
on:mdSelect={(e) => e.preventDefault()}>Home</md-breadcrumb-item>
{#if section.value === 'projects'}
<md-breadcrumb-item href="/projects"
on:mdSelect={(e) => e.preventDefault()}>Projects</md-breadcrumb-item>
{/if}
<md-breadcrumb-item>{heading}</md-breadcrumb-item>
{/if}
</md-breadcrumbs>
<h1>{heading}</h1>
<p>{paneText[section.value]}</p>
</main>
<!-- Compact windows swap the rail for this bottom bar via a media query -->
<md-navigation-bar aria-label="Main navigation"
active-index={activeIndex} on:mdChange={(e) => navigate(e.detail.index)}>
{#each destinations as d (d.value)}
<md-navigation-tab icon={d.icon} label={d.label}
badge={d.badge} badge-value={d.badgeValue}></md-navigation-tab>
{/each}
</md-navigation-bar>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-app-bar | The banner. variant="search" puts a search pill in the 64px row; the host is already position: sticky; top: 0, so it pins itself to its scroll container. scrolled is a prop you set from your own scroll listener — the bar never measures the page. |
md-search | Slotted into the app bar’s search slot, which replaces the built-in input entirely. layout="docked" anchors the results panel beneath the bar; debounce="200" gates mdSearch so the filter runs once per pause, not per keystroke. |
md-navigation-rail | Desktop destinations. mdTabChange carries a stable value for routing; toggle() swaps between the 80px icon rail and the 220px labelled drawer. Badge counts ride on each md-navigation-rail-tab via badge-value. |
md-navigation-bar | The same four destinations for compact windows. It owns active-index, roving focus, and label policy; select() is a no-op on a reselect, which is what makes the two-way sync with the rail loop-safe. |
md-breadcrumbs | The content header’s where-am-I trail. The last crumb is auto-promoted to the non-interactive current page, and mdSelect is cancelable for SPA routing. |
md-menu | The account menu, anchored to the avatar button by id. It writes aria-haspopup / aria-expanded onto the trigger while open and returns focus to it on Escape. |
md-avatar + md-icon-button | The signed-in user. The avatar itself is never focusable, so it sits inside an icon button that carries the accessible name and the click target. |
The details that make it production-grade
Section titled “The details that make it production-grade”- Zero layout shift is structural, not tuned. The app bar row is a fixed
64px, the rail 80px wide, the bottom bar 64px tall — all deterministic
tokens, and nothing in the chrome measures the DOM to lay itself out (even
the app bar’s
scrolledtonal swap is a prop you drive). Server-rendered or statically generated markup paints in its final geometry before any script runs. - Landmarks come free and stay unique. The app bar is the page’s one
role="banner"; rail and bar arenavigationlandmarks named bylabel/aria-label. Because only one of the two navigation surfaces exists per breakpoint in production, screen-reader users always find exactly one “Main navigation”. - Keyboard behavior is owned by the containers. Rail and bar manage roving
tabindex: one tab stop each, arrow keys move between destinations (wrapping,
RTL-aware),
Home/Endjump to the ends. The account menu adds typeahead,Escape-to-close, and focus return to the avatar button. - Badge counts are announced, not just drawn.
badge-value="3"renders arole="status"capsule (“3 new”), numeric values cap at999+, and the dot variant flags activity without a number — no extra ARIA wiring needed. - Breadcrumbs are real links with real semantics. The trail is an
<ol>inside a labelled navigation region, separators arearia-hidden, the last crumb automatically getsaria-current="page", andpreventDefault()onmdSelectis the sanctioned SPA-routing hook — middle-click and copy-link-address keep working.
Variations
Section titled “Variations”- Expandable rail: add
expandableto render the built-in menu/menu_open toggle instead of drivingtoggle()from the app bar, or addmodalso the expanded drawer floats over content with a scrim instead of reflowing it. - Horizontal rail as an application bar: on wide, content-hungry layouts
(tables, editors),
orientation="horizontal"lays the same destinations out as a top bar — brand inslot="logo", account inslot="footer"— spending height instead of width. - More destinations than fit:
max-visible="4"on the rail collapses the rest behind an overflow menu trigger that stays outside the tablist and still activates destinations first-class. - Full-screen search on compact: switch the slotted
md-searchtolayout="full-screen"below the breakpoint — it locks document scroll, traps Tab in the overlay, and keeps the samemdSearchwiring.
Router-controlled destinations
Section titled “Router-controlled destinations”The example above synchronizes local view state. A router needs a separate user
intent handler: md-navigation-bar emits mdChange for programmatic activeIndex
updates too. Treating every change as navigation can turn browser Back/Forward
into another route push.
Use manual-activation and listen to mdTabClick on each destination. Stop
that event before it reaches the bar when route guards decide whether to accept
it. After the router confirms the location, write its index back to the bar.
Reselecting the current destination can be handled separately by the app.
const bar = document.querySelector('md-navigation-bar');const destinations = ['/', '/projects', '/reviews', '/settings'];bar.manualActivation = true;bar.querySelectorAll('md-navigation-tab').forEach((tab, index) => { tab.addEventListener('mdTabClick', (event) => { event.stopPropagation(); router.navigate(destinations[index]); });});
// Wire this to your router's location subscription, including Back/Forward.function reflectLocation(pathname) { const index = destinations.indexOf(pathname); if (index >= 0) bar.activeIndex = index;}In React, the equivalent is manualActivation on MdNavigationBar and
onMdTabClick on each MdNavigationTab. Keep the activeIndex prop derived from
the confirmed route. See the navigation bar manual
for selection and event details.
When a search result opens another overlay, close search and await
search.whenClosed() before showing the destination dialog. This waits for
search’s exit motion, focus return, and scroll cleanup; a timeout cannot reliably
follow reduced-motion preferences or a customized transition duration.
Related
Section titled “Related”- Recipe: Inbox with reading pane — a full working screen built inside exactly this chrome, rail badges included.
- Recipe: Two-factor verification — the auth screen that typically sits just outside this shell.
- Component manuals:
md-app-bar,md-navigation-rail,md-navigation-bar— full API, breakpoint guidance, and the anti-patterns lists.