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.
Live preview — switch destinations, toggle the rail with the menu button, try search and the account menu
Last run
Passed - 3m 41s
Rows today
12.4M
Open reviews
3
Compact windows (below 600 px) replace the rail with this bottom bar — both are shown here for comparison
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.
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.