Material Next
On this page

What a marketing hub is

A marketing hub is a small web project, usually marketing/ui in the app's repository, that renders the app's real screens from a state object. Because each screen is a pure function of its state, the same code serves:

  • a gallery where every screen runs live, with theme, palette, navigation and device-frame previews;
  • a prototype page for one screen, with its controls, presets, window sizes and side-by-side comparisons;
  • captures: PNGs for store listings and posts, from the prototype page's capture mode;
  • videos: Remotion or any DOM-based tool mounts a screen per frame.

This guide builds one for Pocket Notes, a sample app with three screens. Its files are in this repository under src/docs/sample-app/ and docs-src/demos/assets/pocket-notes.css, small enough to copy as a starting point.

Marketing gallery · connected drawer

Open the connected marketing gallery
The finished sample hub. It is a demonstration: Pocket Notes is not a real app.

Open the sample hub in its own tab

Who provides what

The library provides The app provides
The gallery page: shell, top app bar, preview settings, cards Its screens: Lit templates translated from its Composables
The prototype page: inspector, compare mode, window sizes, capture mode Its theme: palettes as [data-palette] CSS, fonts, kit styles
ScreenHost: state, set() and go(), palette and theme, device frames, the animation clock Its product config: name, icon, links
The embed API for videos and capture scripts Its own motion parts, illustrations and campaign code
Device art, the Play badge and App Toolkit's window rules Its brand: the hub chrome stays neutral so the screens carry it

The library never styles a screen. It renders each screen into a light-DOM element and sets data-palette and data-theme on it; the app's CSS does the rest.

Install

npm install material-next-web-components lit @material/web

lit and @material/web are peer dependencies, so the app and the hub share one copy of each. Any bundler that understands package exports works; the examples use Vite.

The product

One identity describes the app to everything that draws it: its name, icons, fonts, palettes, tabs and links. Keep it in src/config/identity.js; the hub reads IDENTITY.product, the Android preview IDENTITY.app, and the screens take their tabs from it.

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

/** Pocket Notes is the documentation sample, not a real product: its whole identity, declared once. /
export const IDENTITY = defineProductIdentity({
  id: 'pocket-notes-sample',
  name: 'Pocket Notes',
  // The bundles sit at the site root, next to demos/.
  icons: {
    app: new URL('./demos/assets/pocket-notes-icon.svg', import.meta.url).href,
  },
  // The palettes are defined in pocket-notes.css.
  colors: {palettes: ['ocean', 'clay']},
  destinations: {
    tabs: [
      {id: 'notes', label: 'Notes', icon: 'sticky_note_2'},
      {id: 'focus', label: 'Focus', icon: 'timer'},
    ],
  },
  links: {
    privacy: 'https://mihaicristiancondrea.github.io/profile/#privacy-policy',
    codeOfConduct:
      'https://mihaicristiancondrea.github.io/profile/#code-of-conduct',
  },
});

/* What the hub reads: the name, the icon and the legal links. */
export const PRODUCT = IDENTITY.product;
export const PALETTES = IDENTITY.palettes;

id namespaces the preview settings saved in the browser, so two hubs on localhost:5173 keep their own. The identity's fields are on the Android preview page; the product the hub receives is on the marketing shell page. Anything only the hub shows (a headline, badges, stories) stays in src/config/product.js beside it.

Screens

A screen is a MarketingScreen: metadata, defaults for its state, and render(state), which returns a Lit template. Here is the sample's home screen; frame() is the app's own helper that draws the status bar, the bottom bar or rail, and the system navigation.

interface NotesState extends Record<string, unknown> {
  filter: 'all' | 'pinned';
}

