Build a marketing hub
From an app's screens to a live gallery, a prototype page, PNG captures and Remotion videos, with one source for all of them.
import 'material-next-web-components/marketing.js';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.
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/weblit 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<div class="pn-bar"> <span class="pn-bar__title">Notes</span ><md-icon-button aria-label="Search" ><md-icon>search</md-icon></md-icon-button > </div> <div class="pn-content"> <md-chip-set class="pn-chips"> ${(['all', 'pinned'] as const).map( (f) => 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> )} </md-chip-set> <div class="pn-grid" style="--pn-columns:${columns}"> ${notes.map( (n) => 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<md-icon aria-label="Pinned">push_pin</md-icon> : nothing}
</p>
<p class="pn-note__body">${n.body}</p>
</button> )} </div> </div>,
html<md-fab variant="primary" aria-label="New note" @click=${() => s.go?.('editor', {noteId: ''})} > <md-icon slot="icon">edit</md-icon> </md-fab>
);
},
};
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 callset({filter: 'pinned'}).go(id, state?)opens another screen. A note callsgo('editor', {noteId}); in the gallery that opens the prototype page, in the prototype page it replaces the screen.navTypeis'gesture'or'buttons', for the system navigation the screen draws.windowis 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.showAdsisfalseunless 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 throughtick()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
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.
One page for the gallery and its screens
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 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.
<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.
<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
identity.jswith the real name, icons, fonts, palettes, tabs and links;product.jsonly for what the hub alone shows.- Every screen translated from its Composable, rendering only from state, with real strings.
- A
categoryon each screen, andcontrolsfor the states worth showing. - Palettes as
[data-palette]CSS in light and dark, named in the identity; fonts and Material Symbols loaded withloadProductFonts(IDENTITY). gallery-page.jsandscreen-page.js, andnpm run assetscopying the marketing assets.- 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.