Display Modes¶
A display mode controls how much browser UI surrounds your installed app: none at all (fullscreen), only the operating system's window frame or status bar (standalone), a small navigation bar (minimal-ui), or a normal browser tab (browser). You request one with the manifest's display member, or with an ordered wish list in display_override. The browser then applies whichever mode it supports and reports the mode it actually applied through the display-mode media feature. The request and the result often differ, so the gap between them is where most bugs come from: a missing back button on iOS, an install that silently fails because of a typo, a fullscreen game that opens in a normal window on desktop.
Key takeaways
- The four standard modes form a fixed fallback chain:
fullscreen→standalone→minimal-ui→browser. A browser that can't honor your mode walks down the chain, never up. display_override(Chromium 89+) is an ordered list that is tried beforedisplay. It is the only place wherewindow-controls-overlay,tabbedandunframedare valid. Put them indisplayand the value is ignored.- Always style and script against the applied mode (
@media (display-mode: …),matchMedia()), never against what the manifest asked for. Desktop Chromium turnsfullscreenintostandaloneandbrowserintominimal-ui. The user can move the app into a tab, and the value changes at runtime. - Safari derives
display-modefrom the manifest'sdisplayvalue, not from the actual window. On iOS, combine the media query withnavigator.standalone. - Standalone windows remove the back button, reload, share and the address bar on most platforms. Provide in-app replacements that appear only when the display mode calls for them.
- The manifest
orientationmember only applies in app-like modes, mostly on Android.screen.orientation.lock()requires fullscreen in Chromium, and Safari doesn't support it at all.
The four standard display modes¶
The Web Application Manifest spec defines four display modes. Each is a request: the spec lets the browser pick another mode from the fallback chain if it can't or won't honor it.
| Mode | Spec definition (abridged) | What users typically see | Fallback |
|---|---|---|---|
fullscreen | "Opens the web application with browser UI elements hidden and takes up the entirety of the available display area." | Android: immersive, with the status and navigation bars hidden. Desktop Chromium and iOS show it like standalone | standalone |
standalone | Looks and feels like a standalone native application. Standard browser UI such as the URL bar is excluded; system UI such as window decorations, the status bar and a system back button can remain. | A window of its own (desktop), or a full-screen activity with the status bar (mobile), with no address bar | minimal-ui |
minimal-ui | "Similar to standalone, but provides the end-user with some means to access a minimal set of UI elements for controlling navigation." | Back and reload buttons in the title bar (desktop Chromium), a compact toolbar (Chrome on Android) | browser |
browser (default) | "Opens the web application using the platform-specific convention for opening hyperlinks." | A normal browser tab | none |
browser is the default whenever display is missing or invalid. That's why desktop Chromium doesn't promote a manifest without display: the effective mode is browser, and Chromium only promotes apps whose mode is standalone, fullscreen, minimal-ui or one of the extended modes described below. Chrome on Android is more lenient with a missing display and only rejects an explicit "browser", but don't rely on that. The Installability Criteria page lists the exact error codes.
The fallback chain¶
The spec defines the fallback as a strict chain. Each mode falls back to the next one down, and browser is the floor:
flowchart LR
F["fullscreen"] -->|"not supported"| S["standalone"]
S -->|"not supported"| M["minimal-ui"]
M -->|"not supported"| B["browser"] The chain has a design flaw that motivated display_override. You can't say "I want minimal-ui, but if that's unavailable, give me standalone rather than a browser tab", because standalone sits above minimal-ui in the chain. A new mode such as window-controls-overlay or tabbed has no natural slot in the chain either. See display_override below.
How the display member is parsed¶
The spec's processing steps for display are short:
- Start with the default,
browser. - If
json["display"]is not a string, stop and keep the default. - Strip leading and trailing ASCII whitespace and convert the value to ASCII lowercase.
- If the result is one of
fullscreen,standalone,minimal-uiorbrowser, use it. Otherwise keep the default.
Implementations differ in the details:
| Input | Spec | Chromium | WebKit (Safari) |
|---|---|---|---|
"standalone" | standalone | standalone | standalone |
" Standalone " | standalone (trimmed, lowercased) | standalone (trimmed, case-insensitive match) | standalone (trimmed, case-insensitive match) |
"standalon" | browser | Undefined, with the console warning unknown 'display' value ignored. | browser, with the warning "standalon" is not a valid display mode. |
"window-controls-overlay" | browser (not a valid display value) | Undefined, with the warning inapplicable 'display' value ignored. | browser |
42 or null | browser | Undefined, with the warning property 'display' ignored, type string expected. | browser |
Chromium keeps an internal "undefined" state rather than storing browser. It later resolves an undefined mode to browser for promotion purposes, which is why display: "standalon" produces the manifest-display-not-supported installability error instead of a clear parse error. Chrome DevTools prints the parser warnings in Application → Manifest, and it's the fastest way to catch typos. See Browser DevTools.
Declared mode versus applied mode¶
The spec separates the mode you declare in the manifest from the mode the browser applies to a given window. The applied mode can differ for several reasons:
- The platform doesn't support the declared mode. Desktop operating systems have no "fullscreen app" launch, for example.
- The user overrode it. Desktop Chromium lets users switch an app between Open in window and Open in browser tab. Users on iOS 26 can turn off "Open as Web App" when they add a site to the Home Screen.
- The app is running in a context that the manifest doesn't govern: a normal tab, an out-of-scope page, a Document Picture-in-Picture window.
- The mode changed at runtime. The user entered fullscreen, moved the app into a browser tab, or toggled the window controls overlay.
The spec requires that "a user agent MUST reflect the applied display mode of the web application in the display-mode media feature". Media Queries Level 5 adds that in child browsing contexts (iframes) the display mode matches the top-level browsing context. Base your code on that media feature. The manifest value is only a hint about what you might get.
display_override: a custom fallback chain¶
display_override is an array of display modes that the browser tries in order, before it looks at display. It is defined in the WICG Manifest Incubations draft, not in the W3C manifest spec, and has shipped in Chromium since version 89 (Chrome, Edge, Opera, Samsung Internet). Safari and Firefox ignore it and use display only, so always keep a sensible display as the baseline.
{
"display_override": ["window-controls-overlay", "minimal-ui"],
"display": "standalone"
}
In a browser that supports all of these, the candidates are evaluated in this order:
window-controls-overlay(fromdisplay_override)minimal-ui(fromdisplay_override)standalone(fromdisplay)minimal-ui(from the fallback chain ofdisplay)browser(from the fallback chain ofdisplay)
Processing rules¶
The Manifest Incubations algorithm processes the member like this:
- If
display_overrideis missing or not an array, the result is an empty list. Chromium logsproperty 'display_override' ignored, type array expected. - Each string entry that is a known display mode, including the extensions
window-controls-overlay,tabbedandunframed, is kept. Unknown strings are dropped silently. There is no console warning for a typo insidedisplay_override. - An entry can also be an object,
{ "display": "…", "url_patterns": [ … ] }. Such an object is kept only if the browser supports URL patterns for that display mode. - To pick the mode, the browser walks the list and returns the first entry it supports. If none is supported, it falls back to processing
displaywith its normal chain.
Per-URL display modes
The object form with url_patterns is a recent addition to the draft. It lets an app use a different display mode for certain URLs. Current Chromium accepts url_patterns only for unframed (see below). For any other mode, an entry with patterns is dropped with the warning display override '<mode>' ignored, url_patterns are not allowed., while plain string entries keep working as before.
Display mode values and where they work¶
| Value | Valid in display | Valid in display_override | Where it takes effect |
|---|---|---|---|
fullscreen | ✅ | ✅ | Chrome on Android, Firefox for Android. Desktop Chromium resolves it to standalone |
standalone | ✅ | ✅ | Every browser that installs apps |
minimal-ui | ✅ | ✅ | Chromium on desktop and Android, Firefox for Android. Safari has no minimal-UI presentation: a macOS Dock web app keeps its default navigation toolbar (as with browser or no manifest), and iOS shows no browser controls |
browser | ✅ | ✅ (useless) | Everywhere. As the first recognized display_override entry, it makes the app non-installable in Chromium |
window-controls-overlay | ❌ | ✅ | Chromium 105+ on Windows, macOS, Linux and ChromeOS |
tabbed | ❌ | ✅ | Chromium on ChromeOS. Other desktops need a flag |
unframed | ❌ | ✅ | Isolated Web Apps only |
picture-in-picture | ❌ | ❌ | Not a manifest value. It exists only as a display-mode media feature value |
How desktop Chromium resolves the effective mode¶
Chromium's desktop logic lives in ResolveEffectiveDisplayMode() in web_app_registrar.cc. Reading it explains several behaviors that surprise developers:
- The user's choice comes first. If the user picked Open in browser tab for the app, the result is
browser, whatever the manifest says. display_overrideis walked next. Each entry is mapped to what an app window can show. An entry counts only if the mapping leaves it unchanged.minimal-ui,standalone,window-controls-overlayandunframedmap to themselves, and so doestabbedwhen the tab strip feature is enabled.fullscreenmaps tostandalone, andbrowsermaps tominimal-ui, so both are skipped on desktop and the walk continues with the next entry.displayis the fallback.standaloneandfullscreenbecomestandalone.browserandminimal-uibecomeminimal-ui. An app window is neverbrowser, because the user chose a window.- Isolated Web Apps are clamped. For an IWA,
minimal-uiandtabbedbecomestandalone, and a browser tab is never allowed.
Two consequences follow. First, "display": "fullscreen" on desktop gives you an ordinary standalone window. If you need real fullscreen on desktop, call element.requestFullscreen() in response to a user gesture. Second, when a site is installed through Chrome's Install page as app without a usable manifest, it gets a standalone window by default. The installed app still behaves as an app, so every UI adaptation on this page applies to it.
How Chrome on Android resolves it¶
On Android the display mode is baked into the WebAPK or home-screen shortcut when it is created. Chromium's ShortcutInfo::UpdateFromManifest() does the following:
- Start from
display, if it is set. - Walk
display_overrideand take the first entry that is one of the four standard modes.window-controls-overlay,tabbedandunframedare skipped on Android. - If the result is
standalone,fullscreenorminimal-ui, also apply the manifest'sorientation(see Orientation). - Finally,
UpdateDisplayMode()normalizes the mode for the kind of launcher entry being created. For a WebAPK, any mode that isn'tstandalone,fullscreenorminimal-uibecomesminimal-ui. For a plain home-screen shortcut, an app-like mode becomesminimal-uiand anything else becomesbrowser.
For sites without a manifest, the legacy <meta name="mobile-web-app-capable" content="yes"> or apple-mobile-web-app-capable tags still make Chromium's shortcut data default to standalone. Because the WebAPK stores the display mode, changing display later reaches users only through a WebAPK update. See App Identity & Updates.
Worked examples¶
| Manifest | Desktop Chromium | Chrome on Android | Safari (iOS 26, macOS) | Firefox for Android |
|---|---|---|---|---|
"display": "standalone" | standalone window | standalone | Web app without browser UI | standalone |
"display": "fullscreen" | standalone window | Immersive fullscreen | Web app; iOS keeps the status bar visible | fullscreen |
"display": "minimal-ui" | Window with back and reload buttons | minimal-ui toolbar | iOS: web app only if the user keeps "Open as Web App" on. macOS: window with a navigation toolbar | minimal-ui |
"display": "browser" | Not promotable. Install page as app still creates an app window | Not promotable; a shortcut opens a tab | iOS 26: web app by default. macOS: window with a navigation toolbar | Not installable |
"display": "standalone", "display_override": ["fullscreen"] | standalone (the override is skipped) | fullscreen | Uses display: standalone | Uses display |
"display": "standalone", "display_override": ["window-controls-overlay"] | WCO when the user enables it, otherwise standalone | standalone | standalone | standalone |
"display": "browser", "display_override": ["minimal-ui"] | minimal-ui | minimal-ui | Uses display: browser (no display_override support); iOS 26 still opens it as a web app by default | Not installable |
Extended display modes¶
Chromium adds three display modes that are valid only inside display_override, plus one media-feature-only value. Each has its own page or section elsewhere on this site. Here is how they fit into the display mode model.
window-controls-overlay¶
Available on desktop Chromium (Chrome and Edge 105 and later, on Windows, macOS, Linux and ChromeOS). The app's client area extends over the whole window, including the title bar. The OS window controls (minimize, maximize, close) and the browser's app menu are drawn as an overlay in one corner. Your page draws its own title bar content in the remaining area.
Three details matter for display mode handling:
- The user has to enable it. Chromium shows a toggle in the title bar, and by default the overlay requires both the manifest opt-in and the user's choice. While the regular title bar is visible, the applied mode is
standalone, notwindow-controls-overlay. - The media query tracks the overlay. Chromium reports
window-controls-overlayonly while the overlay is active and its area isn't empty. Listen for changes (see Listening for display mode changes). - The geometry has its own API.
navigator.windowControlsOverlay.visible,getTitlebarAreaRect(), thegeometrychangeevent, and the CSS environment variablesenv(titlebar-area-x),env(titlebar-area-y),env(titlebar-area-width)andenv(titlebar-area-height). Mark draggable regions withapp-region: drag(Chromium treats-webkit-app-regionas an alias). Chromium is also prototyping a standards-track replacement,window-drag: move, behind a runtime flag.
A minimal title bar that works in both states:
.titlebar {
display: none; /* only rendered when the overlay is active */
}
@media (display-mode: window-controls-overlay) {
.titlebar {
display: flex;
align-items: center;
position: fixed;
/* Fallback values apply if the env() variables are unavailable. */
left: env(titlebar-area-x, 0);
top: env(titlebar-area-y, 0);
width: env(titlebar-area-width, 100%);
height: env(titlebar-area-height, 33px);
-webkit-app-region: drag;
app-region: drag;
}
.titlebar :is(button, a, input, select) {
-webkit-app-region: no-drag; /* interactive controls must stay clickable */
app-region: no-drag;
}
}
The complete API, including hit-testing, the geometrychange event and theming, is on Window Controls Overlay.
tabbed¶
tabbed gives an app window its own tab strip, so a document-style app can keep several documents open in one window. You configure the strip with the tab_strip member (a pinned home_tab and a new_tab_button). Chrome's documentation says the mode "has shipped on ChromeOS". In Chromium's feature configuration it is stable on ChromeOS and experimental everywhere else. On Windows, macOS and Linux, Chromium's manifest parser treats tabbed as unknown unless the desktop tab strip flag is enabled, so the next display_override entry wins.
{
"display_override": ["tabbed", "standalone"],
"display": "standalone",
"tab_strip": {
"home_tab": { "scope_patterns": [{ "pathname": "/" }] },
"new_tab_button": { "url": "/documents/new" }
}
}
Detect it with @media (display-mode: tabbed). Because the value is unknown to Safari and Firefox, the query simply evaluates to false there, and the rest of your stylesheet is unaffected. tab_strip and the launch_handler interaction are covered in Advanced & Integration Members.
unframed (formerly borderless)¶
Experimental
unframed is restricted by spec to Isolated Web Apps and is gated behind the window management permission. Chromium's source called it borderless in earlier releases (the Chrome 140 branch still does); the current parser only recognizes unframed. It was a developer trial from Chrome 146 and shipped in Chrome 152 on ChromeOS only, with a gradual rollout. Regular PWAs can't use it.
An unframed window has no host-native title bar and no visible window controls. Web content covers the whole window, and the app defines draggable regions itself with app-region. The spec requires that the display mode stays fixed for the lifetime of the window and that the browser MUST NOT allow out-of-scope navigations inside it. Because there is no title bar, the browser has to show the app's origin and privacy indicators (camera, microphone) elsewhere. It is also the one mode for which Chromium accepts the object form with url_patterns, which lets an IWA use unframed windows only for specific URLs. Query it with @media (display-mode: unframed).
picture-in-picture¶
picture-in-picture isn't something you request in a manifest. It is the display-mode value of a document shown in a Document Picture-in-Picture window, the always-on-top window that documentPictureInPicture.requestWindow() opens. Chrome 123 added the value on desktop (the Document Picture-in-Picture API itself shipped in Chrome 116), and Firefox 151 added both the API and the media feature value on desktop. The PiP window is a separate document with its own media query context, so styles like these apply only inside the floating window:
@media (display-mode: picture-in-picture) {
body {
margin: 0;
font-size: 14px;
}
.controls-secondary {
display: none; /* the floating window is small; keep only essential controls */
}
}
The media APIs involved are covered on Media & System APIs.
The display-mode media feature¶
The display-mode media feature is defined in Media Queries Level 5 with the values fullscreen | standalone | minimal-ui | browser | picture-in-picture. It is a discrete feature, so exactly one value matches at a time. Chromium additionally recognizes window-controls-overlay, tabbed and unframed. Browsers that don't know a value treat a query that uses it as not all, so it never matches and never breaks a comma-separated list.
MQ5 also clarifies that the feature isn't limited to installed apps. It "can also be used in non-application contexts to determine whether the viewport is in other modes, such as fullscreen or picture-in-picture". In a normal tab, the value is browser.
/* Browser tab: show the install promotion and hide app-only chrome. */
.app-only {
display: none;
}
/* Any app window. List every app-like mode: an unknown value
in the list is ignored by browsers that don't support it. */
@media (display-mode: standalone),
(display-mode: minimal-ui),
(display-mode: fullscreen),
(display-mode: window-controls-overlay),
(display-mode: tabbed) {
.app-only {
display: revert;
}
.install-promo {
display: none;
}
}
/* minimal-ui already has a browser back button: don't duplicate it. */
@media (display-mode: minimal-ui) {
.in-app-back {
display: none;
}
}
Reading the display mode from JavaScript¶
window.matchMedia() evaluates the same queries. Because only one value matches, loop over the values you care about and return the first match:
const DISPLAY_MODES = [
"picture-in-picture",
"unframed",
"window-controls-overlay",
"tabbed",
"fullscreen",
"standalone",
"minimal-ui",
"browser",
];
export function getDisplayMode() {
for (const mode of DISPLAY_MODES) {
if (window.matchMedia(`(display-mode: ${mode})`).matches) return mode;
}
// No match at all: a very old engine without display-mode support.
return "browser";
}
The feature is available in every current engine: Chrome 42, Firefox 47, Safari 13 on macOS and Safari 12.2 on iOS, according to MDN's compatibility data. Firefox for Android fully supports it from version 116. Before that, browser was always true there.
Listening for display mode changes¶
The applied mode can change while your page is running:
- Open in app / open in browser. Chromium can move a live page between a browser tab and an app window without reloading it. The Open in
button in the address bar moves the tab into an app window. The app menu's Open in Chrome (or Edge) moves it back into a tab. browserswitches tostandaloneor the other way round. - Fullscreen. On desktop Chromium, when the window goes fullscreen (the user presses F11 or the page calls
requestFullscreen()), the value becomesfullscreen, and it returns to the previous value on exit. - Window controls overlay toggle.
window-controls-overlayandstandalonealternate as the user toggles the title bar.
Each MediaQueryList fires a change event when its result flips. Listen on all of them and recompute:
export function onDisplayModeChange(callback, { signal } = {}) {
const lists = DISPLAY_MODES.map((mode) =>
window.matchMedia(`(display-mode: ${mode})`),
);
const handler = (event) => {
// Every transition fires twice: once for the old value (matches: false)
// and once for the new one (matches: true). React to the second only.
if (event.matches) callback(getDisplayMode());
};
for (const list of lists) {
if (typeof list.addEventListener === "function") {
list.addEventListener("change", handler, { signal });
} else {
// Safari before 14 only has the deprecated addListener().
list.addListener(handler);
signal?.addEventListener("abort", () => list.removeListener(handler), {
once: true,
});
}
}
}
MediaQueryList has supported addEventListener("change") since Chrome 39, Firefox 55 and Safari 14. The addListener() branch only matters for old Safari versions, mostly on iOS devices that can't update.
How each engine computes the value¶
The same query can return different answers for the same manifest, because engines compute it differently.
Chromium's Browser::GetDisplayMode() (quoted here from the Chrome 140 source) checks, in order:
- The window is fullscreen (F11 or the Fullscreen API) →
fullscreen. - The window is a Document Picture-in-Picture window →
picture-in-picture. - The window is an app window:
- It shows minimal-UI buttons →
minimal-ui. - It uses the window controls overlay and the overlay area isn't empty →
window-controls-overlay. - It uses the tab strip →
tabbed. - It is an unframed IWA window →
unframed(borderlessin that release). - Otherwise →
standalone.
- It shows minimal-UI buttons →
- Anything else, including every normal tab →
browser.
Out-of-scope pages in an app window still report the window's mode.
Chrome's delegate for WebAPKs and Trusted Web Activities returns fullscreen whenever the tab is fullscreen. Otherwise it returns the WebAPK's stored mode, with two corrections in current source. A minimal-ui app reports standalone when its minimal-UI navigation controls aren't actually rendered, and a window-controls-overlay app reports standalone when the header isn't drawn as an overlay. A normal Chrome tab reports browser, or fullscreen during element fullscreen according to the same source, although MDN's compatibility data still lists the fullscreen value as unsupported on Android. Test on a device if you depend on it.
WebKit's display-mode evaluator reads the display value of the applied manifest and returns it verbatim, with one documented exception on iOS described below. Without an applied manifest, the result is browser, including in an iOS 26 Home Screen web app added without a manifest. It ignores the Fullscreen API and the actual window state. MDN's compatibility notes spell out the consequences: in a Safari browser window browser is always true, even in macOS full screen or during element fullscreen. minimal-ui is never true in Safari. The iOS exception: on iOS and iPadOS, a Home Screen web app whose manifest says "display": "standalone" matches display-mode: fullscreen, not standalone (WebKit bug 264218, open since 2023, and MDN's compatibility notes), although iOS always shows the status bar. On iOS, check navigator.standalone === true before any display-mode query.
Desktop Firefox reports fullscreen in its own Full Screen mode (even though tabs can still appear there), and browser otherwise. standalone is never true on desktop. Firefox 143 introduced web apps pinned to the Windows taskbar, and MDN's data says those windows match minimal-ui. Firefox for Android 116 and later reports the installed app's mode correctly.
Detecting an installed launch robustly¶
"Am I running as an installed app?" sounds like a single media query. In practice you need several signals, because of the engine differences above and because iOS 26 lets users open any site as a web app, with or without a manifest.
import { getDisplayMode } from "./display-mode.js";
const APP_LIKE_MODES = new Set([
"standalone",
"minimal-ui",
"window-controls-overlay",
"tabbed",
"unframed",
]);
const LAUNCH_FLAG = "launched-as-app";
/**
* Best-effort answer to "is this document running inside an installed app?"
* Combine signals: no single one is reliable on every platform.
*/
export function isInstalledContext() {
// 1. iOS/iPadOS Home Screen web apps: non-standard but dependable there.
if (navigator.standalone === true) return true;
// 2. The applied display mode.
const mode = getDisplayMode();
if (APP_LIKE_MODES.has(mode)) return true;
// 3. "fullscreen" is ambiguous: an installed fullscreen app, or a browser
// tab in F11 / element fullscreen. Use a marker set by start_url.
if (mode === "fullscreen") return readLaunchFlag();
return false;
}
/** Call once on startup. start_url is "/?source=pwa" in the manifest. */
export function recordLaunchSource() {
const params = new URLSearchParams(location.search);
if (params.get("source") === "pwa") {
try {
sessionStorage.setItem(LAUNCH_FLAG, "1");
} catch {
// Storage can be unavailable (privacy modes); detection degrades gracefully.
}
}
}
function readLaunchFlag() {
try {
return sessionStorage.getItem(LAUNCH_FLAG) === "1";
} catch {
return false;
}
}
The start_url marker only covers launches from the app icon. Shortcuts, share targets, file handlers and protocol handlers open other URLs. sessionStorage is per window (and per tab), so the flag survives in-app navigations but doesn't leak into other tabs. Detection techniques that don't depend on the display mode at all, such as getInstalledRelatedApps() and the appinstalled event, are covered in Detecting Installed Apps. For measuring installed usage in analytics, see Analytics for PWAs.
fullscreen display mode versus the Fullscreen API¶
The manifest spec says the fullscreen display mode "is orthogonal to, and works independently of, the Fullscreen API". MQ5 draws the line precisely:
:fullscreenmatches an element that is in the fullscreen element stack.(display-mode: fullscreen)matches when the browsing context fills the screen without browser UI. That can happen because of the manifest, because ofrequestFullscreen()(when it takes the browser to OS-level fullscreen), or because the user used the browser's own fullscreen control.
| Situation | :fullscreen | (display-mode: fullscreen) |
|---|---|---|
Chrome desktop tab, video.requestFullscreen() | ✅ on the video | ✅ (the window is fullscreen) |
| Chrome desktop tab, user presses F11 | ❌ | ✅ |
Installed Android app with "display": "fullscreen" | ❌ | ✅ |
Safari tab, element.requestFullscreen() | ✅ | ❌ (always browser in a Safari window) |
| Firefox desktop, user enters Full Screen | ❌ | ✅ |
Use :fullscreen and document.fullscreenElement to style content that you put into fullscreen. Use display-mode to adapt your whole layout to the surroundings.
Per-platform behavior¶
The same manifest produces noticeably different windows on each platform. This section describes what the user sees and what disappears, which drives the UI adaptations in the next section.
Chromium on desktop: Chrome and Edge on Windows, macOS, Linux and ChromeOS¶
standaloneopens a separate OS window with a title bar. The title bar is tinted withtheme_color(or the page's<meta name="theme-color">) and contains the app icon, the window title and an app menu button. The app menu has the browser-level functions the window lacks: Copy URL, Open in Chrome (or Edge), zoom, print, find, cast, site information and permission settings. The same menu hosts the Uninstall and app settings entries.minimal-uiis the same window plus back and reload buttons in the title bar. The reload button turns into a stop button while a page is loading.fullscreenis not an app launch mode on desktop. It resolves tostandalone(see How desktop Chromium resolves the effective mode).- Users can change the mode. The app settings offer Open in window versus Open in browser tab, and Open in Chrome moves the current page into a tab. Your UI has to cope with
browsermode for an installed app. - Keyboard shortcuts still work. Ctrl+R / Cmd+R reloads and Alt+← / Cmd+[ goes back. Few users know the shortcuts, so they don't replace visible controls.
The window title comes from your document, combined with the app's short_name. Chromium's WebAppBrowserController::GetTitle() builds it like this:
| Condition | Title bar text |
|---|---|
| An out-of-scope page is showing (the origin bar is visible) | short_name only |
The page has <meta name="application-title" content="Inbox"> | short_name - Inbox (just short_name if the content is empty) |
document.title already starts with short_name | document.title |
document.title is empty | short_name |
| Otherwise | short_name - document.title |
The application-title meta tag shipped in Chrome 134 (Chrome Platform Status calls it "Document Subtitle"). Use it when your <title> is tuned for tabs and search results ("(3) Inbox – Example Mail") but the app window should show something shorter. The title also appears in Alt+Tab, the taskbar, the Dock and the window switcher, so keep it meaningful per view in single-page apps.
Chrome on Android (WebAPK)¶
standaloneruns the app as its own Android task, with its own entry in Recents. The status bar remains visible and is tinted withtheme_color. The system back gesture or button walks your session history, and once there is nothing left it closes the app. There is no address bar and no browser menu. Some Android browsers add a persistent notification while the app is in the foreground, from which the user can copy the URL or open the page in the browser.fullscreenhides the status bar and the navigation bar (Android's immersive mode). The user can swipe from the edge to show them temporarily. Use it for games, readers and kiosks, not for regular apps. Users lose the clock and the notification indicators.minimal-uiadds a compact toolbar. web.dev's PWA course describes it as a title bar that shows the current<title>and the origin, with a small menu. As noted above, current Chromium reportsstandalonerather thanminimal-uiwhenever the minimal-UI navigation controls aren't actually rendered.browserapps aren't promoted for installation. Add to home screen creates a shortcut that opens a normal tab.- Pull-to-refresh stays enabled in standalone apps, which can fight with your own gestures (see Reload and pull-to-refresh).
Samsung Internet, Edge, Opera and other Chromium-based Android browsers follow the same model when they create WebAPKs. When they create browser-badged shortcuts instead, the display depends on the browser. Android covers WebAPK minting and shortcuts.
Firefox¶
Firefox for Android installs manifest-based apps and honors fullscreen, standalone and minimal-ui (MDN lists support since Firefox 47). It doesn't support display_override. Desktop Firefox has no manifest-based installation. Since Firefox 143, Windows users can pin sites to the taskbar as web apps, which Mozilla's release notes describe as "simplified windows". MDN's compatibility data says these windows match display-mode: minimal-ui. Mozilla doesn't document how they treat the manifest's display value.
Safari on iOS and iPadOS¶
- Before iOS 26, a site added to the Home Screen opened as a web app only if its manifest said
standaloneorfullscreen(or it had the legacyapple-mobile-web-app-capablemeta tag).minimal-uiandbrowsercreated a bookmark that opened Safari. - Since iOS and iPadOS 26, "every website added to the Home Screen opens as a web app" by default, with or without a manifest. The user can turn off Open as Web App in the Add to Home Screen sheet, "even if the site is configured to be a web app". WebKit's announcement sums it up: "there are now zero requirements for 'installability' in Safari".
- Presentation is always standalone-like. iOS never hides the status bar for a web app, so
fullscreenlooks likestandalone. There is no minimal-UI toolbar either. - No browser controls. There is no back button, no reload, no share button and no address bar, and iOS has no system back button either. The only visible navigation controls in a Home Screen web app are the ones you draw.
- Status bar style. In web app mode, iOS reads
<meta name="apple-mobile-web-app-status-bar-style">. Apple's documentation lists three values.defaultshows the normal status bar.blackgives it a black background. With both, content starts below the status bar.black-translucentmakes the status bar translucent and lets your content run underneath it, which is what you want for edge-to-edge designs combined withviewport-fit=coverand safe-area insets. navigator.standaloneis a non-standard Boolean. It istrueinside a Home Screen web app andfalsein Safari tabs. Since Safari 17 (June 2023, WebKit commit 265004@main) it also exists on macOS, where it isfalsein tabs andtruein Dock web apps, so its presence does not imply iOS: combine it withnavigator.maxTouchPoints > 0if you need to identify iOS or iPadOS. Other browsers leave itundefined.
The iOS & iPadOS page covers the rest of the Apple-specific behavior, including storage separation and Web Push.
Safari on macOS (web apps in the Dock)¶
Since Safari 17 on macOS Sonoma, File → Add to Dock turns any site into a web app, and it always opens in its own window, "even if the site does not have a manifest file". In Apple's WWDC23 session What's new in web apps, the default window has "a simplified toolbar with navigation buttons", and "the theme color for the site blends into the toolbar". Declaring "display": "standalone" removes the toolbar: "On macOS, the web app will not have a toolbar." Without a manifest, and with minimal-ui or browser, the default toolbar stays. Apple doesn't document what fullscreen does in a Dock web app, so test it before relying on it. Web apps in the Dock support badging, notifications and service workers like Home Screen web apps on iOS.
Platform summary¶
| Desktop Chromium | Chrome on Android | Safari iOS/iPadOS 26 | Safari macOS 17+ | Firefox for Android | Firefox on Windows (143+) | |
|---|---|---|---|---|---|---|
fullscreen | standalone window | Immersive, bars hidden | Standalone look, status bar visible | ⚠️ not documented | ✅ | Simplified window ⚠️ |
standalone | Window, title bar, app menu | Own task, status bar tinted | Web app, no browser UI | Window without toolbar | ✅ | Simplified window ⚠️ |
minimal-ui | Window + back/reload | Compact toolbar | Standalone look | Window with navigation toolbar | ✅ | Simplified window ⚠️ |
browser | Tab (or window via Install page as app) | Shortcut to a tab | Web app by default in iOS 26 | Window with navigation toolbar | Not installable | Simplified window ⚠️ |
display_override | ✅ | ✅ (standard modes only) | ❌ | ❌ | ❌ | ❌ |
| User can switch to a tab | ✅ | ⚠️ varies | ❌ | ❌ | ⚠️ | ⚠️ |
⚠️ = behavior varies or isn't documented by the vendor. Firefox's taskbar web apps report minimal-ui regardless of the cells above, according to MDN.
The Installation by Platform and Desktop Platforms pages show the install flows that lead to these windows.
Navigation scope and out-of-scope pages¶
Display modes apply only to URLs within scope. A URL is within scope when it has the same origin as the manifest's scope and its path starts with the scope's path. The spec doesn't allow browsers to block navigations that leave the scope: "user agents are no longer required or allowed to block off-scope navigations". Instead, "the user agent SHOULD show a prominent UI element indicating the Document/URL or at least its origin, including whether it is served over a secure connection". Each platform does it differently:
flowchart TD
A["Navigation in an app window"] --> B{"Target URL within scope?"}
B -- yes --> C["Stay in the app window, applied display mode unchanged"]
B -- no --> D{"Platform"}
D -- "Desktop Chromium" --> E["Same window, origin bar with a close button"]
D -- "Chrome on Android" --> F["Same task, toolbar with the origin and a close button"]
D -- "iOS / iPadOS" --> G["Opens in an in-app Safari view"]
D -- "macOS Safari" --> H["Opens in the default browser"] Desktop Chromium keeps the page in the app window and shows a custom tab bar below the title bar with the page's title, origin and security state. Chromium shows this bar for out-of-scope URLs and also for any page that isn't served over HTTPS (localhost excepted) or that has insecure content. The bar's close button doesn't close the window. It walks back through history to the most recent in-scope entry, and if there isn't one it loads start_url and clears the history. While the bar is visible, the window title becomes the app's short_name.
Chrome on Android keeps out-of-scope pages inside the app's task and shows a Custom Tabs-style toolbar with the origin and a close button, the same UI that Trusted Web Activities show when a user leaves the verified origin.
iOS and iPadOS open out-of-scope links in Safari View Controller, the in-app browser sheet. Apple's WWDC23 session says so for Home Screen web apps.
macOS Safari web apps open out-of-scope links in the user's default browser. The same session notes that window.open() always opens in the web app regardless of scope, and that OAuth flows on a third-party domain are kept in the app by heuristics. When those heuristics fail, window.open() is the documented escape hatch.
Practical rules that follow:
- Keep authentication in scope, or pop it up. Sign-in on another origin (an identity provider) shows the out-of-scope UI or leaves the app, depending on the platform. A popup (
window.open()) keeps the flow attached to the app window everywhere. Passkeys avoid the problem entirely. See Authentication & Passkeys. - Choose
scopedeliberately. A too-narrow scope makes your own pages look foreign. If you also serve pages from another origin you control, look atscope_extensionsin Advanced & Integration Members. - Test external links in each mode. Clicking a link to another site shouldn't trap the user. In a standalone window without an address bar, the user may not realize they have left your app.
- Links that open the app. Whether a link clicked elsewhere opens your installed app instead of a tab (link capturing,
launch_handler) is a separate mechanism, covered in Protocol Handlers & Launch Handling.
Orientation: the manifest member and the Screen Orientation API¶
The orientation member¶
orientation sets the default screen orientation of the installed app. Its values are the OrientationLockType values from the Screen Orientation spec:
| Value | Meaning |
|---|---|
any | Any orientation, including upside-down where the device allows it |
natural | The device's natural orientation: portrait on most phones, landscape on most tablets and laptops |
portrait | Either portrait orientation, as the platform decides |
portrait-primary | The primary portrait orientation (angle 0° on a phone) |
portrait-secondary | Upside-down portrait |
landscape | Either landscape orientation |
landscape-primary | The primary landscape orientation |
landscape-secondary | The other landscape orientation |
The spec says the value "serves as the default screen orientation for the life of the web application (unless overridden by some other means at runtime)" and makes the Screen Orientation API optional for browsers. Support is narrow:
- Chrome on Android applies it to WebAPKs and shortcuts, but only when the resolved display mode is
standalone,fullscreenorminimal-ui. Inbrowsermode it is ignored. - Firefox for Android supports it (MDN lists Firefox 79).
- Desktop browsers ignore it. Windows can be resized freely.
- Safari doesn't apply it, according to MDN's compatibility data. WebKit's manifest parser reads the member, but iOS web apps rotate with the device.
- Android 16 ignores orientation restrictions for apps targeting API level 36 on displays whose smallest width is at least 600 dp (tablets, unfolded foldables, desktop windowing). Expect a locked orientation not to hold on large screens as the browsers that host WebAPKs adopt that target.
Design for both orientations anyway. orientation is a preference, and the rest of the web platform (split-screen, desktop windows, foldables) makes any fixed aspect ratio fragile. See Responsive & Adaptive Design.
The Screen Orientation API at runtime¶
screen.orientation is a ScreenOrientation object: type (for example "portrait-primary"), angle (0, 90, 180 or 270), a change event, and the methods lock(orientation) and unlock(). Reading the orientation works everywhere: Chrome 38, Firefox 43 and Safari 16.4. Locking is the hard part.
lock() returns a promise and runs these checks, in spec order:
- Common safety checks. The document must be a fully active descendant of a top-level traversable with user attention, otherwise an
InvalidStateError. It must not be in a sandboxed iframe withoutallow-orientation-lock, and it must not be hidden. Both cases throw aSecurityError. - Support. If the browser can't lock at all, the promise rejects with
NotSupportedError. That's the case on desktop Chrome, where it "always throwsNotSupportedError" according to MDN. Safari doesn't support locking at all, so feature-detect the method before calling it. - Pre-lock conditions. The spec says a browser "MUST restrict the use of
lock()to simple fullscreen documents", and "SHOULD require installed web applications to be presented in the 'fullscreen' display mode". The spec rejects withNotAllowedErrorwhen the conditions aren't met. - Supersession. Another
lock()call, orunlock(), rejects a pending promise withAbortError.
Chromium's implementation on Android checks for fullscreen in the browser process. lock() succeeds if the page is in element fullscreen or the app's display mode is fullscreen. A standalone WebAPK therefore still has to call requestFullscreen() first. When the condition fails, Chromium rejects with a SecurityError (not the spec's newer NotAllowedError) and the message The page needs to be fullscreen in order to call screen.orientation.lock(). Exiting fullscreen fully unlocks the orientation. unlock() returns the screen to the default orientation, which is the manifest's orientation for an installed app. Firefox 144 added lock() and unlock() on Android and on Windows tablets.
A landscape game mode that degrades gracefully:
/**
* Enter a landscape "game mode". Must be called from a user gesture
* (click/keydown), because requestFullscreen() needs transient activation.
* Resolves to true if the orientation is locked, false if the caller
* should show a "rotate your device" hint instead.
*/
export async function enterLandscapeMode(element = document.documentElement) {
const orientation = screen.orientation;
if (!orientation || typeof orientation.lock !== "function") return false;
// Chromium only allows lock() in element fullscreen or the fullscreen
// display mode, so go fullscreen first unless the app already is.
// Boolean(): fullscreenElement is undefined (not null) where the Fullscreen API is missing.
const alreadyFullscreen =
Boolean(document.fullscreenElement) ||
window.matchMedia("(display-mode: fullscreen)").matches;
if (!alreadyFullscreen && element.requestFullscreen) {
try {
await element.requestFullscreen({ navigationUI: "hide" });
} catch (error) {
console.warn("Fullscreen request rejected:", error.name);
return false; // no activation, or fullscreen disallowed (iframe, policy)
}
}
try {
await orientation.lock("landscape");
return true;
} catch (error) {
switch (error.name) {
case "NotSupportedError": // desktop browsers, Safari
case "SecurityError": // Chromium: not fullscreen; also hidden or sandboxed documents
case "NotAllowedError": // spec: pre-lock conditions not met
case "InvalidStateError": // document not fully active or without user attention
return false;
case "AbortError": // a later lock()/unlock() superseded this call
return orientation.type.startsWith("landscape");
default:
throw error;
}
}
}
export async function exitLandscapeMode() {
try {
screen.orientation?.unlock?.();
} catch {
// unlock() throws where locking is unsupported; nothing to undo.
}
if (document.fullscreenElement) await document.exitFullscreen();
}
Pair it with a CSS fallback for platforms that can't lock:
.rotate-hint {
display: none;
}
/* Shown only while the game view is active and the device is upright. */
@media (orientation: portrait) {
.game-active .rotate-hint {
display: grid;
place-items: center;
position: fixed;
inset: 0;
}
}
Adapting your UI to standalone windows¶
Removing the browser UI also removes the features that lived in it. This table lists what disappears in app-like modes and what you should provide instead:
| Browser feature | Desktop Chromium standalone | Chrome on Android standalone | iOS/iPadOS web app | What to provide |
|---|---|---|---|---|
| Back button | ❌ (keyboard shortcut only) | ✅ system back | ❌ | In-app back button in app-like modes |
| Reload | ❌ (keyboard shortcut only) | ⚠️ pull-to-refresh | ❌ | Refresh control where content can go stale |
| Address bar / current URL | ⚠️ Copy URL in the app menu | ❌ | ❌ | Copy link / Share action |
| Share | ⚠️ varies by version and OS | ❌ | ❌ | navigator.share() button |
| Open in browser | ✅ app menu | ⚠️ varies | ❌ | Rarely needed; link to the web version if relevant |
| Tab title | Window title | Recents entry | App switcher shows the app name | Meaningful document.title / application-title |
minimal-ui keeps back and reload on desktop Chromium, so hide your own copies in that mode.
Back navigation¶
Only show an in-app back button where the platform lacks one, and only when there is somewhere to go back to. The Navigation API exposes navigation.canGoBack, which is accurate for the current navigable. It's available in Chrome 102, Firefox 147 and Safari 26.2. On older browsers, history.length > 1 is a rough fallback: it also counts entries you can't go back to (forward entries, entries from before the app was launched in the same tab).
import { getDisplayMode, onDisplayModeChange } from "./display-mode.js";
// Modes where the platform shows no back button of its own.
const NEEDS_IN_APP_BACK = new Set([
"standalone",
"fullscreen",
"window-controls-overlay",
"unframed",
]);
function canGoBack() {
if (window.navigation && typeof window.navigation.canGoBack === "boolean") {
return window.navigation.canGoBack;
}
return history.length > 1; // coarse fallback for browsers without the Navigation API
}
export function initBackButton(button) {
const update = () => {
const mode = getDisplayMode();
const isIOSWebApp = navigator.standalone === true; // iOS reports its mode inconsistently
button.hidden = !((NEEDS_IN_APP_BACK.has(mode) || isIOSWebApp) && canGoBack());
};
button.addEventListener("click", () => history.back());
onDisplayModeChange(update);
if (window.navigation) {
window.navigation.addEventListener("currententrychange", update);
} else {
window.addEventListener("popstate", update);
window.addEventListener("pageshow", update);
}
update();
}
On Android, the system back gesture already walks history. Handle it like any other navigation: close open dialogs and sheets on popstate, or with the navigate event of the Navigation API, instead of letting back leave the app unexpectedly. App-Like UX Patterns covers history management for modals and drawers.
Reload and pull-to-refresh¶
Chrome on Android keeps pull-to-refresh in standalone apps. If your app has its own scrolling panes or gestures, disable the browser's version with overscroll-behavior-y: contain on the scrolling element, or on body for the document. Then offer your own refresh where data can go stale, for example a button, or a gesture implemented with pointer events. In iOS web apps and desktop standalone windows there is no visible reload control at all. A stuck app without one forces users to kill and relaunch it, so make error states include a Try again action.
When a new service worker version is waiting, "reload" also means "apply the update". Combine your refresh control with the update flow from Updating Service Workers.
Sharing and copying the current URL¶
Without an address bar, users can't copy the URL of what they're looking at. Offer a share action that uses the Web Share API where it exists and falls back to the clipboard. navigator.share() is available in Safari 12.1+, Chrome on Android, Chrome on desktop (Windows and ChromeOS since 89; MDN lists full desktop support from Chrome 128) and Firefox for Android. It requires transient user activation.
/**
* Share the current page, or copy its URL where sharing isn't available.
* Call from a click handler: share() needs transient user activation, and
* Safari also requires a user gesture for clipboard writes.
* @returns {Promise<"shared" | "copied" | "cancelled" | "failed">}
*/
export async function shareCurrentPage() {
// Prefer the canonical URL: in-app URLs may carry state (?source=pwa, filters).
const canonical = document.querySelector('link[rel="canonical"]')?.href;
const data = { title: document.title, url: canonical || location.href };
if (navigator.canShare?.(data)) {
try {
await navigator.share(data);
return "shared";
} catch (error) {
if (error.name === "AbortError") return "cancelled"; // user closed the sheet
// NotAllowedError (no activation, permissions policy) or others: fall back.
}
}
try {
await navigator.clipboard.writeText(data.url);
return "copied";
} catch {
return "failed"; // clipboard blocked; show the URL in a dialog as a last resort
}
}
The full API, including files and permission policy, is on Web Share API.
External links¶
In a standalone window, a link to another site behaves according to the out-of-scope rules above. That's easy to miss, because nothing indicates that a link leaves the app. Conventions that work well:
- Give external links
target="_blank"andrel="noopener". On desktop Chromium they open in a browser tab, which is what users expect for "the web". On iOS they open in the in-app Safari view either way. - Mark external links visually (an icon, or "opens in browser" text for screen readers).
- Don't
target="_blank"your own in-scope links. On desktop Chromium, that can open a second app window, which feels broken in a single-window app.
The complete module at the end of this section implements this automatically for links added at runtime.
Status bar, title bar, theme color and safe areas¶
theme_colorin the manifest sets the default color of the Android status bar, the desktop title bar and the macOS Safari toolbar. The page's<meta name="theme-color">overrides it at runtime and can change per page or state. The HTMLmediaattribute lets you provide separate light and dark values. MDN's compatibility data notes that desktop Chrome uses the color only in installed apps, and that from Safari 26 the theme color is only used for installed web apps.- Contrast is automatic. Chromium chooses light or dark title bar text and icons based on the color. Test your brand color in both schemes.
- Edge to edge. For content under the iOS status bar (
black-translucent) or under an Android display cutout infullscreen, addviewport-fit=coverto the viewport meta tag and pad critical content withenv(safe-area-inset-top)and its siblings.
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b57d0" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0b1d3a" media="(prefers-color-scheme: dark)">
<!-- iOS/iPadOS web apps only: let content run under a translucent status bar. -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Chromium desktop app windows: short, per-view window title (Chrome 134+). -->
<meta name="application-title" content="Inbox">
.app-header {
/* max() keeps a minimum padding where the inset is 0 (most desktops). */
padding-top: max(12px, env(safe-area-inset-top));
padding-left: max(16px, env(safe-area-inset-left));
padding-right: max(16px, env(safe-area-inset-right));
}
.app-footer {
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
Splash screens, background_color, dark-mode theme colors and the iOS startup images are covered in Splash Screens & Theming.
A complete adaptive UI module¶
The module below ties everything together. It exposes the current mode as a data-display-mode attribute on <html>, keeps it current, wires up back and share buttons, and marks external links. It has no dependencies and works as a native ES module.
// Detect the applied display mode and adapt app chrome to it.
// Usage: <script type="module" src="/js/display-mode.js"></script>
const DISPLAY_MODES = [
"picture-in-picture",
"unframed",
"window-controls-overlay",
"tabbed",
"fullscreen",
"standalone",
"minimal-ui",
"browser",
];
// Modes in which the browser shows no back button of its own.
const NEEDS_IN_APP_BACK = new Set([
"standalone",
"fullscreen",
"window-controls-overlay",
"unframed",
]);
// Must match the manifest's "scope" (resolved against the manifest URL).
const APP_SCOPE = new URL("/", location.origin);
export function getDisplayMode() {
for (const mode of DISPLAY_MODES) {
if (window.matchMedia(`(display-mode: ${mode})`).matches) return mode;
}
return "browser";
}
export function onDisplayModeChange(callback, { signal } = {}) {
for (const mode of DISPLAY_MODES) {
const list = window.matchMedia(`(display-mode: ${mode})`);
const handler = (event) => {
if (event.matches) callback(getDisplayMode());
};
if (typeof list.addEventListener === "function") {
list.addEventListener("change", handler, { signal });
} else {
list.addListener(handler); // Safari < 14
signal?.addEventListener("abort", () => list.removeListener(handler), {
once: true,
});
}
}
}
/** Manifest scope matching: same origin and a path prefix. */
export function isWithinScope(url) {
const target = new URL(url, location.href);
return (
target.origin === APP_SCOPE.origin &&
target.pathname.startsWith(APP_SCOPE.pathname)
);
}
function isAppLike(mode) {
return navigator.standalone === true || (mode !== "browser" && mode !== "picture-in-picture");
}
function canGoBack() {
if (window.navigation && typeof window.navigation.canGoBack === "boolean") {
return window.navigation.canGoBack;
}
return history.length > 1;
}
function updateChrome() {
const mode = getDisplayMode();
const root = document.documentElement;
root.dataset.displayMode = mode;
root.classList.toggle("is-app", isAppLike(mode));
const back = document.querySelector("[data-action='back']");
if (back) {
const needsBack = NEEDS_IN_APP_BACK.has(mode) || navigator.standalone === true;
back.hidden = !(needsBack && canGoBack());
}
}
function markExternalLinks(root = document) {
// querySelectorAll() skips the root itself, so check an added <a> element directly.
const links = [...root.querySelectorAll("a[href]")];
if (root instanceof Element && root.matches("a[href]")) links.push(root);
for (const link of links) {
let url;
try {
url = new URL(link.href);
} catch {
continue; // unparsable href: leave it alone
}
if (!url.protocol.startsWith("http")) continue; // mailto:, tel:, etc.
if (isWithinScope(url)) continue;
link.target = "_blank";
// Keep existing rel tokens (e.g. "nofollow") and add the safe defaults.
link.relList.add("noopener", "external");
}
}
async function share(button) {
const canonical = document.querySelector('link[rel="canonical"]')?.href;
const data = { title: document.title, url: canonical || location.href };
if (navigator.canShare?.(data)) {
try {
await navigator.share(data);
return;
} catch (error) {
if (error.name === "AbortError") return;
}
}
try {
await navigator.clipboard.writeText(data.url);
button.dataset.state = "copied"; // CSS can show a "Link copied" hint
setTimeout(() => delete button.dataset.state, 2000);
} catch {
window.prompt("Copy this link:", data.url); // last resort
}
}
function init() {
updateChrome();
onDisplayModeChange(updateChrome);
if (window.navigation) {
window.navigation.addEventListener("currententrychange", updateChrome);
} else {
window.addEventListener("popstate", updateChrome);
}
window.addEventListener("pageshow", updateChrome); // back/forward cache restores
document.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const actionEl = target?.closest("[data-action]");
if (!actionEl) return;
if (actionEl.dataset.action === "back") history.back();
if (actionEl.dataset.action === "share") share(actionEl);
});
markExternalLinks();
// Links rendered later (client-side routing) are handled as they appear.
new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node instanceof Element) markExternalLinks(node);
}
}
}).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
/* Default (browser tab): no app chrome. */
[data-action="back"],
.app-only {
display: none;
}
/* The attribute is set by display-mode.js; the media queries cover
the first paint before the script runs. */
@media (display-mode: standalone), (display-mode: fullscreen),
(display-mode: minimal-ui), (display-mode: window-controls-overlay) {
.app-only {
display: revert;
}
.browser-only {
display: none;
}
}
html.is-app .app-only {
display: revert;
}
html.is-app .browser-only {
display: none;
}
/* The script controls visibility with the hidden attribute. */
html.is-app [data-action="back"]:not([hidden]) {
display: inline-flex;
}
a[rel~="external"]::after {
content: " \2197"; /* north-east arrow; pair it with visually hidden text for screen readers */
}
[data-action="share"][data-state="copied"]::after {
content: " Link copied";
}
<header class="app-header">
<button type="button" data-action="back" hidden aria-label="Back">←</button>
<h1 class="app-title">Field Notes</h1>
<button type="button" data-action="share" class="app-only">Share</button>
<a class="browser-only install-promo" href="/install">Install the app</a>
</header>
The .install-promo link is only one piece of an install flow. The prompt logic itself is in Install Prompts & Custom UI.
Debugging display modes¶
- Chrome/Edge DevTools → Application → Manifest shows the parsed
displayand the manifest parser's warnings (unknown 'display' value ignored.,inapplicable 'display' value ignored.,property 'display_override' ignored, type array expected.). The Installability section explainsmanifest-display-not-supportedandmanifest-display-override-not-supported. - Ask the page. Evaluate
getDisplayMode()ormatchMedia("(display-mode: standalone)").matchesin the console of the app window. For an installed desktop app, open DevTools inside the app window (Ctrl+Shift+I / Cmd+Option+I). In a tab you only ever seebrowser. chrome://web-app-internalslists every installed app with its stored display mode,display_overrideand user display mode, which tells you whether the user moved the app into a tab.- Reinstall after changing
display. Desktop Chromium picks up manifest changes through its update check, but Android bakes the mode into the WebAPK. See App Identity & Updates. - Android: debug the WebAPK over USB from
chrome://inspect. The device's own window is the only place to check immersive mode, the status bar color and the out-of-scope toolbar. - iOS and iPadOS: add the site to the Home Screen in the Simulator or on a device, then attach Safari's Web Inspector through the Develop menu. Check
navigator.standaloneand thedisplay-moderesult side by side. - Firefox on Windows: pin the site to the taskbar and confirm
(display-mode: minimal-ui)matches.
The Browser DevTools page walks through these panels. Automated Testing shows how to run tests against installed-app contexts.
Browser support¶
Support data as of September 2026. For live data, see MDN: display, MDN: display_override, MDN: display-mode and caniuse.
| Feature | Chrome / Edge (desktop) | Chrome (Android) | Safari (macOS) | Safari (iOS / iPadOS) | Firefox (desktop) | Firefox (Android) |
|---|---|---|---|---|---|---|
Manifest display | ✅ 39 ⚠️ | ✅ 39 | ✅ 17 ⚠️ | ✅ 11.3 ⚠️ | ⚠️ | ✅ 47 |
display_override | ✅ 89 | ✅ 89 ⚠️ | ❌ | ❌ | ❌ | ❌ |
window-controls-overlay | ✅ 105 | ❌ | ❌ | ❌ | ❌ | ❌ |
tabbed | ⚠️ ChromeOS only | ❌ | ❌ | ❌ | ❌ | ❌ |
unframed | ⚠️ 152 (ChromeOS IWAs only) | ❌ | ❌ | ❌ | ❌ | ❌ |
display-mode media feature | ✅ 42 | ✅ 42 | ✅ 13 ⚠️ | ✅ 12.2 ⚠️ | ✅ 47 ⚠️ | ✅ 116 |
display-mode: picture-in-picture | ✅ 123 | ❌ | ❌ | ❌ | ✅ 151 | ⚠️ 151 |
Manifest orientation | ⚠️ parsed, not applied | ✅ 39 | ❌ | ❌ | ❌ | ✅ 79 |
screen.orientation.lock() | ❌ | ✅ 38 (fullscreen required) | ❌ | ❌ | ⚠️ 144 (Windows tablets) | ✅ 144 |
⚠️ Desktop Chromium resolves fullscreen to standalone and browser to minimal-ui for app windows. Chrome on Android uses only the standard modes from display_override. Safari has no fullscreen or minimal-ui presentation. Its display-mode reflects the manifest value rather than the window (see How each engine computes the value). iOS 26 opens every Home Screen site as a web app by default. Firefox desktop has no manifest installation. Its Windows taskbar web apps (143+) report minimal-ui, and standalone never matches. Firefox for Android 151 recognizes the picture-in-picture value in media queries, but it has no Document Picture-in-Picture API, so the query only matters on desktop. MDN lists manifest orientation for desktop Chrome and Edge because the parser is shared, but desktop app windows don't apply it.
Common pitfalls¶
Putting an extended mode in display. "display": "window-controls-overlay" is dropped with inapplicable 'display' value ignored., the mode falls back to browser, and the app stops being installable. Extended modes belong in display_override, with a standard mode in display.
Starting display_override with browser. Chromium uses the first recognized override for its installability check, so ["browser", "standalone"] fails with manifest-display-override-not-supported.
Expecting fullscreen on desktop. Desktop Chromium opens a standalone window. Use the Fullscreen API from a user gesture.
Using display-mode: standalone as the only installed-app test. It misses minimal-ui, window-controls-overlay and tabbed windows, it's unreliable on iOS, and it becomes false when the user moves your app into a tab. Combine signals, as in Detecting an installed launch robustly.
Reading the mode once at startup. The mode changes at runtime (open in app, open in browser, fullscreen, the WCO toggle). Subscribe to changes.
Hiding the only back button. A standalone app with no in-app navigation strands iOS and desktop users on deep pages. Test every flow in a real app window, not in a tab.
Opening in-scope pages with target="_blank". On desktop Chromium this can spawn extra app windows.
Relying on orientation for layout. Desktop, Safari, split-screen and large Android screens ignore it. Build responsive layouts, and lock only for experiences that truly need it, from fullscreen.
Forgetting theme_color contrast and safe areas. black-translucent on iOS or fullscreen on Android without viewport-fit=cover and safe-area padding puts controls under the status bar or the camera cutout.
Changing display and expecting instant results. Installed Android apps keep the old mode until the WebAPK is updated, and iOS keeps whatever it captured when the app was added.
Further reading¶
On this site
- Members Reference: every manifest member, including
scope,theme_colorandorientation - Advanced & Integration Members:
tab_strip,launch_handlerandscope_extensions - Window Controls Overlay: the complete title bar API
- Installability Criteria: how the display mode feeds Chromium's install checks
- Detecting Installed Apps: every technique for recognizing an installed launch
- App-Like UX Patterns: navigation, gestures and history in app windows
- Splash Screens & Theming:
theme_color,background_colorand iOS status bars - iOS & iPadOS: Home Screen web apps in detail
External references
- Web Application Manifest: display modes,
display,orientationand navigation scope - Manifest Incubations:
display_override,tabbedandunframed - Media Queries Level 5: the
display-modemedia feature - Screen Orientation specification
- MDN:
display-mode - MDN:
display_override - MDN:
ScreenOrientation.lock() - Chrome for Developers: Preparing for the display modes of tomorrow (
display_override) - Chrome for Developers: Tabbed application mode
- web.dev Learn PWA: App design (display modes and standalone UX)
- WebKit: WebKit features in Safari 26.0 (every site can be a web app)
- WebKit: WebKit features in Safari 17.0 (web apps on Mac)
- Apple WWDC23: What's new in web apps
- Apple: Supported meta tags (
apple-mobile-web-app-status-bar-style) - MSEdgeExplainers: Installed web app window title (
application-title)