Material Next
On this page

Overview

Apps built on App Toolkit share one shell: the app bar and its search, the navigation the window calls for, the system bars and the sheets and drawers over the content. The Android preview draws that shell once, for every marketing UI, from a declaration of the app:

  • defineProductIdentity(config): the app's name, icons, fonts, palettes and destinations, declared once. identity.app is the shell declaration; identity.product is what the marketing hub shows. See Product identity.
  • definePreviewApp(config): the shell declaration alone (name, mark, tabs, drawer entries and footer, overflow menu), for a preview without a hub.
  • previewScreen(app, state, destination): what a screen renders. The destination gives its tab, title, app bar, content, FAB and overlay; nothing else.
  • <mnw-android-screen>: the element previewScreen() renders. Use it directly when you wire events yourself.

Which controls appear is decided by previewChrome(), never by a screen, following App Toolkit's ShellChrome.kt and ShellFrame.kt:

Layout (width) Navigation App bar on a tab Rail button
Bottom bar (< 600 dp) Navigation bar, modal drawer Menu opens the drawer None
Rail (600 dp) Collapsed rail No menu Expand: opens a modal rail
Expanded rail (840 dp) Rail, collapsed or expanded No menu Expand or collapse
Permanent drawer (1200 dp) Drawer with the tabs No menu None

A destination is a tab's root, a child pushed onto the tab, or a page such as settings or help. A child keeps the navigation bar and marks no tab. A page covers the navigation bar on a phone and opens beside the rail or drawer on wider windows. Both show Back.

The app bar on a tab's root names the app until the navigation beside it names the app, as an expanded rail or the permanent drawer does. From then on it names the tab. A child or page names itself through title, which also overrides the shell's title anywhere. When the title changes, the new one fades in, and an expanding rail shows its name and labels once it has opened.

The overflow button appears on a tab's root when the app declares overflow entries, beside any actions the destination adds. An expanded rail stays expanded when another tab or entry opens.

Live demo

phone: navigation bar, menu, modal drawer
fold: expandable rail, no menu in the app bar
desktop: permanent drawer with the tabs
The same declaration in three windows: a bottom bar, an expandable rail, a permanent drawer.
<div class="frames-demo">
  <figure>
    <div data-window="phone"></div>
    <figcaption><code>phone</code>: navigation bar, menu, modal drawer</figcaption>
  </figure>
  <figure>
    <div data-window="fold"></div>
    <figcaption><code>fold</code>: expandable rail, no menu in the app bar</figcaption>
  </figure>
  <figure>
    <div data-window="desktop"></div>
    <figcaption><code>desktop</code>: permanent drawer with the tabs</figcaption>
  </figure>
</div>
import {html} from 'lit';
import {
  ScreenHost,
  createScreenRegistry,
  definePreviewApp,
  previewScreen,
  type MarketingScreen,
  type PreviewScreenState,
} from 'material-next-web-components/marketing.js';

// One declaration for the whole app. Settings and Help are pages this preview does not draw.
const APP = definePreviewApp({
  name: 'Sample',
  tabs: [
    {id: 'today', label: 'Today', icon: 'today'},
    {id: 'tools', label: 'Tools', icon: 'grid_view'},
  ],
  drawer: {
    footer: [
      {id: 'settings', label: 'Settings', icon: 'settings'},
      {id: 'help', label: 'Help & feedback', icon: 'help_outline'},
    ],
  },
});

// Each screen renders its content and says which tab it is; the shell does the rest.
const tab = (id: 'today' | 'tools', text: string): MarketingScreen => ({
  id,
  title: id,
  description: text,
  defaults: {sheet: false},
  render: (s) => {
    const state = s as PreviewScreenState;
    return previewScreen(APP, state, {
      tab: id,
      content: html`<div style="padding: 16px; display: grid; gap: 12px">
        <p style="margin: 0">${text}</p>
        <button @click=${() => state.set?.({sheet: true})}>Open a sheet</button>
      </div>`,
      overlay: state.sheet
        ? {
            presentation: 'sheet',
            label: 'Details',
            content: html`<p style="margin: 0 0 16px">
              A sheet. Escape, the scrim or Back closes it.
            </p>`,
            onDismiss: () => state.set?.({sheet: false}),
          }
        : null,
    });
  },
});
const registry = createScreenRegistry([
  tab('today', 'The bottom bar layout: the menu opens the drawer.'),
  tab('tools', 'Beside a rail or a drawer, the app bar has no menu.'),
]);

export default (root: HTMLElement) => {
  const hosts = [...root.querySelectorAll<HTMLElement>('[data-window]')].map(
    (el) =>
      new ScreenHost(el, {
        registry,
        id: 'today',
        window: el.dataset.window,
        frame: true,
        onGo: (id, state, host) => host.load(id, state),
      })
  );
  return () => hosts.forEach((h) => h.destroy());
};