export const notesScreen: MarketingScreen<NotesState> = {
  id: 'notes',
  title: 'Notes',
  description:
    'The home screen: every note, with a Pinned filter and a button for a new note.',
  category: 'notes',
  categoryLabel: 'Notes',
  palette: 'ocean',
  defaults: {filter: 'all'},
  controls: [
    {
      type: 'choice',
      key: 'filter',
      label: 'Filter',
      options: ['all', 'pinned'],
    },
    {
      type: 'presets',
      label: 'Presets',
      options: [
        ['Everything', {filter: 'all'}],
        ['Pinned only', {filter: 'pinned'}],
      ],
    },
  ],
  render: (s) => {
    const notes = NOTES.filter((n) => s.filter === 'all' || n.pinned);
    const columns = shellLayout(s.window).width >= 600 ? 2 : 1;
    return frame(
      s,
      'notes',
      html&lt;div class=&quot;pn-bar&quot;&gt;&#10;          &lt;span class=&quot;pn-bar__title&quot;&gt;Notes&lt;/span&#10;          &gt;&lt;md-icon-button aria-label=&quot;Search&quot;&#10;            &gt;&lt;md-icon&gt;search&lt;/md-icon&gt;&lt;/md-icon-button&#10;          &gt;&#10;        &lt;/div&gt;&#10;        &lt;div class=&quot;pn-content&quot;&gt;&#10;          &lt;md-chip-set class=&quot;pn-chips&quot;&gt;&#10;            ${(['all', 'pinned'] as const).map(&#10;              (f) =&gt;&#10;                html<md-filter-chip
                  label=${f === 'all' ? 'All' : 'Pinned'}
                  .selected=${s.filter === f}
                  @click=${(e: Event) => {
                    // The screen's state decides what is selected, not the chip.
                    (
                      e.currentTarget as HTMLElement & {selected: boolean}
                    ).selected = s.filter === f;
                    s.set?.({filter: f});
                  }}
                ></md-filter-chip>&#10;            )}&#10;          &lt;/md-chip-set&gt;&#10;          &lt;div class=&quot;pn-grid&quot; style=&quot;--pn-columns:${columns}&quot;&gt;&#10;            ${notes.map(&#10;              (n) =&gt;&#10;                html<button
                  class="pn-note ${n.pinned ? 'pn-note--pinned' : ''}"
                  @click=${() => s.go?.('editor', {noteId: n.id})}
                >
                  <p class="pn-note__title">
                    ${n.title}${n.pinned ? html&lt;md-icon aria-label=&quot;Pinned&quot;&gt;push_pin&lt;/md-icon&gt; : nothing}
                  </p>
                  <p class="pn-note__body">${n.body}</p>
                </button>&#10;            )}&#10;          &lt;/div&gt;&#10;        &lt;/div&gt;,
      html&lt;md-fab&#10;        variant=&quot;primary&quot;&#10;        aria-label=&quot;New note&quot;&#10;        @click=${() =&gt; s.go?.('editor', {noteId: ''})}&#10;      &gt;&#10;        &lt;md-icon slot=&quot;icon&quot;&gt;edit&lt;/md-icon&gt;&#10;      &lt;/md-fab&gt;
    );
  },
};

The important fields:

Field
id, title, description Shown on its card; the title also names it in the inspector. Ids must be unique; createScreenRegistry() throws on duplicates.
category, categoryLabel Optional category text shown on the screen card.
palette The palette of the reference capture, used when the hub's palette is "As captured".
defaults The initial state. Every render gets {...defaults, ...state}.
controls Optional tweaks for the inspector: presets, choices, toggles, sliders.
tick(state, dt) Optional clock: return a patch to animate while the screen is visible.

Besides its own state, render() receives a ScreenRenderContext:

  • set(patch) merges a patch (or a function of the state) and renders again. The notes screen's filter chips call set({filter: 'pinned'}).
  • go(id, state?) opens another screen. A note calls go('editor', {noteId}); in the gallery that opens the prototype page, in the prototype page it replaces the screen.
  • navType is 'gesture' or 'buttons', for the system navigation the screen draws.
  • window is the window the screen lays itself out for ('phone', 'fold', 'desktop' or a size). shellLayout(window) gives App Toolkit's rule: a bottom bar below 600dp, a rail from 600, the permanent drawer from 1200.
  • showAds is false unless a reviewer turns ad stand-ins on.

set and go are absent in videos, where the frame decides the state, so call them with ?..

Rules that keep screens reusable

  • Everything from state. No timers, Math.random() or CSS animations inside a screen: a video must get the same picture for the same state. Motion goes through tick() in the browser and through the frame number in a video.
  • The app's real strings and data. A screen shows only states the app can show.
  • Translated, not designed. Screens follow the Compose code; the hub's design never restyles them.
  • App Toolkit's own screens stay out. Settings, onboarding and help belong to App Toolkit; a hub shows the app's own UI.

Theme

Palettes are CSS, keyed by data-palette and data-theme, with Material's colour roles. The hub sets both on each screen's element:

