Rich Install UI¶
The rich install UI is the store-like install dialog that Chromium browsers show instead of the minimal "Install app?" prompt when your manifest provides screenshots (and, ideally, a description). On Android it is an expandable bottom sheet; on desktop it is a larger modal dialog with a screenshot carousel. It matters because the default prompt shows only an icon, a name and an origin, which gives users almost no reason to say yes. This page documents the members involved, the exact filtering rules Chromium applies to screenshots (several of which are easy to trip over and fail silently), how to produce compliant screenshots automatically with Playwright, and how categories and iarc_rating_id fit in.
Key takeaways
- The richer UI is Chromium-only: Chrome for Android since 94, Chrome on desktop since 108. Safari and Firefox don't read
screenshotsordescriptionfor installation. - Android uses screenshots whose
form_factorisnarrowor absent; desktop uses onlyform_factor: "wide". With no qualifying screenshot, users get the plain dialog. - Each screenshot must be between 320 and 3840 pixels in both dimensions, with the long side at most 2.3 times the short side. On Android, every screenshot must also have the same aspect ratio as the first accepted one, or it's dropped.
- Chromium considers at most eight screenshots. On Android the count includes every entry in manifest order,
wideones too, so list narrow screenshots first. DevTools warns above five for mobile. descriptionis optional but shown prominently; Chromium truncates it to 300 characters.- The
labelbecomes the accessible name of each screenshot on desktop; Chrome for Android currently uses a generic description instead, so don't put essential information only in screenshots. categoriesandiarc_rating_idare store metadata. Chromium ignores both; Safari 17.4+ on macOS usescategoriesto name Launchpad folders.
The members behind the richer install UI¶
description, screenshots, categories and iarc_rating_id started in the main manifest specification and were moved to a companion document, Web App Manifest - Application Information, published as a W3C Group Note (21 August 2023). The main specification says these members "provide additional metadata related to how the web application may be presented in the context of a digital storefront, installation dialog, or other surfaces where the web application may be marketed or distributed." A Group Note isn't on the Recommendation track, so implementations define most of the practical rules, and the rules below come from Chromium's source.
description¶
A string that "allows the developer to describe the purpose of the web application" and "serves as the accessible description of an installed web application." Chromium trims surrounding whitespace, shows the text under a Description heading in the rich dialog, and cuts it at kMaximumDescriptionLength, which is 300 characters (character-boundary truncation, no ellipsis logic you can control). Since Chrome 148's manifest localization support (desktop, per chromestatus), description_localized can provide per-language variants alongside name_localized and short_name_localized. Chromium's parser reads it, although the core specification defines *_localized only for its own localizable members (name, short_name, icons and the shortcut item members), not for the top-level description from the Application Information note. See Members Reference.
{
"lang": "en",
"description": "Offline topo maps, GPS tracking and trail conditions for day hikes and multi-day trips.",
"description_localized": {
"de": "Offline-Topokarten, GPS-Tracking und Wegzustand für Tages- und Mehrtagestouren.",
"fr": "Cartes topographiques hors ligne, suivi GPS et état des sentiers pour la randonnée."
}
}
Browsers that don't support localization ignore the *_localized member and show the plain description, so always keep it.
Write it like the first paragraph of a store listing: what the app does and for whom, in one or two sentences. It is not the page's meta description, and it shouldn't be a list of keywords.
screenshots¶
An array of screenshot objects. A screenshot object is an image resource (the same shape as an icons entry) plus three members:
| Member | Type | Meaning | Chromium behavior |
|---|---|---|---|
src | URL string | The image, resolved against the manifest URL | Required. Must resolve to http, https, data: or the document's own scheme, or the entry is dropped |
sizes | string | Space-separated WxH list, like icons | Optional, but the first size is used as the download target and, on desktop, to reserve layout space |
type | MIME type | Image type hint | Parsed, not needed for decoding |
label | string | "The accessible name of that screenshots object", usable as alt text | Trimmed; used as the accessible name in the desktop carousel |
form_factor | "narrow" or "wide" | "The screen shape of a broad class of devices" | Compared case-insensitively; any other value logs a warning and is treated as absent |
platform | string | Distribution platform the screenshot applies to | Not parsed by Chromium; used by stores |
The Application Information note defines platform values for operating systems (android, chromeos, ios, ipados, kaios, macos, windows, xbox) and for distribution platforms (chrome_web_store, play, itunes, microsoft-inbox, microsoft-store). It advises using platform only when a screenshot shows functionality that exists on that platform alone, and says user agents "shouldn't display screenshots that do not pertain to their platform". When platform is absent, the screenshot applies everywhere.
categories¶
An array of strings that "describes the application categories to which the web application belongs", explicitly "a hint to catalogs or stores" that they aren't required to honor. The note recommends lower-case values and lists known categories: beauty, books, books & reference, business, cars, dating, design, developer, developer tools, development, education, entertainment, events, fashion, finance, fitness, food, fundraising, games, government, graphics, graphics & design, health, health & fitness, kids, lifestyle, magazines, medical, multimedia, multimedia design, music, navigation, network, networking, news, parenting, personalization, pets, photo, photo & video, politics, productivity, reference, security, shopping, social, social networking, sports, transportation, travel, utilities, video, weather.
Chromium's manifest parser doesn't read categories. Safari 17.4 added support on macOS Sonoma: when a user creates a Launchpad folder containing web apps, the folder is named after their category.
iarc_rating_id¶
A string holding an International Age Rating Coalition certification code, "intended to be used to determine which ages the web application is appropriate for." The note explains that an IARC certificate is obtained through participating storefronts, that the member takes a single code, and that the same code can be shared across storefronts as long as the product is the same (not "totally different code paths depending on user agent sniffing"). No browser uses it in install UI. Store packaging tools can carry it; each store's own listing and rating process remains authoritative. See Publishing to App Stores and PWABuilder.
{
"id": "/",
"name": "Trailhead: Hiking Maps",
"short_name": "Trailhead",
"description": "Offline topo maps, GPS tracking and trail conditions for day hikes and multi-day trips. Plan routes at home, then navigate with no signal.",
"categories": ["navigation", "travel", "health & fitness"],
"iarc_rating_id": "e84b072d-71b3-4d3e-86ae-31a8ce4e53b7",
"screenshots": [
{
"src": "/screenshots/map-narrow.png",
"sizes": "1080x2340",
"type": "image/png",
"form_factor": "narrow",
"label": "Topographic map with the planned route and elevation profile"
},
{
"src": "/screenshots/tracking-narrow.png",
"sizes": "1080x2340",
"type": "image/png",
"form_factor": "narrow",
"label": "Live GPS tracking with distance, pace and remaining ascent"
},
{
"src": "/screenshots/planner-wide.png",
"sizes": "1920x1080",
"type": "image/png",
"form_factor": "wide",
"label": "Route planner with map, waypoint list and weather forecast"
},
{
"src": "/screenshots/offline-wide.png",
"sizes": "1920x1080",
"type": "image/png",
"form_factor": "wide",
"label": "Offline map regions downloaded for a three-day trip"
}
]
}
The iarc_rating_id above is the example value from the specification, not a real certificate. Omit the member until you have one.
How Chromium renders the richer install UI¶
Chromium has two separate code paths, one per platform family, and they filter screenshots differently. Both are browser UI you can't style; your only inputs are the manifest members.
Before and after¶
| Without qualifying screenshots | With qualifying screenshots | |
|---|---|---|
| Android | Compact install dialog: icon, name, origin, Install / Cancel | Bottom sheet: icon, name, origin and Install button; expanded, it adds the description and a horizontally scrolling row of screenshots, each of which opens a zoomed view on tap |
| Desktop | Small bubble anchored to the address bar's install icon: icon, name, origin, Install / Cancel | Tab-modal dialog over the page, titled Install app: icon, name and origin, a Description section, a screenshot carousel with scroll buttons, and Install / Cancel, with Cancel as the default button |
The same UI appears whichever way installation starts: the browser menu, the address bar's install icon, or your own button calling prompt() on a saved beforeinstallprompt event (covered in Install Prompts & Custom UI).
flowchart TD
A["Install flow starts (menu, omnibox icon, prompt())"] --> B{"Platform"}
B -- Android --> C["Take screenshots whose form_factor is not wide"]
B -- Desktop --> D["Take screenshots whose form_factor is wide"]
C --> E["Download, then filter: size 320 to 3840, ratio at most 2.3,<br/>same aspect ratio as the first, at most 8 by manifest position"]
D --> F["Skip entries whose declared sizes exceed 3840,<br/>keep the first 8, load lazily into the carousel"]
E --> G{"At least one left?"}
F --> H{"At least one left?"}
G -- Yes --> I["Bottom sheet with description and screenshots"]
G -- No --> J["Plain install dialog"]
H -- Yes --> K["Detailed dialog with description and carousel"]
H -- No --> L["Plain install bubble"] Android: the install bottom sheet¶
Chrome for Android shipped the richer UI in Chrome 94. The screenshot pipeline lives in InstallableDataFetcher::CheckAndFetchScreenshots and OnScreenshotFetched, with limits from components/webapps/common/constants.cc:
// Maximum dimension can't be more than 2.3 times as long as the minimum
// dimension for screenshots.
const double kMaximumScreenshotRatio = 2.3;
const size_t kMaximumDescriptionLength = 300;
const int kMinimumScreenshotSizeInPx = 320;
const int kMaximumScreenshotSizeInPx = 3840;
const int kMaximumNumOfScreenshots = 8;
The algorithm, in order:
- Select. Walk
screenshotsin manifest order, skipping entries whoseform_factoriswide. Entries withnarrow, noform_factor, or an invalid one are candidates. Stop after eight candidates. - Download. Each candidate is downloaded with an ideal size equal to the larger dimension of its first declared size (320 if
sizesis absent) and a minimum of 320 pixels. There's deliberately no maximum at download time, so oversized images aren't silently downscaled; they're rejected in the next step instead. Failed downloads just disappear. - Filter, in manifest order again. This pass walks all entries and counts each one toward the limit of eight, including
wideentries that were never downloaded. For each downloaded image:- drop it if either dimension exceeds 3840 pixels;
- drop it if its aspect ratio differs from the first accepted screenshot (checked by cross-multiplying, so it must match exactly, including orientation);
- drop it if the long side is more than 2.3 times the short side.
- Show or fall back. If at least one screenshot survives, the bottom sheet is used. Otherwise the plain dialog is.
The description is truncated to 300 characters, and hidden if empty. Each screenshot's accessible description in the Android view is a generic localized string ("screenshot"), not your label.
Three consequences that trip people up:
- Ordering matters. Because step 3 counts every entry, a manifest that lists six
widescreenshots and then fournarrowones gives Android only two narrow screenshots. List narrow screenshots first, and keep the total at eight or fewer. - Mixed aspect ratios silently lose screenshots. A 1080×2340 screenshot followed by a 1080×1920 one: the second is dropped because its ratio doesn't equal the first. Capture every narrow screenshot at identical pixel dimensions.
- Very tall phones exceed the ratio. 1080×2340 (19.5:9, ratio 2.17) and 1080×2400 (20:9, ratio 2.22) pass. 1080×2520 (21:9, ratio 2.33) fails the 2.3 limit and is dropped. Capture at a viewport that yields 2.3 or less, not at whatever the test device happens to be.
Chrome DevTools' manifest view warns when more than five screenshots apply to mobile ("No more than 5 screenshots will be displayed on mobile"), while the current fetch code accepts up to eight. Five narrow screenshots satisfy both.
Android downloads screenshots before anyone clicks Install
On Android, the installability check Chrome runs to decide whether to offer installation (the same pass that leads to beforeinstallprompt) requests screenshot fetching. Visitors who never install can still download your narrow screenshots. Keep each file small (compressed PNG, JPEG or WebP in the low hundreds of kilobytes), serve them with long-lived Cache-Control and content-hashed URLs, and don't exceed the number you actually need.
Android: peeked sheet, expanded sheet, and what triggers each¶
The Android bottom sheet has two states, and which one users see depends on who started the flow. The logic is split between AppBannerManagerAndroid, AmbientBadgeManager and PwaBottomSheetController in Chromium:
flowchart TD
A["Installability check passes<br/>(screenshots fetched)"] --> B["beforeinstallprompt fires"]
B --> C{"Page called preventDefault()?"}
C -- Yes --> D["No browser promotion"]
C -- No --> E{"Guardrails: recently blocked or ignored,<br/>or prompts dismissed repeatedly?"}
E -- Yes --> D
E -- No --> F{"On-device ML model says promote?"}
F -- No --> D
F -- Yes --> G{"Qualifying screenshots?"}
G -- Yes --> H["Bottom sheet, peeked"]
G -- No --> I["Compact install message"]
D --> J["Later: page calls prompt(),<br/>or user picks Install from the menu"]
J --> K{"Qualifying screenshots?"}
K -- Yes --> L["Bottom sheet, expanded"]
K -- No --> M["Classic install dialog"] - Browser-initiated promotion shows the sheet peeked. If the page doesn't cancel
beforeinstallprompt, Chrome may promote installation on its own. That promotion is gated twice: by guardrails (the banner was recently blocked or ignored for this app, or install prompts were dismissed or ignored several times in a row recently) and by an on-device machine-learning classifier whose inputs include the URL, the origin and whether the app has a maskable icon. The classifier is enabled by default on Android (WebAppsEnableMLModelForPromotion) and disabled on desktop. When the promotion does show and the app has at least one qualifying screenshot, Chrome shows the bottom sheet in its peeked (collapsed) state, with the icon, name and Install button visible and the description and screenshots one swipe away. Without screenshots, users get the compact message instead. - An explicit request shows the sheet expanded. Calling
prompt()on a savedbeforeinstallpromptevent, or choosing to install from Chrome's menu, opens the sheet directly expanded when screenshots qualify, and the classic modal dialog otherwise. In current Chrome for Android the menu path first shows a small dialog asking whether to install the app or create a shortcut; choosing to install leads to the same UI. - Calling
preventDefault()removes the peeked sheet. Sites that interceptbeforeinstallpromptto show their own install button never get Chrome's promotion, so the only way their users see the rich UI is through that button or the menu. That's usually the right trade-off, but it means your in-page install UI has to earn the click on its own; see Install Prompts & Custom UI. - Collapsing isn't cancelling. Chrome records a dismissal (which feeds the guardrails above) when the user swipes the sheet away, or when an expanded sheet is torn down without an install. Dragging an expanded sheet back to its peeked state doesn't count as a dismissal, because the sheet is still visible. A peeked sheet that was never expanded is treated like the old install infobar, not like a rejected dialog.
The practical upshot: screenshots change not only what the explicit install dialog looks like, but also what Chrome's automatic promotion looks like on Android, turning a one-line message into a sheet that previews the app.
Desktop: the detailed install dialog¶
Chrome on desktop added the richer dialog in Chrome 108, according to Chrome's documentation (the chromestatus entry records its ship milestone as 106; if you support browsers from that window, test rather than assume). Desktop has no automatic peeked state: Chrome's desktop promotion is the install icon in the address bar (occasionally pointed out by an in-product help bubble), and the dialog only opens when the user clicks it, uses the app menu, or your page calls prompt(). The desktop install command (FetchManifestAndInstallCommand) handles screenshots separately from Android:
- Select. Walk
screenshotsin order, considering only entries whoseform_factoriswide. Unset andnarroware ignored on desktop. - Pre-filter by declared size. Skip any entry with a declared size larger than 3840 in either dimension, so it doesn't take one of the carousel's slots.
- Count. Keep the first eight. If at least one remains, Chrome uses the detailed dialog instead of the simple bubble.
- Reserve layout. Each slot's size comes from the entry's first declared
sizesvalue, or a 320×320 square if none is declared. Images then load lazily into the slots. - Render. The carousel has a fixed height of 180 DIP (two 65 DIP margins around a 50 DIP spinner). Each image is scaled to that height, and its width is capped at 2.3 times the height (414 DIP), so over-wide images are squashed rather than rejected. The label, if present, becomes the image's accessible name.
The description is truncated to 300 characters, as on Android. Two more details from the dialog code: Cancel is the default button, so pressing Enter doesn't install; and if the browser window is too small to fit the dialog, Chrome closes it as "ignored" instead of showing a cramped version.
Because desktop reserves layout from the declared sizes, a missing or wrong sizes value causes the carousel to jump when images arrive. Always declare the real pixel size.
Chromium also contains a redesigned install flow dialog behind a feature that is disabled by default (WebAppInstallDialog), which reuses the same screenshot data. Treat the layout details above as current behavior, not a contract.
Microsoft Edge and other browsers¶
Edge, Opera and Samsung Internet use Chromium's manifest parser, but each vendor ships its own install UI and may not show the same dialog or apply the same limits; check the browsers your audience uses. Safari (iOS, iPadOS and macOS) and Firefox (Android, and web apps on Windows) show their own install or add-to-Home-Screen UI based on the name, icon and URL, and don't display description or screenshots. Store listings (Microsoft Store, Google Play via Trusted Web Activity) take screenshots and descriptions through their own submission flows; see Publishing to App Stores and Trusted Web Activity.
Screenshot requirements at a glance¶
| Rule | Android (Chrome 94+) | Desktop (Chrome 108+) |
|---|---|---|
| Which entries | form_factor absent, narrow or invalid | form_factor: "wide" only |
| Minimum qualifying | 1 | 1 |
| Maximum used | 8, counted over all entries in manifest order (DevTools warns above 5) | 8 wide entries |
| Minimum dimension | 320 px each side | 320 px download minimum |
| Maximum dimension | 3840 px each side (checked on the downloaded image) | 3840 px (checked on declared sizes) |
| Long side ÷ short side | At most 2.3, else dropped | Rendered width capped at 2.3 × height |
| Same aspect ratio for all | Required, else dropped | Not required, but all render at the same height |
label | Not used as alt text | Accessible name of the image |
description | Up to 300 characters | Up to 300 characters |
Support data as of September 2026. For live compatibility data see MDN's pages on screenshots, description and categories, and caniuse.
Recommended targets that satisfy every rule:
| Form factor | Viewport (CSS px) | Device scale factor | Output (px) | Ratio |
|---|---|---|---|---|
narrow | 360 × 780 | 3 | 1080 × 2340 | 2.17 |
narrow (alternative) | 412 × 915 | 2 | 824 × 1830 | 2.22 |
wide | 1280 × 720 | 1.5 | 1920 × 1080 | 1.78 |
wide (alternative) | 1440 × 900 | 2 | 2880 × 1800 | 1.6 |
Pick one row per form factor and use it for every screenshot of that form factor.
Designing screenshots that earn the install¶
The dialog shows screenshots small: on desktop they're 180 DIP tall; on Android they're thumbnails in a scrolling row. Design for that size.
- Lead with the core task. The first narrow and first wide screenshot are the only ones many users see without scrolling. Show the main screen doing its main job with realistic data, not an empty state, a login form or a splash screen.
- One idea per screenshot. The remaining screenshots each show one feature, in the order you'd demo them.
- Real UI, legible at thumbnail size. Avoid tiny text as the only content. If you add captions or device frames, keep them honest; users compare the dialog with what opens after installing.
- No personal data. Use seeded demo accounts. Screenshots are public URLs.
- Consistent theme. Screenshots can't vary by
prefers-color-scheme. Pick light or dark and use it throughout. - Localization. Screenshots aren't a localizable member in the current specification. If localized screenshots matter, generate per-locale manifests that differ only in
screenshotsanddescription, keep the sameid, and serve the right one from each locale's pages. App Identity & Updates explains whyidmust stay identical. - Label every screenshot. Describe what the screenshot shows ("Weekly budget with spending by category"), not "Screenshot 1". It is the text alternative on desktop and in store listings.
- Keep them current. A redesign that makes screenshots obsolete should replace them in the same release. Unlike icons, changing screenshots doesn't trigger any update review.
Producing screenshots automatically with Playwright¶
Hand-made screenshots drift out of date and out of spec. A capture script that runs against a seeded build in CI keeps them accurate and enforces Chromium's rules before they reach users. The script below captures each scene at exact pixel dimensions per form factor, freezes time and animations for repeatability, validates every rule from the previous sections, and writes the screenshots array into the manifest.
#!/usr/bin/env node
// Captures manifest screenshots with Playwright and writes them into the manifest.
// Usage: BASE_URL=http://localhost:4173 node scripts/capture-screenshots.mjs
import { chromium } from "playwright";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
const BASE_URL = process.env.BASE_URL ?? "http://localhost:4173";
const OUT_DIR = "public/screenshots"; // Served at /screenshots/
const PUBLIC_PREFIX = "/screenshots";
const MANIFEST_PATH = "public/manifest.webmanifest";
// Chromium limits: components/webapps/common/constants.cc
const MIN_PX = 320;
const MAX_PX = 3840;
const MAX_RATIO = 2.3;
const MAX_COUNT = 8;
const MAX_NARROW_RECOMMENDED = 5; // DevTools warns above this for mobile.
// One fixed capture geometry per form factor, so every screenshot in a group
// has an identical aspect ratio (Android drops mismatches).
const FORM_FACTORS = {
narrow: {
viewport: { width: 360, height: 780 },
deviceScaleFactor: 3, // 1080 x 2340
isMobile: true,
hasTouch: true,
},
wide: {
viewport: { width: 1280, height: 720 },
deviceScaleFactor: 1.5, // 1920 x 1080
isMobile: false,
hasTouch: false,
},
};
// Order matters: narrow scenes are emitted first because Android counts
// every entry, in manifest order, toward the limit of eight.
const SCENES = [
{
name: "map",
path: "/app/map?demo=1",
ready: '[data-ready="map"]',
label: "Topographic map with the planned route and elevation profile",
formFactors: ["narrow", "wide"],
},
{
name: "tracking",
path: "/app/track?demo=1",
ready: '[data-ready="tracking"]',
label: "Live GPS tracking with distance, pace and remaining ascent",
formFactors: ["narrow"],
},
{
name: "planner",
path: "/app/plan?demo=1",
ready: '[data-ready="planner"]',
label: "Route planner with map, waypoint list and weather forecast",
formFactors: ["wide"],
},
];
// Hide things that make captures non-deterministic or leak environment details.
const CAPTURE_CSS = `
[data-screenshot-hide], .toast, .cookie-banner, .dev-badge { visibility: hidden !important; }
*, *::before, *::after { caret-color: transparent !important; }
`;
function pngDimensions(buffer) {
// PNG signature, then the IHDR chunk: width and height are big-endian at 16 and 20.
const signature = buffer.subarray(0, 8).toString("hex");
if (signature !== "89504e470d0a1a0a") throw new Error("Expected a PNG screenshot");
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
function validate(entries) {
const errors = [];
const firstRatio = new Map();
for (const [index, entry] of entries.entries()) {
const [width, height] = entry.sizes.split("x").map(Number);
const where = `screenshots[${index}] ${entry.src}`;
if (Math.min(width, height) < MIN_PX) errors.push(`${where}: smaller than ${MIN_PX}px`);
if (Math.max(width, height) > MAX_PX) errors.push(`${where}: larger than ${MAX_PX}px`);
if (Math.max(width, height) > Math.min(width, height) * MAX_RATIO) {
errors.push(`${where}: long side exceeds ${MAX_RATIO}x the short side`);
}
const first = firstRatio.get(entry.form_factor);
if (!first) firstRatio.set(entry.form_factor, { width, height });
else if (width * first.height !== height * first.width) {
errors.push(`${where}: aspect ratio differs from the first ${entry.form_factor} screenshot`);
}
}
const narrow = entries.filter((e) => e.form_factor !== "wide");
const wide = entries.filter((e) => e.form_factor === "wide");
if (narrow.length === 0) errors.push("no narrow screenshot: Android falls back to the plain dialog");
if (wide.length === 0) errors.push("no wide screenshot: desktop falls back to the plain bubble");
if (wide.length > MAX_COUNT) errors.push(`more than ${MAX_COUNT} wide screenshots`);
if (narrow.length > MAX_NARROW_RECOMMENDED) {
errors.push(`more than ${MAX_NARROW_RECOMMENDED} narrow screenshots (DevTools warns; keep it at 5)`);
}
const lastNarrow = entries.findLastIndex((e) => e.form_factor !== "wide");
if (lastNarrow >= MAX_COUNT) {
errors.push(`a narrow screenshot sits at position ${lastNarrow + 1}; Android only looks at the first ${MAX_COUNT} entries`);
}
return errors;
}
async function captureAll() {
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch();
const entries = [];
try {
for (const [formFactor, contextOptions] of Object.entries(FORM_FACTORS)) {
const context = await browser.newContext({
...contextOptions,
colorScheme: "light",
locale: "en-US",
timezoneId: "UTC",
reducedMotion: "reduce",
serviceWorkers: "block", // Capture what the server renders, not a stale cache.
});
// Pin Date.now()/new Date() so relative times ("5 min ago") are stable across
// runs. setFixedTime keeps timers running, so the app can still finish loading.
await context.clock.setFixedTime(new Date("2026-06-15T09:30:00Z"));
const page = await context.newPage();
for (const scene of SCENES.filter((s) => s.formFactors.includes(formFactor))) {
const url = new URL(scene.path, BASE_URL).href;
const response = await page.goto(url, { waitUntil: "networkidle" });
if (!response?.ok()) throw new Error(`${url} returned ${response?.status()}`);
await page.locator(scene.ready).waitFor({ state: "visible", timeout: 15_000 });
await page.evaluate(() => document.fonts.ready);
const file = `${scene.name}-${formFactor}.png`;
const buffer = await page.screenshot({
path: path.join(OUT_DIR, file),
type: "png",
scale: "device", // One image pixel per device pixel: viewport x deviceScaleFactor.
animations: "disabled",
caret: "hide",
style: CAPTURE_CSS,
});
const { width, height } = pngDimensions(buffer);
entries.push({
src: `${PUBLIC_PREFIX}/${file}`,
sizes: `${width}x${height}`,
type: "image/png",
form_factor: formFactor,
label: scene.label,
});
console.log(`captured ${file} (${width}x${height})`);
}
await context.close();
}
} finally {
await browser.close();
}
return entries;
}
async function main() {
const entries = await captureAll();
const errors = validate(entries);
if (errors.length) {
for (const error of errors) console.error(`✖ ${error}`);
process.exit(1);
}
const manifest = JSON.parse(await readFile(MANIFEST_PATH, "utf8"));
manifest.screenshots = entries;
await writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`✔ wrote ${entries.length} screenshots to ${MANIFEST_PATH}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Notes on the choices in this script:
scale: "device"(Playwright's default forpage.screenshot(), stated explicitly here) produces one image pixel per device pixel, so the output size is exactly viewport ×deviceScaleFactor. The PNG header is then read back to write accuratesizes, which desktop Chrome uses for layout.animations: "disabled"fast-forwards finite CSS animations and transitions to their end state and cancels infinite ones, per Playwright's documentation;reducedMotion: "reduce"lets your own motion-aware code settle as well.context.clock.setFixedTime()makesDate.now()andnew Date()return the same instant on every run while leaving timers running, so timestamps render identically without stalling the app's own loading logic. (clock.install()installs fake timers that you drive withpauseAt(),runFor()orfastForward(); it's the right tool when a scene needs a specific countdown state, but more than a still screenshot needs.)serviceWorkers: "block"avoids capturing whatever an old service worker cached during development.- PNG keeps UI text crisp and makes the dimension check trivial. Playwright's
typeoption also acceptsjpegand, in current releases,webp; compress in a later build step if file size matters more than simplicity (see the Android warning above). - A
data-readyattribute set by the app when a view has finished loading is more reliable than waiting for network idle alone, which can resolve before client-side rendering completes.
The validation mirrors Chromium's rules. Run it in CI after the build, commit the generated images, and fail the pipeline if anything is out of spec. Automated Testing covers running Playwright against a local production build.
Keeping screenshot files small and cacheable¶
A 1080×2340 PNG of a map or photo-heavy screen easily weighs 1 to 3 MB, and on Android those bytes can be spent on visitors who never install. A second build step converts the captures to WebP, checks that the pixel dimensions survived (the sizes you declared must stay true), enforces a byte budget, and gives every file a content hash so it can be cached forever:
#!/usr/bin/env node
// Converts manifest screenshots to content-hashed WebP files and rewrites the manifest.
// Run after capture-screenshots.mjs. Requires: npm install --save-dev sharp
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import sharp from "sharp";
const PUBLIC_DIR = "public";
const MANIFEST_PATH = path.join(PUBLIC_DIR, "manifest.webmanifest");
const BUDGET_BYTES = 350 * 1024; // Per file; tune to your audience's networks.
async function optimize(entry) {
const inputPath = path.join(PUBLIC_DIR, entry.src);
const { data, info } = await sharp(inputPath)
.webp({ quality: 82, effort: 6 }) // UI text stays legible at this quality.
.toBuffer({ resolveWithObject: true });
const [declaredWidth, declaredHeight] = entry.sizes.split(" ")[0].split("x").map(Number);
const problems = [];
if (info.width !== declaredWidth || info.height !== declaredHeight) {
problems.push(`${entry.src}: output is ${info.width}x${info.height}, sizes says ${entry.sizes}`);
}
if (data.length > BUDGET_BYTES) {
problems.push(`${entry.src}: ${Math.round(data.length / 1024)} KiB exceeds the budget`);
}
// Content hash in the filename: safe to serve with "immutable" caching.
const hash = createHash("sha256").update(data).digest("hex").slice(0, 10);
const { dir, name } = path.posix.parse(entry.src);
const src = `${dir}/${name}.${hash}.webp`;
await writeFile(path.join(PUBLIC_DIR, src), data);
return { entry: { ...entry, src, type: "image/webp" }, problems };
}
async function main() {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, "utf8"));
const results = await Promise.all((manifest.screenshots ?? []).map(optimize));
const problems = results.flatMap((result) => result.problems);
if (problems.length) {
for (const problem of problems) console.error(`✖ ${problem}`);
process.exit(1);
}
manifest.screenshots = results.map((result) => result.entry); // Order is preserved.
await writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
for (const { entry } of results) console.log(`✔ ${entry.src}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Serve the hashed files with Cache-Control: public, max-age=31536000, immutable. Keep the manifest itself on a short cache lifetime (or no-cache with validation) so a new set of screenshot URLs reaches browsers promptly. Screenshots are not security-sensitive members, so changing them never triggers an update review; the next install dialog simply uses the new files. There's little point in precaching screenshots in your service worker: they matter only before installation, and installed users never see them again, so precaching would spend every visitor's storage and bandwidth on images most of them won't need.
Testing the rich install UI¶
DevTools. In Chromium, Application > Manifest lists each Screenshot #N with its image, and the warnings are specific:
- "Richer PWA install UI won’t be available on desktop. Add at least one screenshot with the
form_factorset towide." - "Richer PWA install UI won’t be available on mobile. Add at least one screenshot for which
form_factorisn’t set or set to a value other thanwide." - "No more than 8 screenshots will be displayed on desktop" and "No more than 5 screenshots will be displayed on mobile."
- "All screenshots with the same
form_factormust have the same aspect ratio as the first screenshot with thatform_factor." - Per-image errors when the actual pixel size doesn't match
sizes, when a size is outside 320 to 3840, when a side exceeds 2.3 times the other, and when a screenshot's first size isany.
DevTools checks aspect ratios only among screenshots that declare a form_factor, while Android also includes screenshots without one. Run the capture script's validation too.
Real devices. Open the site in Chrome on Android (not installed yet) and start installation from the menu (confirming Install if Chrome asks whether to install or create a shortcut): the bottom sheet should open expanded, with the description and screenshots. Don't try to test the peeked sheet by waiting for Chrome's own promotion; the ML gating and dismissal guardrails make it non-deterministic. A debug button that calls prompt() on a saved beforeinstallprompt event exercises the same sheet reliably. On desktop, use the install icon in the address bar. To see the dialog again after installing, uninstall the app first (see Detecting Installed Apps and Installation by Platform).
Programmatic checks. The Chrome DevTools Protocol's Page.getAppManifest returns the processed screenshots (image URL, sizes, type, formFactor, label) and the processed description, which you can assert in Playwright the same way App Identity & Updates shows for id.
import { test, expect } from "@playwright/test";
test.skip(({ browserName }) => browserName !== "chromium", "Uses the Chrome DevTools Protocol");
test("manifest qualifies for the rich install UI on Android and desktop", async ({ page }) => {
await page.goto("/");
const cdp = await page.context().newCDPSession(page);
const { errors, manifest } = await cdp.send("Page.getAppManifest");
expect(errors.map((e) => e.message)).toEqual([]);
expect(manifest.description?.length ?? 0).toBeGreaterThan(40);
expect(manifest.description.length).toBeLessThanOrEqual(300);
const screenshots = manifest.screenshots ?? [];
const wide = screenshots.filter((s) => s.formFactor === "wide");
const narrow = screenshots.filter((s) => s.formFactor !== "wide");
expect(wide.length).toBeGreaterThanOrEqual(1);
expect(narrow.length).toBeGreaterThanOrEqual(1);
expect(screenshots.length).toBeLessThanOrEqual(8);
for (const shot of screenshots) {
expect(shot.label, `${shot.image.url} needs a label`).toBeTruthy();
expect(shot.image.sizes, `${shot.image.url} needs sizes`).toMatch(/^\d+x\d+/);
}
// Every screenshot URL must actually load.
for (const shot of screenshots) {
const response = await page.request.get(shot.image.url);
expect(response.ok(), shot.image.url).toBe(true);
}
});
The value of formFactor for an entry without form_factor isn't documented in the protocol, so the test treats anything other than wide as narrow, which mirrors Android's selection rule.
Common pitfalls¶
- Only wide screenshots. Desktop gets the rich dialog, Android doesn't. The reverse (only narrow or unset) is equally common.
- Wide screenshots listed first. Android counts them toward the limit of eight and can end up with fewer narrow screenshots than you listed.
- Screenshots captured on different devices. Different aspect ratios mean Android drops every screenshot that doesn't match the first.
- 21:9 phone captures. A ratio of 2.33 exceeds 2.3; the screenshot is dropped.
- Retina desktop captures at 2× on a 4K viewport. 3840 CSS pixels at 2× produce 7680-pixel images, over the 3840 limit.
- Missing or wrong
sizes. Desktop reserves the wrong space, and DevTools flags a size mismatch. - A description written for SEO. It's cut at 300 characters and read by people deciding whether to install.
- Relying on
labelfor Android accessibility. Chrome for Android currently announces a generic string. Keep the description meaningful on its own. - Expecting
categoriesoriarc_rating_idto change anything in Chrome. Chromium ignores both. - Huge, uncached screenshot files. On Android they can be fetched during the installability check for visitors who never install.
Further reading¶
On this site
- Install Prompts & Custom UI:
beforeinstallprompt,prompt()and when to ask - Installability Criteria: what must be true before any install UI appears
- Members Reference: every manifest member, including localized variants
- Icons & Maskable Icons: the icon shown at the top of the install UI
- Publishing to App Stores: where
categories,iarc_rating_idand screenshots matter more - PWABuilder: packaging for stores
- Automated Testing: Playwright setup for PWAs
- App-Like UX Patterns: designing the screens you'll capture
External references
- Web App Manifest - Application Information (W3C Group Note):
description,screenshots,categories,iarc_rating_id,platformandform_factor - Web Application Manifest (W3C Working Draft): localization and image resources
- Richer PWA installation UI and Richer install UI on desktop: Chrome's announcements
- MDN: screenshots
- WebKit Features in Safari 17.4:
categoriesandshortcutson macOS - Playwright
page.screenshot()and emulation options - International Age Rating Coalition