Usage

import {
  defineProductIdentity,
  previewScreen,
  supportUs,
  toolkitFooter,
} from 'material-next-web-components/marketing.js';

// src/config/identity.js: toolkitGraph(appTitle, appIcon) { tab(...); drawer { toolkitFooter() }; overflow { supportUs() } }
export const IDENTITY = defineProductIdentity({
  id: 'app-toolkit',
  name: 'App Toolkit',
  icons: {app: '/logos/icon.svg', logo: '/logos/app_logo.svg'},
  fonts: {brand: {family: 'Google Sans Flex', src: 'fonts/GoogleSansFlex.ttf', weight: '1 1000', stretch: '25% 151%'}},
  colors: {palettes: ['captured', 'dynamic', 'blue'], defaultPalette: 'captured'},
  destinations: {
    tabs: [
      {id: 'quick-tools', label: 'Quick Tools', icon: {name: 'grid_view', rounded: true}},
      {id: 'numbers', label: 'Measure', icon: 'straighten'},
      {id: 'apps', label: 'Apps', icon: 'apps'},
    ],
    drawer: {footer: toolkitFooter()},
    overflow: [supportUs()],
  },
});

// In a MarketingScreen:
render(s) {
  return previewScreen(IDENTITY.app, s, {
    tab: 'numbers',
    title: 'Measure',
    content: html`...`,
    overlay: s.sheet ? {presentation: 'sheet', label: 'Counter', content: html`...`, onDismiss: () => s.set({sheet: null})} : null,
  });
}

Icons are Material Symbols names. {name, rounded: true} draws one from Material Symbols Rounded, which the page must load (material-symbols/rounded.css) as it loads Outlined.

The drawer, rail and menu state lives in the screen's own state as drawerOpen, railExpanded, railOpen and overflowOpen, so a preset or a URL (state={"drawerOpen":true}) can open them. A tab opens its screen (default: its id) with go(). An entry without screen is a page the preview does not draw: it is drawn like the app draws it, carries data-preview-only and the title "Not part of this preview", and only closes the navigation.

Product identity

defineProductIdentity() is the one place an app names itself. Everything that draws the app reads it, so the app bar, the rail, the drawers, the previews and the marketing hub cannot disagree:

Field What reads it
id The hub, to keep each app's saved preview settings apart.
name, label name is the store's and the hub's. label is the name the app draws on itself (R.string.app_name): the app bar on a tab's root, the expanded rail, the drawer header. It defaults to name.
icons app is the square launcher icon: the hub. logo is the mark beside the name inside the app (ShellGraph.appIcon): the drawer header; it defaults to app. monochrome is the one-colour variant for artwork that needs it.
fonts brand and plain become --md-ref-typeface-brand and --md-ref-typeface-plain, which the shell, Material Web and the app's type scale read. With src, loadProductFonts() registers the face from the app's assets. symbols lists the icon fonts to wait for before a capture.
colors palettes lists the app's data-palette values in picker order (captured keeps the page's own tokens); defaultPalette is where previews start; seed is the brand colour for anything outside the app.
destinations tabs, drawer and overflow, as in definePreviewApp().
links privacy and codeOfConduct in the hub's drawer; repository for the app's own pages.

Assets stay in the app. The identity holds their paths and resolves them through identity.asset(path) against a base the host can move with identity.setAssetBase(), as a Remotion composition does for staticFile(); the drawer's mark is resolved when it is drawn, so a base set after the identity loads still applies.

App Toolkit presets

toolkitFooter({updates?, screens?}) and supportUs({screen?}) are the App Toolkit library's own entries, as ToolkitDrawer.kt and overflow { supportUs() } declare them: Settings, Help & feedback, Updates (omit with updates: false when the app has no changelog) and Share, and Support the app. An app on App Toolkit declares only its tabs and its own entries; their pages are the library's, so they close the navigation unless the app maps one to a screen it draws.

Overlays

overlay.presentation is sheet (a ModalBottomSheet, opened straight to expanded over a 32% scrim), dialog (an AlertDialog centred over the scrim, 280 to 560 dp wide by its content) or fullscreen (a full-screen dialog over the status bar). handle: true gives a sheet its drag handle; flush: true leaves the padding to the content, and a flush sheet lays its content out as a column, so the content's own scroll area scrolls and its footer stays pinned. The shell owns what every overlay needs:

  • It is drawn inside the screen and clipped by it: no popover or top-layer content escapes the device frame.
  • The content behind it is inert; focus moves into it when it opens and back to what opened it when it closes.
  • Escape, the system Back button and the scrim close the topmost layer: the overflow menu, then the drawer or modal rail, then the overlay (onDismiss).