[data-palette='ocean'] {
  --md-sys-color-primary: #006684;
  --md-sys-color-surface: #f6fafd;
  /* every role the screens use */
}
[data-palette='ocean'][data-theme='dark'] {
  --md-sys-color-primary: #65d3ff;
  --md-sys-color-surface: #0f1417;
}

App Toolkit apps generate these from the app's Kotlin palettes. Load the app's fonts and Material Symbols yourself; the hub chrome uses Google Sans Flex when it is available.

The gallery uses mnw-app-shell, with a closed desktop drawer by default (navigationMode: 'modal'). Set navigationMode: 'auto' for the connected drawer (permanent from permanentFrom, 900px by default), or navigationMode: 'modal' for a closed drawer at every width. navigation can render additional destinations into the drawer. Privacy and conduct URLs are product settings; the drawer's footer connects Light/Dark/System to the preview theme. navigationMode: 'none' drops the drawer and the menu button for a hub that is one page of screens. The page has no footer.

src/gallery-page.js is a few lines:

// The sample hub (demos/marketing-hub/index.html): the gallery, and each
// screen's prototype in the same page. An app's
// marketing/ui/src/gallery-page.js is the same few lines.
import {
  configureMarketingAssets,
  mountMarketingHub,
} from 'material-next-web-components/marketing.js';
import {PALETTES, PRODUCT} from '../sample-app/product.js';
import {SCREENS} from '../sample-app/screens.js';

// This site serves the copied assets from its root, under the Pages subpath.
configureMarketingAssets({
  base: new URL('./material-next/', import.meta.url).href,
});

mountMarketingHub({
  product: PRODUCT,
  navigationMode:
    new URL(location.href).searchParams.get('drawer') === 'auto'
      ? 'auto'
      : 'modal',
  screens: SCREENS,
  palettes: PALETTES,
});

mountMarketingGallery() renders the shell into document.body (or root) and a card per screen, each with a live ScreenHost. It also:

  • installs the hub's page styles and follows the theme setting, including the system theme;
  • saves the preview settings per product in localStorage, and works the same when storage is unavailable;
  • keeps per-card theme and navigation toggles until a hub-wide choice replaces them;
  • opens the prototype page when a card is opened, or when a screen inside a card calls go(), carrying the screen's id, the settings and the state in the URL.

It returns a handle with settings(), update(patch), destroy() and the registry. extra adds app-specific sections after the catalog, such as motion parts; they render in light DOM with the app's CSS.

The sample above uses mountMarketingHub(), which takes the gallery's options and opens each screen's prototype in the same page: no page load, no flash between them. A card's Open shows the prototype at ?id=<screen> (the same parameters a screen.html link carries, so it can still be shared or captured), All screens and the browser's Back return to the gallery where it was scrolled, and Forward opens the screen again. Browsers with view transitions cross-fade the two. prototypeDefaults, adsToggle and fillPhone go to the prototype. The handle has page(), go(href) and destroy().

Keep a screen.html with mountScreenPrototype() when capture scripts or older links open it directly.

The prototype page

src/screen-page.js is the same, with mountScreenPrototype():

// The sample prototype page (demos/marketing-hub/screen.html).
import {
  configureMarketingAssets,
  mountScreenPrototype,
} from 'material-next-web-components/marketing.js';
import {PALETTES, PRODUCT} from '../sample-app/product.js';
import {SCREENS} from '../sample-app/screens.js';

configureMarketingAssets({
  base: new URL('./material-next/', import.meta.url).href,
});

mountScreenPrototype({
  product: PRODUCT,
  screens: SCREENS,
  palettes: PALETTES,
  galleryUrl: './',
});

The sample's prototype page. Try Compare, the Fold window, or the Focus timer's controls.

The whole view lives in the URL, so a link reopens it exactly:

Parameter Values
id A screen id; the first screen when missing
mode, compare inspect or compare; theme, nav, palette or frame
palette, paletteB A palette name or captured
theme, nav system, light, dark; gesture, buttons
window phone, fold, desktop
frame device or none
state, preset JSON merged over the defaults; a preset's label
ads on shows ad stand-ins (with adsToggle)
panel none is capture mode: the screen alone, framed with frame=device

fillPhone (default true) makes a frameless phone fill the stage, for screens that adapt to their width. Turn it off when screens are laid out at a fixed 430 × 957dp. Larger windows keep their size and are scaled to fit.

