Skip to content

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
Home Projects Atlas ingestion pipeline Atlas ingestion pipeline
Good morning, Priya
Jump back in Atlas ingestion pipeline — run #482 passed 12 minutes ago
Waiting on you 3 reviews — oldest is schema change SC-119, opened yesterday
Workspace Meridian Robotics — 14 members, 6 active projects
Schema change SC-119 — events partitioning Awaiting your review — opened yesterday by Jonas
Retry policy — exponential backoff Approved by you — merged this morning
Ingest alert threshold 5% → 2% Changes requested — waiting on Alice
Workspace Meridian Robotics
Data region eu-central
The full pattern for a page like this lives in the Notification preferences recipe.
Last run Passed - 3m 41s
Rows today 12.4M
Open reviews 3
Recent activity
Scheduled run #482 finished - 09:20
Priya Raman approved schema change SC-118 - 08:54
Retry policy updated to exponential backoff - yesterday
Source "events-eu" reconnected after 6m outage - yesterday
Scheduled run #481 finished - yesterday
Scroll this pane — the app bar swaps to its scrolled container color.
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>
ComponentRole in this screen
md-app-barThe 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-searchSlotted 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-railDesktop 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-barThe 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-breadcrumbsThe 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-menuThe 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-buttonThe 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.
  • 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 scrolled tonal 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 are navigation landmarks named by label / 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/End jump 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 a role="status" capsule (“3 new”), numeric values cap at 999+, 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 are aria-hidden, the last crumb automatically gets aria-current="page", and preventDefault() on mdSelect is the sanctioned SPA-routing hook — middle-click and copy-link-address keep working.
  • Expandable rail: add expandable to render the built-in menu/menu_open toggle instead of driving toggle() from the app bar, or add modal so 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 in slot="logo", account in slot="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-search to layout="full-screen" below the breakpoint — it locks document scroll, traps Tab in the overlay, and keeps the same mdSearch wiring.