Window Controls Overlay¶
Window Controls Overlay (WCO) lets an installed desktop PWA draw its own content in the area the operating system normally reserves for the title bar. The browser removes the title bar, keeps only the minimize, maximize and close buttons (plus its own app menu) as a small overlay in one corner, and tells your page exactly where the free space is through navigator.windowControlsOverlay and four CSS environment variables. It is the web equivalent of the custom title bars in VS Code, Teams or Spotify, and it is available in Chromium-based browsers on Windows, macOS, Linux and ChromeOS.
Key takeaways
- Opt in with
"display_override": ["window-controls-overlay"]and keep a normaldisplayvalue as the fallback. Only Chromium desktop browsers (Chrome and Edge 105+, Opera 91+) act on it. Safari, Firefox and every mobile browser ignore it. - The manifest only makes the overlay possible. In stable Chromium the window opens with the normal title bar, and the user turns the overlay on with the chevron button ("Hide title bar"). The choice is stored per app.
navigator.windowControlsOverlay.visible,getTitlebarAreaRect()and thegeometrychangeevent give you the free title bar area in CSS pixels. Theenv(titlebar-area-x|y|width|height)variables carry the same rectangle into CSS, and they are undefined while the overlay is off, so yourenv()fallbacks apply.- Mark the title bar as a window drag handle with
app-region: drag(Chromium also accepts-webkit-app-region) and every interactive control inside it withapp-region: no-drag. Chrome 152 added the standardized name,window-drag: move | none, and turnedapp-regioninto an alias of it. - Draggable regions swallow all pointer events. Buttons, inputs and links inside a drag region don't work unless you exclude them.
- The overlay's background is your theme color. Keep
<meta name="theme-color">in sync with the color of your custom title bar, including in dark mode. - Chromium suspends the overlay when an out-of-scope page is shown, when an infobar is visible, in immersive mode and in macOS fullscreen. Design the same header to work as an ordinary in-page toolbar.
What the overlay changes in an app window¶
In a regular standalone app window, Chromium draws a title bar above your page. It contains the app icon, the window title, an app menu (the "three dots" menu with Copy URL, Open in Chrome, site settings and so on), and the operating system's window controls. Your page begins below it, and you can influence the title bar only through its color.
With Window Controls Overlay enabled, the browser makes the window frameless at the top. Your page's viewport extends all the way to the top edge of the window. What remains of the title bar is the overlay: the window controls, the app menu button, the WCO toggle and any transient browser UI, drawn on top of your content in the top-right corner (top-left in right-to-left locales), and on macOS split between the traffic lights on the left and the browser buttons on the right.
The part of the old title bar that is not covered by the overlay is the title bar area. That is the rectangle the API reports and where your custom title bar goes.
flowchart TB
subgraph S["standalone window"]
direction TB
S1["OS title bar: icon, title, app menu, window controls"]
S2["Your viewport starts here"]
S1 --> S2
end
subgraph W["window-controls-overlay window"]
direction TB
W1["Title bar area: yours to draw in"]
W2["Overlay: app menu, toggle, window controls"]
W3["Your viewport starts at the very top"]
W1 --- W2
W1 --> W3
end The coordinate system does not move¶
The overlay does not shrink or offset your viewport. The WCO explainer spells this out, and Chromium implements it that way:
(0, 0)is still the top-left corner of the viewport, and it lies under the overlay when the controls are on the left (macOS, RTL locales).window.innerHeightincludes the strip under the title bar. On platforms without window borders,innerHeightequalsouterHeight.vh,vw,dvhand friends still measure the full viewport. Aheight: 100vhlayout now extends under the overlay.position: fixed; top: 0elements land in the title bar strip, partly hidden by the overlay.
That last point is why most WCO bugs are layout bugs. Content that used to sit safely below the browser's title bar now starts at the top edge of the window, and anything in the top-right corner (a close button in a modal, a toast, a "Sign in" button) can end up under the minimize and close buttons.
What the overlay contains on each platform¶
The overlay's size and position are chosen by the browser and the OS, and they change at run time. Don't hard-code any of them.
| Platform | Window controls | Browser controls in the overlay | Typical title bar area |
|---|---|---|---|
| Windows | Minimize, maximize/restore, close on the right (left in RTL locales) | App menu and WCO toggle next to the window controls | Starts at x = 0, ends before the overlay |
| macOS | Traffic lights on the left | App menu and toggle on the right | Starts after the traffic lights, ends before the right-hand buttons, so x > 0 |
| Linux | Position follows the desktop environment's button layout | App menu and toggle next to the window controls | Depends on the layout |
| ChromeOS | On the right | App menu and toggle next to the window controls | Starts at x = 0 |
The explainer lists transient content that can appear in the overlay and resize it: the page's origin, shown for a few seconds when the app launches, and an extension's icon while the user interacts with it through the app menu. Each time that happens, the overlay grows or shrinks, the title bar area changes, and a geometrychange event fires. The rectangle also changes when the user resizes the window, changes the page zoom, or moves the window to a monitor with a different scale factor.
Opting in with display_override¶
WCO is declared in the manifest. window-controls-overlay isn't a valid value of display; it is a display mode extension defined in the WICG Manifest Incubations draft that can only appear in display_override:
{
"id": "/",
"name": "Ledger Notes",
"short_name": "Ledger",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay"],
"theme_color": "#1f3a5f",
"background_color": "#ffffff",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
The browser walks display_override in order and takes the first mode it supports, then falls back to display and its standard fallback chain. The consequences for each engine:
| Browser | What it does with this manifest |
|---|---|
| Chrome, Edge, Opera on Windows, macOS, Linux, ChromeOS | Resolves the app's display mode to window-controls-overlay. The window opens with a title bar and a toggle; the overlay is active once the user enables it |
| Chrome on Android | Skips window-controls-overlay (only the four standard modes count on Android) and uses standalone |
| Safari (macOS Dock apps, iOS and iPadOS Home Screen apps) | Doesn't support display_override; uses display |
| Firefox | Doesn't support display_override; uses display where it installs apps at all |
Always keep a meaningful display value. display_override without display is technically allowed by Chromium for installability, but every non-Chromium engine then falls back to browser. The full algorithm, including how Chromium maps entries it can't show in a desktop window, is on Display Modes.
You can combine WCO with other extension modes. Chromium honors only the first supported entry, so the order is your priority list:
{
"display_override": ["tabbed", "window-controls-overlay", "minimal-ui"],
"display": "standalone"
}
On ChromeOS, where tabbed mode has shipped, this app gets a tab strip. On Windows, macOS and Linux, where tabbed needs a flag, it gets WCO. On Android it gets minimal-ui, and in Safari and Firefox it gets standalone.
Adding WCO to an app that is already installed¶
display_override is one of the manifest members that desktop Chromium updates silently. From Chrome 144, Chromium checks for manifest changes whenever a page that links the manifest loads, and applies display_override changes immediately, without a prompt (see App Identity & Updates). Existing users therefore get the WCO toggle on a later launch without reinstalling. The overlay itself still starts disabled for them, like for new installs.
The user's toggle and the default state¶
The part of WCO that surprises developers most is who decides whether the overlay is shown. Declaring it in the manifest doesn't hide the title bar. Chromium's source is explicit about the default behavior: WCO requires "both manifest support and user enablement".
When an app with window-controls-overlay in its manifest opens, Chromium shows the standard title bar with an extra chevron button next to the app menu. Its tooltip reads Hide title bar. Clicking it enables the overlay, the chevron flips, and its tooltip becomes Show title bar. Chromium also announces the change to assistive technology with the text "Title bar is now hidden" or "Title bar is now showing".
A few implementation details follow from Chromium's code:
- The default is off. The per-app flag
window_controls_overlay_enabledstarts asfalsein Chromium's web app database. - The choice is per app and persistent. Toggling writes the new value to the web app database, so the next window of the same app opens in the state the user chose last. You can see the stored value in
chrome://web-app-internalsunder the app's entry. - There is no API to toggle it. Your page can't enable or disable the overlay, and it can't find out whether the user has never enabled it or has disabled it again. It can only read the current state.
- A flag removes the toggle. Chromium has a feature,
DesktopPWAsWindowControlsOverlayWithNoToggle, that removes the toggle button and enables WCO automatically for apps that declare it. It is disabled by default, so don't design for it.
When Chromium suspends the overlay¶
Even with the user's toggle on, the overlay is shown only when it is available for the window. Chromium's BrowserView::UpdateWindowControlsOverlayAvailable() makes it unavailable when:
- An out-of-scope page is showing. When the user follows a link outside the app's scope, Chromium displays its origin bar (the "custom tab bar" with a close button, a security indicator and the origin). The explainer requires the overlay to be replaced by the standard title bar in that case, so the user can see where they are. When the user returns to the app's scope, the overlay comes back.
- An infobar is visible. A non-empty infobar container disables the overlay while it is shown.
- The window is in immersive mode, such as ChromeOS immersive fullscreen.
- The window is fullscreen on macOS. In macOS fullscreen, the menu bar and title bar slide down over the content from a separate view that web content can't reach, so Chromium turns the overlay off.
stateDiagram-v2
[*] --> TitleBar: app launches with WCO declared
TitleBar --> Overlay: user clicks Hide title bar
Overlay --> TitleBar: user clicks Show title bar
Overlay --> Suspended: out of scope page or infobar or immersive or macOS fullscreen
Suspended --> Overlay: condition ends
note right of TitleBar
display-mode is standalone
visible is false
env titlebar-area values undefined
end note
note right of Overlay
display-mode is window-controls-overlay
visible is true
env titlebar-area values set
end note From your page's point of view, "user turned it off" and "Chromium suspended it" look identical: visible becomes false, the environment variables disappear, and geometrychange fires. That is why a WCO layout has to be a progressive enhancement over a layout that works under a normal title bar.
The JavaScript API: navigator.windowControlsOverlay¶
Interface definitions¶
The WCO specification is a WICG draft. Its IDL:
[SecureContext, Exposed=(Window)]
partial interface Navigator {
[SameObject] readonly attribute WindowControlsOverlay windowControlsOverlay;
};
[Exposed=Window]
interface WindowControlsOverlay : EventTarget {
readonly attribute boolean visible;
DOMRect getTitlebarAreaRect();
attribute EventHandler ongeometrychange;
};
[Exposed=Window]
interface WindowControlsOverlayGeometryChangeEvent : Event {
constructor(DOMString type, WindowControlsOverlayGeometryChangeEventInit eventInitDict);
[SameObject] readonly attribute DOMRect titlebarAreaRect;
readonly attribute boolean visible;
};
dictionary WindowControlsOverlayGeometryChangeEventInit : EventInit {
required DOMRect titlebarAreaRect;
boolean visible = false;
};
Chromium's implementation differs from the draft in small ways that are worth knowing:
| Detail | WICG draft | Chromium (Blink IDL and C++) |
|---|---|---|
[SecureContext] on navigator.windowControlsOverlay | Yes | Not in Blink's IDL. Installed apps are HTTPS (or localhost) anyway |
| Platform or runtime-flag gating | n/a | None: the interface exists in every Chromium context, including normal tabs and Android |
visible in the event init dictionary | Optional, default false | required |
| Event target and name | Algorithm text says to fire ongeometrychange at the Window (an editorial slip) | Fires a geometrychange event at the WindowControlsOverlay object |
| When the event fires | When the overlay's width or height changes | When the title bar area rectangle, in CSS pixels, differs from the previous one, including when it becomes empty |
visible¶
navigator.windowControlsOverlay.visible is true while the overlay is shown and the title bar area is non-empty, and false otherwise. Chromium computes it as "the title bar area rectangle is not empty". It is false in:
- a browser tab, even for an installed app opened in a tab;
- an app window while the title bar is showing (toggle off or overlay suspended);
- any iframe, including same-origin iframes inside an app window whose overlay is visible (the explainer excludes iframes for privacy, and Chromium only sends the geometry to the main frame);
- Chrome on Android and every non-desktop context.
getTitlebarAreaRect()¶
getTitlebarAreaRect() returns a new DOMRect describing the title bar area in CSS pixels, relative to the top-left corner of the viewport. When the overlay isn't visible, it returns an empty rect: x, y, width and height are all 0.
The rectangle excludes the overlay. If the overlay is a single region (Windows, ChromeOS, most Linux layouts), the area runs from the window edge to the inner edge of the overlay. If the overlay is split into two regions (macOS), the area is the space between them. The height matches the height of the overlay.
Chromium receives the rectangle from the browser process in device-independent pixels (DIPs) and converts it to CSS pixels in LocalFrame::UpdateWindowControlsOverlay(). Two details of that conversion matter:
- Page zoom changes the numbers. The rectangle is divided by the page's zoom factor. At 200% zoom, the same physical title bar strip is half as many CSS pixels tall. The value changes, and
geometrychangefires, whenever the user zooms. - The rect is rounded outward. Chromium uses an enclosing integer rectangle, preferring "a rect that is slightly larger than one that would render smaller than the window control overlay", so values are integers and can be a pixel larger than the exact fraction.
const wco = navigator.windowControlsOverlay;
if (wco?.visible) {
const { x, y, width, height } = wco.getTitlebarAreaRect();
// Space to the right of the title bar area is covered by the overlay
// (Windows, ChromeOS); space to the left is covered on macOS or in RTL.
const overlayLeft = x;
const overlayRight = window.innerWidth - (x + width);
console.log({ x, y, width, height, overlayLeft, overlayRight });
}
On Windows you typically see x: 0, y: 0 and a width equal to the window width minus the overlay. On macOS x is the width of the traffic-light area. Take the values from the API, never from a table of platform constants: they also depend on the OS text scale, the OS zoom factor and the browser's UI.
The geometrychange event¶
geometrychange fires on navigator.windowControlsOverlay whenever the title bar area rectangle changes. The event is a WindowControlsOverlayGeometryChangeEvent with two extra properties:
| Property | Type | Meaning |
|---|---|---|
titlebarAreaRect | DOMRect | The new title bar area, same format as getTitlebarAreaRect() |
visible | boolean | Whether the overlay is visible after the change (false when the rect is empty) |
The event doesn't bubble and isn't cancelable. You can listen with addEventListener("geometrychange", …) or by assigning ongeometrychange.
Things that trigger it:
- the user enables the overlay (
visiblebecomestrue); - the user disables it, or Chromium suspends it (
visiblebecomesfalse, the rect becomes0 × 0); - the window is resized, maximized, restored or snapped, which changes the width;
- the page zoom changes, which changes every value in CSS pixels;
- browser UI appears in or disappears from the overlay (the launch-time origin text, an extension icon);
- the window moves to a display with a different scale factor, if that changes the CSS-pixel values.
Does geometrychange fire when the overlay is hidden?
web.dev's WCO article states that the event "will only fire when the window controls overlay is visible". Current Chromium source behaves differently: when the overlay is disabled, the browser sends an empty rectangle, LocalFrame::UpdateWindowControlsOverlay() sees a changed rect, and WindowControlsOverlay::WindowControlsOverlayChanged() dispatches a geometrychange event with visible: false. Write handlers that work either way: treat event.visible === false as "fall back to the normal layout", and also listen for the display-mode media query change, which fires in both cases.
What does not trigger it:
- Page load. The rectangle is stored on the frame and carried across navigations (so the environment variables are correct before first paint), and the event only fires on a change. A page that loads while the overlay is already visible receives no initial event. Always read
visibleandgetTitlebarAreaRect()once at startup, then subscribe. - Changes in iframes. Only the top-level document gets geometry.
During a window resize, geometrychange fires as often as the browser relays new window sizes, comparable to a resize listener. The web.dev and Microsoft Edge articles recommend debouncing it. Layout that the CSS environment variables can handle needs no JavaScript at all. For the remaining work, such as measuring whether toolbar items fit or updating a state attribute, coalescing to one run per animation frame keeps the UI in sync without the lag a 200 ms debounce adds:
/**
* Runs `fn` at most once per animation frame with the latest arguments.
* Unlike a timeout-based debounce, the final state is applied on the very
* next frame, so the title bar never visibly lags behind the window edge.
*/
export function coalesceToFrame(fn) {
let frame = 0;
let lastArgs = [];
return (...args) => {
lastArgs = args;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
fn(...lastArgs);
});
};
}
Feature detection: "exists" is not "active"¶
Because Chromium exposes the interface everywhere, "windowControlsOverlay" in navigator is true in Chrome on Android and in an ordinary Chrome tab. It tells you that the engine implements the API, not that the overlay can appear. Distinguish three questions:
| Question | How to answer it |
|---|---|
| Does this engine implement the API? | "windowControlsOverlay" in navigator |
| Is the overlay visible right now? | navigator.windowControlsOverlay?.visible === true, or matchMedia("(display-mode: window-controls-overlay)").matches |
| Could the user turn it on in this window? | Not directly observable. A reasonable heuristic: the app runs in standalone display mode, the API exists, navigator.userAgentData?.mobile === false, and your manifest declares WCO |
The display-mode media feature matches window-controls-overlay only while the overlay is active. When the title bar is showing, the same window matches standalone. See Display Modes for how Chromium computes the value.
The title bar area environment variables¶
WCO adds four user-agent environment variables for use with env():
| Variable | Value while the overlay is visible |
|---|---|
titlebar-area-x | Distance from the left edge of the viewport to the title bar area, in px |
titlebar-area-y | Distance from the top edge of the viewport to the title bar area, in px (normally 0px) |
titlebar-area-width | Width of the title bar area, in px |
titlebar-area-height | Height of the title bar area, which is also the height of the overlay, in px |
They carry exactly the rectangle getTitlebarAreaRect() returns, formatted as px lengths, and they update in the same step that dispatches geometrychange. Because CSS reacts to them without JavaScript, they are the primary tool: the explainer notes that script-driven layout is "not as responsive as a CSS solution" when the overlay resizes.
The most important behavior is what happens when the overlay is not visible. Chromium doesn't set the variables to zero. It removes them from the document's environment. An env() reference to an undefined variable uses its fallback value, or makes the declaration invalid at computed-value time if there is no fallback. So:
.titlebar {
/* Overlay visible: the reported rectangle.
Overlay hidden, other modes, other browsers: the fallbacks. */
left: env(titlebar-area-x, 0);
top: env(titlebar-area-y, 0);
width: env(titlebar-area-width, 100%);
height: env(titlebar-area-height, 40px);
}
Some consequences of the removal behavior:
- Every
env(titlebar-area-*)needs a fallback. Without one, the declaration becomes invalid at computed-value time, which resets the property to its inherited or initial value.height: env(titlebar-area-height)without a fallback givesheight: autooutside WCO, which may or may not be what you want. - Fallbacks describe the "no overlay" layout, not a guess at the overlay size.
width: env(titlebar-area-width, 100%)means "full width when there is no overlay", which is correct. A fallback such as33pxfor the height is a design choice for your in-page toolbar, not an emulation of Chromium. - You can compute with them.
calc(env(titlebar-area-y, 0px) + env(titlebar-area-height, 0px))is the offset below which content is guaranteed clear of the overlay. - They persist across navigations. Chromium stores the rectangle on the frame and applies it to each new document as it is attached, so a multi-page app's second page paints with the correct title bar on the first frame.
- They're main-frame only. Iframes see the fallbacks.
The environment variables are also what the DevTools emulation (see Debugging) sets, which makes CSS-first code easy to test.
Draggable regions: app-region and window-drag¶
Without a title bar, the user has almost nothing to grab: the Edge documentation notes that only the system-critical controls remain, leaving "very little space available for users to move the application window around". You have to tell the browser which parts of your page act as the window's caption.
Three names for one mechanism¶
| Syntax | Values | Status |
|---|---|---|
-webkit-app-region | drag, no-drag, none | Non-standard, in Chromium for over a decade (MDN lists it from Chrome 24), widely used by Electron. Now an alias of app-region |
app-region | drag, no-drag, none | Unprefixed form, referenced by the WCO and Manifest Incubations drafts. In Chrome 152 and later it is a legacy alias of window-drag |
window-drag | none, move | Standardized in CSS Basic User Interface Level 4. Shipped in Chrome 152 |
The CSS UI 4 definition of window-drag:
| Descriptor | Value |
|---|---|
| Value | none \| move |
| Initial | none |
| Applies to | all elements |
| Inherited | yes |
| Computed value | as specified |
| Animation type | discrete |
move makes "the element's principal box" a window drag area. The spec adds two normative rules: a drag gesture on such a box "initiates a window move operation instead of normal web event processing", and "the UA must not fire pointer events, mouse events, or drag events on the element for the duration of the window move gesture". As a note puts it, the HTML draggable attribute therefore has no effect on elements with window-drag: move.
Chrome Platform Status describes the Chrome 152 change as standardizing and renaming app-region, changing its value names to move and none, and adding explicit inheritance. It also states that "Chromium is not deprecating or removing app-region or -webkit-app-region". In Chromium's style system, app-region is now a surrogate for window-drag: both properties write the same computed value, so whichever declaration wins the cascade decides. app-region: drag and window-drag: move set the same state. app-region: no-drag produces an explicit "not draggable" region. An explicit window-drag: none produces that same explicit state, which is how you carve a button out of an inherited move.
Draggable regions only work where they are allowed¶
The Manifest Incubations draft says app-region "MUST only take effect when the applied display mode of the window is unframed or window-controls-overlay". Chromium follows that: BrowserView::AreDraggableRegionsEnabled() returns true only when WCO is enabled or the window is an unframed Isolated Web App. In a browser tab, in a standalone window with the title bar showing, and in every other engine, drag declarations have no effect, and your elements behave normally.
That means you can leave app-region: drag in your stylesheet unconditionally. Scoping it to @media (display-mode: window-controls-overlay) is still good practice, because it documents the intent and avoids surprises in Electron shells that reuse the same CSS.
How Chromium hit-tests drag regions¶
Chromium collects the boxes with a draggable state into a draggable region, subtracting boxes marked no-drag, and sends it to the browser process. When the pointer is pressed inside that region, the browser handles the press as a window-caption press instead of forwarding it to the page. On Windows, BrowserView returns HTCAPTION, the hit-test code Windows itself uses for a native title bar, so native behaviors such as double-click to maximize and dragging to a screen edge to snap apply there too.
One exception is built in: if a browser-owned widget (a permission prompt, the find bar, a JavaScript dialog) covers the point, the click goes to that widget, not to the drag region.
What you lose inside a drag region¶
Because the browser consumes the input, a drag region is not interactive web content:
- No pointer, mouse or click events reach elements inside it. The Electron documentation, which uses the same Chromium mechanism, puts it bluntly: "Draggable areas ignore all pointer events. For example, a button element that overlaps a draggable region will not emit mouse clicks or mouse enter/exit events within that overlapping area."
- No
:hoverstyles or custom cursors, because the page never sees the pointer entering. - No reliable text selection. The explainer and the Electron documentation both recommend adding
user-select: noneto drag regions, because the dragging behavior can conflict with text selection. - No HTML drag and drop from elements in the region.
- A system context menu may appear on right-click. Electron notes that "on some platforms, the draggable area will be treated as a non-client frame, so when you right click on it, a system menu will pop up." Don't attach a custom
contextmenuhandler to a drag region.
Carving out interactive controls¶
Mark every control inside a drag region as non-draggable. A robust default is a selector that covers everything focusable, so a new button can't be silently broken:
@media (display-mode: window-controls-overlay) {
.titlebar {
-webkit-app-region: drag; /* Chromium before app-region was unprefixed */
app-region: drag;
user-select: none;
}
.titlebar :is(a, button, input, select, textarea, summary, label,
[tabindex], [contenteditable], [role="button"],
[role="menuitem"], [role="tab"]) {
-webkit-app-region: no-drag;
app-region: no-drag;
}
}
If you want to adopt the standard name where it exists, add it behind @supports, after the legacy declarations, so that browsers with window-drag use it and older Chromium keeps using app-region:
@supports (window-drag: move) {
@media (display-mode: window-controls-overlay) {
.titlebar {
window-drag: move;
}
.titlebar :is(a, button, input, select, textarea, summary, label,
[tabindex], [contenteditable], [role="button"]) {
window-drag: none; /* explicit none = excluded from the drag area */
}
}
}
Because window-drag is inherited, in Chrome 152+ a descendant of a move element is draggable even if it overflows its parent's box, for example an absolutely positioned badge hanging below the title bar. In earlier versions the draggable region was built from the boxes that declared drag themselves. If you have elements that protrude from the title bar, give them no-drag (or window-drag: none) explicitly so they behave the same in every version.
Building a complete custom title bar¶
The following example builds a title bar for a notes app that:
- works as a normal in-page toolbar when WCO is unavailable, off or suspended;
- moves into the title bar area when the overlay is visible;
- keeps a back button, a search field and an account button clickable;
- collapses the search field when the title bar area is narrow;
- keeps the OS-drawn overlay the same color as the custom title bar in light and dark mode;
- dims itself when the window is inactive, like native title bars do.
The manifest¶
Use the manifest from Opting in with display_override. theme_color provides the initial title bar color. The <meta name="theme-color"> tags in the page take precedence once the page has loaded.
The HTML¶
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ledger Notes</title>
<!-- Chromium uses the first theme-color whose media query matches. -->
<meta name="theme-color" content="#1f3a5f" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f1b2d" media="(prefers-color-scheme: dark)">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icons/icon-32.png" sizes="32x32">
<link rel="stylesheet" href="/app.css">
<script type="module" src="/titlebar.js"></script>
</head>
<body>
<!-- Full-width strip behind the title bar area and the overlay. -->
<div class="titlebar-backdrop" aria-hidden="true"></div>
<header class="titlebar" id="titlebar">
<img class="titlebar__icon" src="/icons/icon-32.png" alt="" width="16" height="16">
<span class="titlebar__title" id="titlebar-title">Ledger Notes</span>
<button type="button" class="titlebar__button" id="back-button"
aria-label="Back" disabled>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<form class="titlebar__search" role="search" action="/search">
<label class="visually-hidden" for="search-input">Search notes</label>
<input id="search-input" name="q" type="search"
placeholder="Search notes" autocomplete="off">
</form>
<button type="button" class="titlebar__button titlebar__account"
id="account-button" aria-label="Account" aria-haspopup="menu">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="8" r="4" fill="currentColor"/>
<path d="M4 21c0-4 4-6 8-6s8 2 8 6" fill="currentColor"/>
</svg>
</button>
</header>
<main id="content" tabindex="-1">
<!-- Application content. -->
</main>
</body>
</html>
Notes on the markup:
- One
<header>, a direct child of<body>, so it maps to thebannerlandmark. It is the app's top bar in every display mode. - The title is a
<span>, not an<h1>. The page's<h1>belongs to the content in<main>. The window's accessible name comes fromdocument.title, which assistive technologies and the OS window switcher still read even though the native title bar is hidden. - Icon-only buttons have
aria-labels, and their SVGs arearia-hidden. - The backdrop is a separate element. The title bar area never covers the overlay, but the overlay doesn't necessarily paint the whole strip under it. A full-width backdrop behind both keeps the strip one continuous color, as the explainer's example does with its full-width container.
The CSS¶
:root {
color-scheme: light dark;
--titlebar-bg: #1f3a5f;
--titlebar-fg: #ffffff;
--titlebar-inactive-fg: rgb(255 255 255 / 0.6);
--titlebar-fallback-height: 40px;
/* Offset below which content is clear of the title bar in every mode. */
--titlebar-bottom: calc(env(titlebar-area-y, 0px)
+ env(titlebar-area-height, var(--titlebar-fallback-height)));
}
@media (prefers-color-scheme: dark) {
:root {
--titlebar-bg: #0f1b2d;
}
}
html,
body {
margin: 0;
}
body {
font: 14px/1.4 system-ui, sans-serif;
}
/* ---------- Title bar: default (no overlay) layout ---------- */
.titlebar-backdrop {
display: none;
}
.titlebar {
position: sticky;
top: 0;
z-index: 10;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: var(--titlebar-fallback-height);
padding-inline: 8px;
background: var(--titlebar-bg);
color: var(--titlebar-fg);
container: titlebar / inline-size;
}
.titlebar__icon {
flex: none;
}
.titlebar__title {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 600;
}
.titlebar__button {
flex: none;
display: grid;
place-items: center;
inline-size: 28px;
block-size: 28px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
}
.titlebar__button:hover:not(:disabled) {
background: rgb(255 255 255 / 0.14);
}
.titlebar__button:focus-visible,
.titlebar__search input:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
}
.titlebar__button:disabled {
opacity: 0.4;
}
.titlebar__search {
flex: 1 1 240px;
max-inline-size: 480px;
margin-inline: auto;
}
.titlebar__search input {
box-sizing: border-box;
inline-size: 100%;
block-size: 26px;
padding-inline: 10px;
border: 1px solid rgb(255 255 255 / 0.25);
border-radius: 6px;
background: rgb(255 255 255 / 0.12);
color: inherit;
font: inherit;
}
.titlebar__search input::placeholder {
color: rgb(255 255 255 / 0.7);
}
/* Narrow title bar area: drop the search field, keep the essentials. */
@container titlebar (inline-size < 420px) {
.titlebar__search {
display: none;
}
}
@container titlebar (inline-size < 220px) {
.titlebar__title {
display: none;
}
}
/* Inactive window: native title bars dim their text. */
:root[data-window-active="false"] .titlebar {
color: var(--titlebar-inactive-fg);
}
/* ---------- Title bar: overlay layout ---------- */
@media (display-mode: window-controls-overlay) {
.titlebar-backdrop {
display: block;
position: fixed;
inset-inline: 0;
top: 0;
height: var(--titlebar-bottom);
background: var(--titlebar-bg);
z-index: 9;
-webkit-app-region: drag;
app-region: drag;
}
.titlebar {
position: fixed;
left: env(titlebar-area-x, 0);
top: env(titlebar-area-y, 0);
width: env(titlebar-area-width, 100%);
height: env(titlebar-area-height, var(--titlebar-fallback-height));
/* Keep items vertically centered however tall the OS makes the strip. */
padding-block: 0;
-webkit-app-region: drag;
app-region: drag;
user-select: none;
}
.titlebar :is(a, button, input, select, textarea, summary, label,
[tabindex], [contenteditable], [role="button"]) {
-webkit-app-region: no-drag;
app-region: no-drag;
}
/* The fixed title bar is out of flow; reserve its space. */
main {
padding-block-start: var(--titlebar-bottom);
}
/* Anything else pinned to the top must clear the overlay too. */
.toast-region {
top: calc(var(--titlebar-bottom) + 8px);
}
}
/* ---------- Accessibility modes ---------- */
@media (forced-colors: active) {
.titlebar,
.titlebar-backdrop {
background: Canvas;
color: CanvasText;
border-block-end: 1px solid CanvasText;
}
.titlebar__button:disabled {
color: GrayText;
opacity: 1;
}
}
.visually-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
How the pieces fit:
- Outside WCO the header is a sticky 40 px toolbar at the top of the page. The
env()declarations on.titlebarlive inside the media query, so their fallbacks never matter there; only--titlebar-bottomevaluates its fallbacks outside WCO, which is exactly what makes it correct in both layouts. - Inside WCO the header becomes
position: fixedand takes exactly the title bar area.position: fixedmatters: the title bar area is fixed relative to the window and must not scroll with the document. - The backdrop spans the full window width under both the title bar area and the overlay, and is itself a drag region. On macOS this covers the strip around the traffic lights. The backdrop sits behind the header (
z-index: 9vs10), so the header'sno-dragcontrols stay on top. --titlebar-bottomis the single source of truth for "how far down do I need to push content". It uses the environment variables when they exist and the fallback height otherwise, so it's also correct in the in-page toolbar layout.- Container queries react to the width of the title bar area, not the window. On macOS the area is narrower than the window by the width of both overlay regions, so a viewport media query would get the breakpoints wrong.
- Chromium doesn't expose a CSS selector for window focus, so the inactive style is driven by a data attribute from JavaScript.
The JavaScript¶
The script is a progressive enhancement. The page is fully usable without it. It handles the parts CSS can't: state that other code needs, the back button, the inactive-window style, keeping theme-color in sync, and an optional hint that the title bar can be hidden.
import { coalesceToFrame } from "./coalesce.js";
const root = document.documentElement;
const wco = "windowControlsOverlay" in navigator ? navigator.windowControlsOverlay : null;
const wcoQuery = matchMedia("(display-mode: window-controls-overlay)");
/* ------------------------------------------------------------------ */
/* 1. Overlay state: expose it as data attributes for CSS and scripts. */
/* ------------------------------------------------------------------ */
function applyOverlayState(visible, rect) {
// Let other modules (menus, toasts, editors) react without their own listeners.
root.dispatchEvent(
new CustomEvent("titlebarchange", { detail: { visible, rect } }),
);
if (!visible || rect.width === 0) {
root.dataset.wco = "off";
delete root.dataset.wcoControls;
root.style.removeProperty("--wco-leading-gap");
root.style.removeProperty("--wco-trailing-gap");
return;
}
// The overlay occupies whatever the title bar area leaves free.
// Compare both sides instead of guessing from the platform or locale.
const leftGap = rect.x;
const rightGap = Math.max(0, window.innerWidth - (rect.x + rect.width));
root.dataset.wco = "on";
root.dataset.wcoControls =
leftGap > 0 && rightGap > 0 ? "both" : leftGap > rightGap ? "left" : "right";
root.style.setProperty("--wco-leading-gap", `${leftGap}px`);
root.style.setProperty("--wco-trailing-gap", `${rightGap}px`);
}
const onGeometryChange = coalesceToFrame((event) => {
// Prefer the event's own values; fall back to querying the API when this
// was triggered by the media query or a resize instead.
const visible = event?.visible ?? wco?.visible ?? false;
const rect = event?.titlebarAreaRect ?? wco?.getTitlebarAreaRect() ?? new DOMRect();
applyOverlayState(visible, rect);
});
if (wco) {
// No event fires for the state the page loads into: read it once.
applyOverlayState(wco.visible, wco.getTitlebarAreaRect());
wco.addEventListener("geometrychange", onGeometryChange);
// The display-mode query flips in every on/off case, including suspensions.
wcoQuery.addEventListener("change", () => onGeometryChange());
} else {
root.dataset.wco = "off";
}
/* ------------------------------------------------------------------ */
/* 2. Keep the OS-drawn overlay the same color as the custom bar. */
/* ------------------------------------------------------------------ */
/**
* Updates every theme-color meta tag (one per color scheme) and the CSS
* variable the title bar uses, so the overlay and the page never disagree.
* @param {{ light: string, dark?: string }} colors
*/
export function setTitlebarColor({ light, dark = light }) {
for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
const media = meta.getAttribute("media") ?? "";
meta.content = media.includes("dark") ? dark : light;
}
const isDark = matchMedia("(prefers-color-scheme: dark)").matches;
root.style.setProperty("--titlebar-bg", isDark ? dark : light);
}
/* ------------------------------------------------------------------ */
/* 3. Inactive-window styling, like native title bars. */
/* ------------------------------------------------------------------ */
function updateWindowActive() {
// hasFocus() stays true when focus moves into an iframe of this page.
root.dataset.windowActive = String(document.hasFocus());
}
window.addEventListener("focus", updateWindowActive);
window.addEventListener("blur", updateWindowActive);
updateWindowActive();
/* ------------------------------------------------------------------ */
/* 4. Back button: the native title bar in minimal-ui had one; WCO */
/* windows have none, so provide your own. */
/* ------------------------------------------------------------------ */
const backButton = document.getElementById("back-button");
function updateBackButton() {
if ("navigation" in window) {
backButton.disabled = !navigation.canGoBack;
} else {
backButton.disabled = history.length <= 1;
}
}
backButton.addEventListener("click", () => history.back());
if ("navigation" in window) {
navigation.addEventListener("currententrychange", updateBackButton);
}
window.addEventListener("pageshow", updateBackButton);
updateBackButton();
/* ------------------------------------------------------------------ */
/* 5. Window title: keep the OS window title and the custom one equal. */
/* ------------------------------------------------------------------ */
const titleElement = document.getElementById("titlebar-title");
/** Sets the window title (Alt+Tab, taskbar, Dock) and the visible title. */
export function setWindowTitle(text) {
document.title = text;
titleElement.textContent = text;
}
/* ------------------------------------------------------------------ */
/* 6. Optional: tell desktop users the title bar can be hidden. */
/* ------------------------------------------------------------------ */
const HINT_KEY = "wco-hint-dismissed";
function maybeShowOverlayHint() {
const couldToggle =
wco !== null &&
!wco.visible &&
matchMedia("(display-mode: standalone)").matches &&
navigator.userAgentData?.mobile === false;
let dismissed = false;
try {
dismissed = localStorage.getItem(HINT_KEY) === "1";
} catch {
// Storage can be unavailable; showing the hint again is harmless.
}
if (!couldToggle || dismissed) return;
const hint = document.createElement("p");
hint.className = "wco-hint";
hint.setAttribute("role", "status");
hint.textContent =
"Tip: use the arrow button next to the app menu in the title bar to hide the title bar.";
const close = document.createElement("button");
close.type = "button";
close.textContent = "Got it";
close.addEventListener("click", () => {
hint.remove();
try {
localStorage.setItem(HINT_KEY, "1");
} catch {
/* ignore */
}
});
hint.append(" ", close);
document.getElementById("content").prepend(hint);
// If the user follows the tip, remove the hint straight away.
wcoQuery.addEventListener("change", () => hint.remove(), { once: true });
}
maybeShowOverlayHint();
Why it is written this way:
- The initial read. Section 1 reads
visibleandgetTitlebarAreaRect()synchronously, because nogeometrychangefires for the state the document loads into. - Two signals. It listens to both
geometrychangeand thedisplay-modemedia query. The media query reliably reports every on/off transition, including suspensions.geometrychangecarries size changes while the overlay stays on. Both feed one coalesced handler, so a transition that triggers both runs the work once. - Side detection by measurement.
data-wco-controlsis derived from the rectangle, never fromnavigator.platformordir. It handles macOS (both: traffic lights left, browser buttons right), Windows and ChromeOS (right), RTL locales (left) and Linux layouts. - The theme color is written to the meta tags. Chromium picks the first
theme-colormeta whosemediamatches, and it repaints the title bar and the overlay when the tag changes. Writing both tags keeps light and dark mode consistent. - The hint can't be precise. No API tells you whether the toggle is present. The heuristic is limited to desktop standalone windows, and the hint dismisses itself as soon as the overlay turns on.
Both titlebar.js and coalesce.js are ES modules. navigation.canGoBack comes from the Navigation API, which every Chromium version with WCO supports.
Toggling, suspension and layout stability¶
When the overlay turns on or off, the whole viewport changes size by the height of the title bar: the window's outer size stays the same, but the web content gains or loses the strip at the top. Several things happen in the same frame:
- The viewport is resized, so
resizefires onwindow. - The environment variables are set or removed.
geometrychangefires onnavigator.windowControlsOverlay.- The
display-modemedia query flips betweenstandaloneandwindow-controls-overlay, andchangefires on anyMediaQueryListyou hold.
The sequence of these steps isn't specified. Don't write code that depends on one of them happening before another. Read state from the API and the media query when your handler runs, rather than carrying it from one event to the next.
To keep the transition calm:
- Don't animate the title bar's position. Its geometry changes because the window changed. An animated slide looks like lag.
- Keep the same DOM in both layouts. The example reuses one
<header>in both modes, so focus, input text in the search field and open menus survive the toggle. - Restore the scroll anchor if needed. When the viewport grows by the title bar height, content can shift. Browsers' scroll anchoring usually handles this. If your scroll container is custom, record the top visible item before the change and restore it in the handler.
- Test the suspended state. Navigate to an out-of-scope URL and back, and trigger an infobar if you can. Your layout must look right with the standard title bar at any moment.
Theme color and the overlay¶
The overlay is drawn by the browser, not by your page, so you can't style it with CSS. You can only color it through the theme color:
- On Windows, Chromium paints the caption button container with a solid background in the frame's title bar color when WCO is enabled, and places it on its own layer above the web contents.
- The title bar color comes from the page's
<meta name="theme-color">when present, otherwise from the manifest'stheme_color, otherwise from the browser's default frame color. Chromium evaluatesmediaattributes ontheme-colormeta tags, so light and dark variants work. - The explainer describes the intended behavior across platforms: "If the OS and browser support a colored title bar, the window controls overlay would use the
theme_colorfrom the manifest as the background color." Where a colored title bar isn't supported, the overlay uses the OS or browser theme. - The browser chooses the color of the minimize, maximize and close glyphs, and their hover states follow the OS design (the close button's red hover on Windows, for example).
In practice:
- Make the title bar background exactly equal to the theme color. Any difference shows as a visible seam where the overlay meets your title bar area.
- Avoid gradients and images that run into the overlay. The overlay's background is a flat color. If you want a gradient, the web.dev article's approach is to end it in the theme color at the edge that touches the overlay. Because the overlay can be on either side (or both, on macOS), use the measured
data-wco-controlsto pick the gradient direction. - Update both when you change one. If users can pick an accent color or a per-document color, call something like
setTitlebarColor()above so the meta tag and the CSS variable change together. - Use sufficient contrast for your own text. You control the text color in your title bar; the browser controls the glyph color of the overlay controls. Pick a theme color that works with both.
More about how theme colors reach the title bar, the splash screen and the Android status bar is on Splash Screens & Theming.
Accessibility of a custom title bar¶
A native title bar is accessible by construction: the OS labels the window, handles keyboard window management and draws controls with the system's contrast settings. Moving your UI into that strip transfers part of that responsibility to you.
Landmarks and names. Use a <header> (the banner landmark) for the title bar and label its controls. Keep a meaningful document.title per view: screen readers announce it as the window name, and the OS uses it in Alt+Tab, the taskbar and the Dock even though the title bar is hidden. Chromium announces the WCO toggle itself ("Title bar is now hidden"), so you don't need to.
Keyboard access. The overlay's buttons are browser UI, not part of your document's tab order. Your title bar controls are, and they should come first in the tab order because they come first visually. A drag region is not reachable from the keyboard at all. That's acceptable, because moving a window with the keyboard is left to the operating system's own window commands, but it means no functionality may exist only in a drag region. Never make the app title a click target inside a drag area, for instance.
Target size. The title bar area is short: the height is whatever the OS title bar height is, converted to CSS pixels. WCAG 2.2 success criterion 2.5.8 (Target Size, Minimum) asks for targets of at least 24 by 24 CSS pixels or sufficient spacing. The example uses 28 px buttons. Check at high page zoom levels, where the area gets shorter in CSS pixels.
Zoom and text scaling. Page zoom doesn't make the title bar taller. The OS decides its physical height, and Chromium divides it by the zoom factor. At 200% zoom a 32 DIP title bar is only 16 CSS pixels tall. Plan for this:
- let text in the title bar truncate (
text-overflow: ellipsis) rather than overflow; - hide optional items with container queries on the title bar's width and height;
- make sure every function in the title bar is also reachable from the page or a menu, so hiding items at high zoom removes nothing essential.
Forced colors. In Windows contrast themes, your theme-color and background colors are overridden or ignored in different places. The @media (forced-colors: active) block in the example switches the title bar to system colors and adds a border, so the boundary between title bar and content stays visible.
Focus visibility. Title bar backgrounds are often dark and saturated. Make :focus-visible outlines use currentColor or a color with enough contrast against the title bar, not your content area's focus color.
The site-wide accessibility guidance for installed apps is on Accessibility.
Security and privacy considerations¶
The title bar is traditionally a trusted surface: the OS or browser draws it, and users rely on it to know which app they're in. WCO hands most of it to web content, and the specification discusses the risks.
Spoofing. The spec warns that "displaying installed web apps in a frameless window leaves room for developers to spoof content in what was previously a trusted, UA-controlled region." Chromium keeps several mitigations:
- The overlay, including the app menu, is always on top of web content and receives its input directly. Your page can't cover or intercept it.
- At launch, the overlay shows the page's origin for a few seconds. The explainer calls out a right-to-left layout risk: without enough padding between the origin and the edge of the overlay, text drawn by the page could appear to continue the origin (its example: an attacker's origin followed by a trusted domain drawn in the page).
- When the user navigates out of scope, the overlay is replaced by the standard title bar with Chromium's origin bar, which shows the real origin, a security indicator and a close button.
- The app menu still offers site information and permissions.
As an app developer, don't draw anything that imitates browser UI (a fake address bar, a lock icon next to a URL, fake window controls), and don't place your own origin text next to the overlay.
Fingerprinting. The size of the overlay "can vary depending on the OS, the text scale, the OS font size, the OS zoom factor, and the web content's zoom factor", which the spec acknowledges as additional fingerprinting surface. The mitigations are scope limits: the geometry is exposed only to installed desktop apps with WCO enabled, and never to iframes. If you embed third-party content, it can't read your title bar geometry.
Tabbed mode in brief¶
tabbed is another display_override extension. It gives an app window a browser-style tab strip, so users can keep several documents open in one window. You configure the tab strip with the tab_strip manifest member: a home_tab whose scope_patterns (URL Pattern inputs) define a pinned home tab, and a new_tab_button whose url is loaded when the user clicks the new-tab button.
| Aspect | Status (September 2026) |
|---|---|
| ChromeOS | Shipped. Chromium's WebAppTabStrip runtime feature is stable on ChromeOS |
| Windows, macOS, Linux | Behind chrome://flags/#enable-desktop-pwas-tab-strip ("Desktop PWA tab strips"), plus #enable-desktop-pwas-tab-strip-customizations for tab_strip. Chrome Platform Status lists an origin trial from Chrome 118 to 126. MDN's compatibility data lists tabbed from Chrome 126, but Blink's manifest parser still drops the tabbed entry from display_override unless the WebAppTabStrip runtime feature is on, which by default is only the case on ChromeOS |
| Other engines | Not supported. Mozilla's standards position is "Defer" |
| Media query | @media (display-mode: tabbed) |
Tabbed mode and WCO are alternatives: Chromium resolves one display mode per app, so the first supported entry in display_override wins. The tab strip occupies the title bar region itself, so there's no title bar area for your page. List tabbed first and window-controls-overlay second if you want tabs where available and a custom title bar elsewhere. The complete tab_strip reference, including home tab scope rules and the launch_handler interaction, is in Advanced & Integration Members. Launch routing into tabs is covered on Protocol Handlers & Launch Handling.
Unframed (formerly borderless) mode in brief¶
Isolated Web Apps on ChromeOS only
Unframed display mode is restricted to Isolated Web Apps. It shipped in Chrome 152 on ChromeOS only, with a gradual rollout; elsewhere it remains a developer trial. It isn't available to regular PWAs.
The unframed mode, previously called borderless in Chromium, removes the window frame entirely: no host-native title bar and no visible window controls. Web content covers the whole window, and the app defines its own draggable regions with app-region (or window-drag), exactly as with WCO. The Manifest Incubations draft states that user agents "MUST restrict the use of unframed to" Isolated Web Apps, that the display mode "is fixed for the lifetime of the window", and that out-of-scope navigations "MUST NOT" take place inside an unframed window. Because there's no title bar, the browser has to show the app origin and privacy indicators (camera, microphone) somewhere else.
"Unframed display mode for IWAs" was a developer trial from Chrome 146 behind chrome://flags/#enable-unframed-iwa, and shipped in Chrome 152 on ChromeOS only, rolled out gradually. It is gated behind the window management permission, and administrators can control it with the DefaultWindowManagementSetting, WindowManagementAllowedForUrls and WindowManagementBlockedForUrls policies. It is the one display mode for which Chromium accepts the object form of display_override entries with url_patterns, so an IWA can use unframed windows for some URLs only.
Because an unframed window has no minimize, maximize or close buttons, the app has to draw them. That is what Additional Windowing Controls provide: window.maximize(), minimize(), restore() and setResizable() for apps with the window-management permission, plus the display-state and resizable media features. In Chromium's source the DesktopPWAsAdditionalWindowingControls runtime feature is enabled by default on ChromeOS only; on Windows, macOS and Linux it sits behind chrome://flags/#enable-desktop-pwas-additional-windowing-controls, and Chrome Platform Status lists it as a developer trial targeting Chrome 155. Details on IWAs are on Isolated Web Apps.
standalone | window-controls-overlay | unframed | |
|---|---|---|---|
| Available to | Any installed app | Any installed desktop app (Chromium) | Isolated Web Apps only |
| Title bar | Native | Hidden; overlay with window controls | None |
| Window controls | Native | Native, in the overlay | Drawn by the app |
| User can switch it off | n/a | Yes, toggle in the overlay | No |
| Draggable regions honored | No | Yes | Yes |
| Out-of-scope pages | Shown with origin bar | Overlay suspended, standard title bar | Not allowed in the window |
Browser support¶
Support data as of September 2026. For live data, see MDN: Window Controls Overlay API and caniuse.
| Feature | Chrome / Edge (desktop) | Opera (desktop) | Chrome (Android) | Safari (macOS) | Safari (iOS / iPadOS) | Firefox |
|---|---|---|---|---|---|---|
display_override | ✅ 89 | ✅ | ✅ 89 ⚠️ | ❌ | ❌ | ❌ |
window-controls-overlay display mode | ✅ 105 | ✅ 91 | ❌ | ❌ | ❌ | ❌ |
navigator.windowControlsOverlay | ✅ 105 | ✅ 91 | ⚠️ | ❌ | ❌ | ❌ |
geometrychange event | ✅ 105 | ✅ 91 | ❌ | ❌ | ❌ | ❌ |
env(titlebar-area-*) | ✅ 105 ⚠️ | ✅ 91 | ❌ | ❌ | ❌ | ❌ |
display-mode: window-controls-overlay | ✅ 105 | ✅ 91 | ❌ | ❌ | ❌ | ❌ |
-webkit-app-region / app-region | ✅ ⚠️ | ✅ ⚠️ | ❌ | ❌ | ❌ | ❌ |
window-drag | ✅ 152 | ❌ | ❌ | ❌ | ❌ | ❌ |
tabbed display mode | ⚠️ ChromeOS only | ❌ | ❌ | ❌ | ❌ | ❌ |
unframed display mode | ⚠️ 152 (ChromeOS IWAs only) | ❌ | ❌ | ❌ | ❌ | ❌ |
Notes:
- Chrome on Android uses only the standard display modes from
display_override. Thenavigator.windowControlsOverlayobject exists there (Chromium doesn't gate the interface by platform) butvisibleis alwaysfalse. Chrome Platform Status has a "Window Controls Overlay - Android" entry in the Proposed state, with no milestone. env(titlebar-area-*): MDN's compatibility data lists the variables from Chrome 93, the start of the origin trial (Chrome 93 to 96) that preceded the Chrome 105 launch. They only have values while the overlay is visible.app-region: Chromium has recognized-webkit-app-regionfor years, but it only takes effect in WCO and unframed windows (and in Electron). From Chrome 152,app-regionand-webkit-app-regionare aliases of the standardwindow-drag.- WebKit tracks the API in bug 257782 and has no position on
window-dragyet. Mozilla's standards position onwindow-dragis positive. Neither engine has an installable-app model that would use a custom title bar on desktop today.
Common pitfalls¶
- Expecting the manifest to hide the title bar. It only enables the toggle. Test with the overlay off first, because that is what every user sees on first launch.
- Buttons that don't click. Any interactive element inside an
app-region: dragbox needsno-drag, including elements inside a draggable backdrop that overlaps your header. Use the catch-all selector from the example. - Missing
env()fallbacks. Without a fallback, a declaration using an undefinedtitlebar-area-*variable is invalid at computed-value time, which silently changes your layout when the overlay turns off. - Using viewport media queries for title bar content. On macOS the title bar area is narrower than the window by both overlay regions. Query the title bar's container size instead.
- Treating
"windowControlsOverlay" in navigatoras "WCO is on". It istruein every Chromium browser, including Android and normal tabs. - Waiting for a first
geometrychange. None fires for the initial state. ReadvisibleandgetTitlebarAreaRect()at startup. - Hard-coding a title bar height such as 32 or 33 px. It varies with OS, OS text scale, display scale and page zoom.
- Forgetting other fixed-position UI. Toasts, modals with top-right close buttons, sticky headers and "skip to content" links need to clear
--titlebar-bottom. - A title bar color that doesn't match
theme-color. The overlay background won't match your title bar, especially after switching to dark mode if you only have onetheme-colormeta tag. - Putting a classic drop-down menu bar in the title bar. web.dev notes that a classic drop-down menu in the WCO area would "violate the design guidelines on macOS", where the menu bar belongs at the top of the screen. Keep title bar content to search, navigation and account controls, and put menus behind buttons.
- Custom context menus on drag regions. On some platforms the OS shows the system window menu on right-click in a caption area.
- Testing only in maximized windows. Resize, snap and move between monitors with different scale factors, and zoom the page.
Debugging¶
DevTools emulation. Chromium DevTools can emulate the overlay without installing the app. In the Application panel, open Manifest and scroll to the Window Controls Overlay section. If your manifest declares window-controls-overlay, DevTools says it found the value and offers an Emulate Window Controls Overlay checkbox with an OS selector (Windows, macOS, Linux). The Microsoft Edge documentation notes that the emulated overlay is a static image, and that the title bar area environment variables are set to match the selected platform. That makes emulation good for CSS iteration, but test drag regions, the toggle and suspension in a real installed window.
Inspecting the real window. An installed app window has DevTools like any other window (Ctrl+Shift+I or Cmd+Option+I). In the Console:
// Current state.
navigator.windowControlsOverlay.visible;
navigator.windowControlsOverlay.getTitlebarAreaRect();
matchMedia("(display-mode: window-controls-overlay)").matches;
getComputedStyle(document.querySelector(".titlebar")).height;
// Log every change while you resize, zoom and toggle.
navigator.windowControlsOverlay.addEventListener("geometrychange", (e) => {
console.log(e.visible, e.titlebarAreaRect.toJSON());
});
The stored toggle state. chrome://web-app-internals lists every installed app with its manifest data and settings, including window_controls_overlay_enabled. It is also where you can confirm that an updated manifest with window-controls-overlay has reached an existing install.
Checking drag regions. In the Elements panel, the computed style shows app-region (and window-drag in Chrome 152+). A quick visual check is a temporary outline, for example @media (display-mode: window-controls-overlay) { .titlebar { outline: 2px dashed magenta; } .titlebar :is(button, input) { outline: 2px solid lime; } }, then try to drag and click every element.
More DevTools techniques for installed apps are on Browser DevTools, and automated checks of display-mode-dependent layouts are on Automated Testing.
Further reading¶
On this site
- Display Modes:
display,display_overrideand thedisplay-modemedia feature - Advanced & Integration Members:
tab_stripand the other integration members - App Identity & Updates: how manifest changes such as
display_overridereach installed apps - Protocol Handlers & Launch Handling: which window receives a launch
- Desktop Platforms: installed apps on Windows, macOS, Linux and ChromeOS
- Isolated Web Apps: the app model behind unframed mode
- App-Like UX Patterns: navigation and chrome for standalone windows
- Splash Screens & Theming: theme colors across platforms
External references
- Window Controls Overlay specification (WICG draft)
- Window Controls Overlay explainer
- Manifest Incubations: display mode extensions,
tab_strip, draggable regions - CSS Basic User Interface Level 4: the
window-dragproperty - MDN: Window Controls Overlay API
- MDN:
geometrychangeevent - MDN:
display_override - web.dev: Customize the window controls overlay of your PWA's title bar
- Microsoft Edge: Display content in the title bar area using Window Controls Overlay
- Microsoft Edge DevTools: Simulate the Window Controls Overlay
- Chrome Platform Status: Window Controls Overlay for Installed Desktop Web Apps
- Chrome Platform Status:
window-drag - Electron: Custom window interactions (draggable regions)