The page sets data-ready on <html> once fonts and icons are loaded. Capture scripts wait for it:

// scripts/capture.mjs (Playwright)
await page.goto(`${base}/screen.html?id=${id}&frame=none&panel=none`);
await page.waitForSelector('html[data-ready]');
await page.screenshot({path: `captures/${id}.png`});

ScreenHost

ScreenHost runs one screen in an element: it owns the state, passes set() and go() to the screen, applies palette and theme, draws the device frame, and runs the clock for screens with tick(). The gallery and the prototype page use it; use it directly for a custom page.

set({filter: 'pinned'}) set({filter: 'all'}) go('focus') reset()

  
The buttons call the host's API and the switches call setOptions(). Tap a note or the Focus tab inside the screen: its go() reaches onGo, which loads the target in the same host.
<div class="host-demo">
  <div class="host-demo__screen"></div>
  <div class="host-demo__side">
    <div class="demo-controls" role="group" aria-label="Screen host">
      <md-outlined-button data-action="pinned">set({filter: 'pinned'})</md-outlined-button>
      <md-outlined-button data-action="all">set({filter: 'all'})</md-outlined-button>
      <md-outlined-button data-action="go">go('focus')</md-outlined-button>
      <md-outlined-button data-action="reset">reset()</md-outlined-button>
    </div>
    <label class="switch-row"><md-switch data-option="dark"></md-switch> theme: 'dark'</label>
    <label class="switch-row"><md-switch data-option="frame"></md-switch> frame: true</label>
    <pre class="demo-log" aria-live="polite"></pre>
  </div>
</div>
import '@material/web/button/outlined-button.js';
import '@material/web/switch/switch.js';
import {ScreenHost, createScreenRegistry} from 'material-next-web-components/marketing.js';
import {SCREENS} from '../sample-app/screens.js';

export default (root: HTMLElement) => {
  const log = root.querySelector('.demo-log')!;
  const show = (host: ScreenHost) => {
    log.textContent = `screen: ${host.screen?.id}\nstate: ${JSON.stringify(host.state)}`;
  };
  const host = new ScreenHost(
    root.querySelector<HTMLElement>('.host-demo__screen')!,
    {
      registry: createScreenRegistry(SCREENS),
      id: 'notes',
      palette: 'captured',
      // go() from inside the screen (a note, the Focus tab) lands here. This page
      // loads the target in the same host; the gallery opens the prototype page.
      onGo: (id, state, h) => {
        h.load(id, state);
        show(h);
      },
      onChange: show,
    }
  );
  show(host);

  const actions: Record<string, () => void> = {
    pinned: () => host.set({filter: 'pinned'}),
    all: () => host.set({filter: 'all'}),
    go: () => host.go('focus'),
    reset: () => host.reset(),
  };
  for (const button of root.querySelectorAll<HTMLElement>('[data-action]')) {
    button.addEventListener('click', () => {
      actions[button.dataset.action!]();
      show(host);
    });
  }
  for (const toggle of root.querySelectorAll<HTMLElement & {selected: boolean}>(
    '[data-option]'
  )) {
    toggle.addEventListener('change', () => {
      host.setOptions(
        toggle.dataset.option === 'dark'
          ? {theme: toggle.selected ? 'dark' : 'light'}
          : {frame: toggle.selected}
      );
    });
  }
  return () => host.destroy();
};
Option Default
registry, id Required: the screens and the one to show
state {} Merged over the screen's defaults
palette 'captured' A palette name, or the screen's own
theme light 'light' or 'dark'
frame false Draw the window's device around it
navType, window, showAds 'gesture', phone, false Passed to the screen
fill false Stretch a frameless screen over its container
onGo(id, state, host) Handles go(); without it the host loads the target itself
onChange(host) After set() and reset(); not after load() or go()

Methods: load(id, state?), reset(), setOptions(patch), set(patch), go(id, state?), draw() and destroy(). screen, state and options are readable. The clock runs only while a ticking screen is mounted and visible.

Videos and deterministic captures

createMarketingEmbed() is the framework-free entry for Remotion, Playwright or anything with a DOM. There is no clock and no set() or go(): the caller computes the state for each frame. mount() resolves once every Lit and Material Web element under it has rendered, so a frame captured right after it is complete.


  
Drag the slider: each frame number gives one state, and the same state always draws the same picture.
<div class="host-demo">
  <div class="host-demo__screen embed-target"></div>
  <div class="host-demo__side">
    <label class="slider-row">Frame <md-slider min="0" max="900" value="0" labeled aria-label="Frame"></md-slider></label>
    <pre class="demo-log" aria-live="polite"></pre>
  </div>