Scrolling, bottom bars and lists

  • bottom is the destination's own bottom bar (Scaffold's bottomBar): an action row or a message field between the content and the navigation bar. Beside a rail it keeps clear of the system navigation.
  • collapse (0 to 1) drives a large app bar for a screen that scrolls from its state, so a capture of any position is deterministic. Without it the bar follows the content's own scrolling, and onScroll reports the position (scrollTop, scrollHeight, clientHeight, collapseHeight) for a screen that follows it, such as reading progress.
  • scrollPosition starts the content scrolled, for a capture of a scrolled screen. titleLines lets a large title wrap before it ellipsises.
  • hideNavigationBar hides the navigation bar on scroll (HideOnScrollBottomBar) and gives its inset back to the content.
  • A child drawn as the detail beside its tab's list on a wide window sets besideList, and its tab stays marked, as highlightedTab does.
  • Drawer entries take a badge: a count, or '' for the dot. preview.statusBar: 'blank' on the identity leaves the status bar empty, for timeless captures.

Captures and videos

previewScreen() sets interactive only when the state has set(). Without it (createMarketingEmbed(), Remotion) the shell renders the state it is given: no handlers, no entrance animations, overlays already settled. settle() waits for the shell's own Material Web parts inside its shadow root.

API

An app screen inside the App Toolkit shell, drawn as Compose draws it: status bar, app bar, the navigation the window's layout calls for (bar, rail, expanded rail or permanent drawer), the system navigation, and the modal drawer, modal rail, overflow menu and sheets over it.

The app declares itself once with definePreviewApp(); each destination sets its tab, title, app bar and overlay. Which buttons appear comes from previewChrome(), never from the screen. Content stays in light DOM, so the app's own CSS applies to it.

Without interactive (videos and captures) nothing reacts and nothing animates in: the shell renders the state it is given. Interactive, it never changes its own state; it asks the host:

<mnw-android-screen> properties
PropertyAttributeTypeDefaultDescription
appProperty onlyPreviewApp | undefined
windowProperty onlystring | WindowSpec | undefinedA window name (phone, fold, desktop) or size.
navTypenav-type'gesture' | 'buttons''gesture'
tabtabstring | undefinedThe tab this destination belongs to, selected in the navigation.
kindkindPreviewDestinationKind'root'
besideListbeside-listbooleanfalseA child open as the detail beside its tab's list: its tab stays marked in the navigation.
headingheadingstring | undefinedThe app bar title, overriding the shell's: a tab's root names the app, or the tab once the navigation beside it names the app; a child or page has no default.
topBartop-barPreviewTopBar'small'
searchPlaceholdersearch-placeholderstring'Search'
searchValuesearch-valuestring''
actionsProperty onlyPreviewAction[][]
navigationProperty onlyPreviewNavigationState{}
overlayProperty onlyPreviewOverlay | nullnull
scrolledscrolledbooleanfalseThe app bar and status bar take the scrolled colour.
collapsecollapsenumber | undefinedHow far a large app bar has collapsed, 0 to 1, for screens that scroll from their state (captures). Unset, the bar follows the content's own scrolling.
titleLinestitle-linesnumber1A large title's lines before it ellipsises (an article's title wraps to two).
scrollPositionscroll-positionnumber | undefinedWhere the content is scrolled to, for screens and captures that start scrolled. Applied when it changes; the person can scroll on from there.
hideNavigationBarhide-navigation-barbooleanfalseThe navigation bar has hidden on scroll (HideOnScrollBottomBar): the content takes its place.
interactiveinteractivebooleanfalseLive preview: reacts to input and animates overlays in. Off in videos and captures.
<mnw-android-screen> events
EventDescription
type
preview-navigation{patch}: a PreviewNavigationState change (drawer, rail, menu).
preview-tab{tab}: a tab was chosen.
preview-entry{entry}: a drawer, rail or overflow entry was chosen.
preview-backThe app bar's or the system's Back.
preview-search{value}: the tab's search text changed.
preview-action{id}: an app bar action was pressed.
preview-dismissThe overlay was dismissed: scrim, Escape or Back.
preview-scroll{scrollTop, scrollHeight, clientHeight, collapseHeight}: the content scrolled.
<mnw-android-screen> slots
SlotDescription
DefaultThe destination's content.
fabA floating action button, placed above the navigation bar and the system bars.
bottomThe destination's own bottom bar (Scaffold's bottomBar): an action row or a message field, between the content and the navigation bar.
topReplaces the app bar, for a destination with its own (an article's).
overlayThe overlay's content (see overlay).

Accessibility

  • Navigation buttons are labelled "Open navigation drawer", "Expand navigation", "Collapse navigation" and "Navigate up"; the rail button reports aria-expanded.
  • The drawer, modal rail and overlays are role="dialog" with aria-modal; the overflow menu is role="menu".
  • The selected tab and drawer destination carry aria-current="page".

Limitations

  • List-detail panes and the scrolling behaviours of large app bars beyond collapse are left to the app's content.
  • The system Home and Recents buttons are drawn but do nothing.
mail