</div>
import '@material/web/slider/slider.js';
import {createMarketingEmbed} from 'material-next-web-components/marketing.js';
import {SCREENS} from '../sample-app/screens.js';

const FPS = 30;
const {mount} = createMarketingEmbed({screens: SCREENS});

/** The state for one video frame: a function of the frame number only. */
const focusAt = (frame: number) => ({
  minutes: 1,
  elapsed: frame / FPS,
  running: false,
});

export default (root: HTMLElement) => {
  const target = root.querySelector<HTMLElement>('.embed-target')!;
  const slider = root.querySelector<HTMLInputElement>('md-slider')!;
  const log = root.querySelector('.demo-log')!;
  const draw = async () => {
    const frame = Number(slider.value);
    const state = focusAt(frame);
    // mount() resolves once every element has rendered: capture right after.
    await mount(target, 'focus', state, {palette: 'captured'});
    log.textContent = `frame ${frame}\nmount(el, 'focus', ${JSON.stringify(state)})`;
  };
  slider.addEventListener('input', draw);
  void draw();
};

setup() runs once: it sets data-motion="frame" on <html> (turn CSS motion off for that attribute), applies a page-wide palette and theme if given, calls your loadTheme() and waits for Material Web's icons. Pass marketingAssetBase where the device frames are served, such as Remotion's staticFile('/material-next/').

mount(el, id, state, {frame, palette, window, showAds}) renders a screen. Without palette, the element keeps the page's palette. mountPart(el, build) renders one piece from the parts you passed, such as a card a film moves out of its screen, built by the same function the screen calls.

// Remotion: one component, a state per frame
import {useEffect, useRef, useState} from 'react';
import {continueRender, delayRender, staticFile} from 'remotion';
import {createMarketingEmbed} from 'material-next-web-components/marketing.js';
import {SCREENS} from '../../ui/src/screens/index.js';
import {loadFonts} from '../../ui/src/theme/index.js';

const {setup, mount} = createMarketingEmbed({
  screens: SCREENS,
  loadTheme: loadFonts,
});
await setup({
  assetBase: staticFile('/'),
  marketingAssetBase: staticFile('/material-next/'),
});

export const LiveScreen = ({id, state}) => {
  const ref = useRef<HTMLDivElement>(null);
  const [handle] = useState(() => delayRender());
  useEffect(() => {
    void mount(ref.current!, id, state, {frame: true}).then(() =>
      continueRender(handle)
    );
  }, [id, JSON.stringify(state)]);
  return <div ref={ref} />;
};

<LiveScreen id="focus" state={{minutes: 1, elapsed: frame / fps}} />;

Assets

The device frames and the Google Play badge are image files in the package. Copy them into the hub's public folder as part of its asset step:

{
  "scripts": {
    "assets": "node scripts/sync-assets.mjs && mnw-marketing-assets public/material-next"
  }
}

mnw-marketing-assets [destination] copies assets/marketing/ from the package (default destination public/material-next). The elements then load them from /material-next/. When the hub is served from a subpath (GitHub Pages, a Remotion staticFile()), point them elsewhere before rendering:

import {configureMarketingAssets} from 'material-next-web-components/marketing.js';

configureMarketingAssets({
  base: new URL('./material-next/', import.meta.url).href,
});

Add the destination to .gitignore: it is generated.

Checklist

  1. identity.js with the real name, icons, fonts, palettes, tabs and links; product.js only for what the hub alone shows.
  2. Every screen translated from its Composable, rendering only from state, with real strings.
  3. A category on each screen, and controls for the states worth showing.
  4. Palettes as [data-palette] CSS in light and dark, named in the identity; fonts and Material Symbols loaded with loadProductFonts(IDENTITY).
  5. gallery-page.js and screen-page.js, and npm run assets copying the marketing assets.
  6. Captures and videos through the prototype page's capture mode or createMarketingEmbed().

The Marketing API page lists every export of material-next-web-components/marketing.js.

Gallery frames default to off. Opening a screen from a card shows its frame by default; the inspector can turn it off. Explicit frame=none links stay frameless.

mail