Splash Screens and Theming for Progressive Web Apps¶
A PWA splash screen is what users see between tapping your icon and your first paint, and theming is how your app colors the operating system chrome around it: the status bar, the title bar, the task switcher. Android generates the splash screen from your manifest, iOS and iPadOS show startup images only if you supply one per screen size, and desktop platforms show no splash at all. Getting the colors right across these paths, in light and dark mode, is what makes a launch feel native instead of flashing white. This page covers every platform's mechanism, the exact inputs each one reads, and the code to keep them consistent.
Key takeaways
- Android (Chrome, Samsung Internet, Edge) builds a splash screen from
name,background_colorand an icon. Chromium prefers a maskable icon near an ideal size, andtheme_colorcolors the status bar. - iOS and iPadOS ignore
background_colorand generate nothing. They show anapple-touch-startup-imageonly when its pixel size exactly matches the device and orientation, so you need one image and media query per screen size. - Desktop apps have no splash screen: the window opens and paints as fast as your app shell does.
theme_colortints the title bar. <meta name="theme-color">overrides the manifest'stheme_colorfor in-scope pages and supportsmediafor light and dark values (Chrome 93, Safari 15). Since Safari 26 it applies only to installed web apps.- Theme color can change at runtime by updating the meta element's
content. Sync it with in-app theme toggles and modal scrims. - Avoid launch flashes by making the splash color,
background_color,<meta name="color-scheme">, the inlinehtmlbackground and the app shell's first frame the same color.
What happens between tap and first paint¶
Every platform goes through the same phases when an installed PWA launches. What differs is who draws what during each phase:
sequenceDiagram
participant User
participant OS as OS launcher
participant Browser as Browser engine
participant SW as Service worker
participant Page
User->>OS: Tap app icon
OS->>OS: Show launch surface (splash, startup image or empty window)
OS->>Browser: Start renderer for start_url
Browser->>SW: Start worker, dispatch fetch for the navigation
SW-->>Browser: App shell from cache
Browser->>Page: Parse HTML, apply critical CSS
Page-->>OS: First paint
OS->>OS: Remove launch surface | Phase | Android (Chromium WebAPK) | iOS / iPadOS web app | Desktop (Chromium, Safari on macOS) |
|---|---|---|---|
| Launch surface | Generated splash: background_color, icon, name | Matching apple-touch-startup-image, or a plain screen | App window, empty until first paint |
| System bars during launch | Status bar in theme_color | Status bar per apple-mobile-web-app-status-bar-style | Title bar in theme_color |
| Surface removed | Once the page has painted | Once the page has painted | – |
| Controls you have | Manifest colors and icons | Startup images, status bar style | Manifest colors, color-scheme, fast shell |
The length of the launch phase is mostly under your control: a service worker that serves the app shell from cache (App Shell Model) and navigation preload make the splash screen a brief flash rather than a loading screen. A splash screen is a bridge, not a feature. Design it to be seen for a fraction of a second.
Android: the generated splash screen¶
Chrome has generated splash screens for installed web apps since Chrome 47. The web.dev manifest guide summarizes the inputs: "Chrome automatically creates the splash screen from the name, background_color, and icons specified in your manifest." Other Chromium-based Android browsers read the same manifest members, but their launch screens aren't documented in the same detail; test each browser your users install from.
{
"name": "Field Notes",
"short_name": "Notes",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#f7f7f5",
"theme_color": "#1f6f5c",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
What each member contributes¶
| Member | Role on the Android splash screen |
|---|---|
background_color | Fills the whole screen. It's the dominant color users see, and the one that must match your page. |
icons | The centered image. Chromium picks one icon specifically for the splash (below). |
name | The app name shown on the splash in Chrome's classic layout. Chromium's resources describe "two possible layouts for splash screens", chosen by whether the icon was generated by Chrome and whether it's larger than a threshold, so what's shown varies with your icon. |
theme_color | Colors the status bar while the splash is up, and afterwards unless a <meta name="theme-color"> overrides it. |
The manifest specification restricts background_color to this bridging role: user agents use it "to draw the background color of a web application for which the manifest is known before the files are actually available", and it "MUST NOT be used by the user agent as the background color when the web application's stylesheet is available."
How Chromium picks the splash icon¶
Chromium's Android code (ShortcutInfo::UpdateBestSplashIcon in components/webapps) selects the splash image separately from the launcher icon. It asks for an icon closest to an ideal splash size, above a minimum size, trying purpose: "maskable" first and falling back to purpose: "any". Practical consequences:
- Provide a 512 × 512 maskable icon. web.dev notes that "Providing 192px and 512px icons is sufficient for most cases", and a large maskable icon gives Chromium a crisp, full-bleed source that it can mask to fit its layout.
- Declare
sizestruthfully. Selection trusts the declared size; a 192 px file declared as512x512renders blurry. - Make the maskable icon's background match or complement
background_color, because the masked shape sits on top of it. - Lighthouse's retired splash-screen audit (removed with the PWA category in Lighthouse 12) required a PNG icon of at least 512 px. The audit is gone, but the guidance still produces the best result.
Icons & Maskable Icons covers the safe zone and how launchers mask icons.
Updating splash colors and icons¶
The splash screen is part of the WebAPK that Chrome minted at install time. Editing background_color or icons in your manifest doesn't change existing installs until Chrome's periodic manifest update check regenerates the WebAPK, and icon changes can require user approval. App Identity & Updates explains when that happens. Consequences for testing: uninstall and reinstall to see a change immediately, and choose your launch colors carefully the first time.
Dark mode on Android¶
The manifest has no working dark variant yet. The specification defines color_scheme_dark, an object whose theme_color and background_color replace the top-level values "when the operating system uses a dark color theme", but no browser implements it as of September 2026. Chromium's internal manifest structure still carries dark_theme_color and dark_background_color fields, which its source labels as a non-standard Chrome experiment (tracked in w3c/manifest#1045) with a note that they may be removed. The spec also lets user agents "override the value defined by the background_color member to support prefers-color-scheme", which no Android browser documents doing.
So on Android your single background_color shows in both light and dark mode. Pick the color your app paints first in the scheme most of your users run, or pick a mid-tone brand color that works as a splash in both. Adding color_scheme_dark today is harmless and forward-compatible:
{
"background_color": "#f7f7f5",
"theme_color": "#1f6f5c",
"color_scheme_dark": {
"background_color": "#121614",
"theme_color": "#121614"
}
}
iOS and iPadOS: apple-touch-startup-image¶
Safari doesn't read background_color (MDN's compatibility data lists no Safari support), so iOS and iPadOS never generate a splash screen. Apple's mechanism predates the manifest: a <link rel="apple-touch-startup-image"> in the page. Apple's archived Configuring Web Applications guide describes it:
On iOS, similar to native applications, you can specify a launch screen image that is displayed while your web application launches. This is especially useful when your web application is offline. By default, a screenshot of the web application the last time it was launched is used.
That archived guide is the only official documentation. The selection rules developers rely on today come from testing, and web.dev's Learn PWA course summarizes the key one: "the startup image must have the exact window size that your PWA will have on opening." An image that's off by a pixel, or a portrait image on a device launched in landscape, isn't used.
Matching images to devices with media queries¶
Because the image must match exactly, you declare one <link> per device screen size and orientation and let a media query select it. The media query uses the device's screen size in CSS pixels (points), its pixel ratio and the orientation:
<!-- iPhone 17 Pro, iPhone 17, iPhone 16 Pro: 402×874 pt @3x → 1206×2622 px -->
<link rel="apple-touch-startup-image" href="/splash/1206x2622.png"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/splash/2622x1206.png"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">
Note the landscape link: device-width and device-height describe the screen in its portrait orientation on iOS and don't swap when the device rotates, so both links use the same values and differ only in orientation and image size. The widely used generator pwa-asset-generator encodes exactly this ("Apple expects same device width and height values from portrait orientation, for landscape", as its source puts it).
Screen sizes to cover¶
Apple doesn't publish a list of startup image sizes. pwa-asset-generator used to scrape device specs from Apple's Human Interface Guidelines at run time; since v8.1.6 (September 2026) scraping is off, the --scrape flag has no effect, and the tool always uses a bundled device list. The table below is derived from that list as shipped in v8.1.7. It doesn't yet include devices announced after the list was last updated, so check new models against their published point sizes. Each row is one distinct image; many devices share a size.
| CSS size (pt) | Scale | Portrait image (px) | Devices |
|---|---|---|---|
| 440 × 956 | 3 | 1320 × 2868 | iPhone 17 Pro Max, 16 Pro Max |
| 420 × 912 | 3 | 1260 × 2736 | iPhone Air |
| 402 × 874 | 3 | 1206 × 2622 | iPhone 17 Pro, 17, 16 Pro |
| 430 × 932 | 3 | 1290 × 2796 | iPhone 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max |
| 393 × 852 | 3 | 1179 × 2556 | iPhone 16, 15 Pro, 15, 14 Pro |
| 428 × 926 | 3 | 1284 × 2778 | iPhone 14 Plus, 13 Pro Max, 12 Pro Max |
| 390 × 844 | 3 | 1170 × 2532 | iPhone 16e, 14, 13 Pro, 13, 12 Pro, 12 |
| 375 × 812 | 3 | 1125 × 2436 | iPhone 11 Pro, XS, X |
| 360 × 780 | 3 | 1080 × 2340 | iPhone 13 mini, 12 mini |
| 414 × 896 | 3 | 1242 × 2688 | iPhone 11 Pro Max, XS Max |
| 414 × 896 | 2 | 828 × 1792 | iPhone 11, XR |
| 414 × 736 | 3 | 1242 × 2208 | iPhone 8 Plus, 7 Plus, 6s Plus, 6 Plus |
| 375 × 667 | 2 | 750 × 1334 | iPhone SE (2nd and 3rd generation), 8, 7, 6s, 6 |
| 320 × 568 | 2 | 640 × 1136 | iPhone SE (1st generation) |
| 1032 × 1376 | 2 | 2064 × 2752 | iPad Pro 13-inch |
| 1024 × 1366 | 2 | 2048 × 2732 | iPad Pro 12.9-inch, iPad Air 13-inch |
| 834 × 1210 | 2 | 1668 × 2420 | iPad Pro 11-inch (5th and 6th generation) |
| 834 × 1194 | 2 | 1668 × 2388 | iPad Pro 11-inch (1st–4th generation) |
| 820 × 1180 | 2 | 1640 × 2360 | iPad Air 11-inch and 10.9-inch, iPad 11-inch |
| 834 × 1112 | 2 | 1668 × 2224 | iPad Pro 10.5-inch, iPad Air 10.5-inch |
| 810 × 1080 | 2 | 1620 × 2160 | iPad 10.2-inch |
| 744 × 1133 | 2 | 1488 × 2266 | iPad mini 8.3-inch |
| 768 × 1024 | 2 | 1536 × 2048 | iPad Pro and Air 9.7-inch, iPad 9.7-inch, iPad mini 7.9-inch |
Two rows share 414 × 896 and differ only in pixel ratio, which is why the -webkit-device-pixel-ratio condition is required. Every new iPhone generation may add a row; when a device matches no link, it simply shows no startup image.
iPad multitasking breaks exact matching
On iPad, a web app opened in Split View, Slide Over or a resizable Stage Manager window has a window size that matches no full-screen image, so no startup image is shown. The media queries test the screen, not the window, which can also select a full-screen image for a smaller window. Keep startup images simple (a centered logo on a flat color) so a mismatch never looks broken.
Light and dark startup images¶
The media attribute accepts prefers-color-scheme like any other media feature, and pwa-asset-generator --dark-mode prefixes its media queries with (prefers-color-scheme: dark) and. Make both sets explicit, so exactly one link matches in each scheme regardless of the order iOS evaluates them in:
<link rel="apple-touch-startup-image" href="/splash/1206x2622.png"
media="(prefers-color-scheme: light) and (device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/splash/1206x2622-dark.png"
media="(prefers-color-scheme: dark) and (device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
Apple doesn't document how or when iOS fetches and caches startup images. Treat them as install-time assets: test changes by removing the web app from the Home Screen and adding it again, and verify dark-mode images on the iOS versions you support.
Generating startup images¶
With 23 sizes, two orientations and optionally two schemes, you're looking at up to 92 images. Generate them.
# Light set: logo centered on a solid background. --index updates the
# <link> tags in index.html; --path-override sets the href prefix.
npx pwa-asset-generator ./brand/logo.svg ./public/splash \
--splash-only --background "#f7f7f5" --padding "30%" \
--type png --index ./index.html --path-override /splash
# Dark set: same sizes, media queries prefixed with (prefers-color-scheme: dark).
npx pwa-asset-generator ./brand/logo-dark.svg ./public/splash \
--splash-only --dark-mode --background "#121614" --padding "30%" \
--type png --index ./index.html --path-override /splash
Defaults worth knowing: output type is JPEG at quality 70 unless you pass --type png; padding is 10% (too tight for most logos on a splash); --background defaults to transparent, but --opaque defaults to true, which flattens that onto a white canvas, so forgetting --background gives you white startup images even for a dark app. --portrait-only/--landscape-only halve the output if your app locks orientation. With --index, each run replaces only its own set of links: a light run rewrites the links without a (prefers-color-scheme: dark) prefix and a --dark-mode run rewrites the prefixed ones, so run both. The light set has no (prefers-color-scheme: light) prefix, so in dark mode both a light and a dark link match. To keep exactly one match per scheme, as recommended above, add the prefix to the light links after generating them:
// Generates iOS/iPadOS startup images and the matching <link> tags.
// Usage: node scripts/generate-splash.mjs (requires: npm i -D sharp)
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
// [css width, css height, pixel ratio] in portrait. Keep in sync with new devices.
const SCREENS = [
[440, 956, 3], [420, 912, 3], [402, 874, 3], [430, 932, 3], [393, 852, 3],
[428, 926, 3], [390, 844, 3], [375, 812, 3], [360, 780, 3], [414, 896, 3],
[414, 896, 2], [414, 736, 3], [375, 667, 2], [320, 568, 2],
[1032, 1376, 2], [1024, 1366, 2], [834, 1210, 2], [834, 1194, 2],
[820, 1180, 2], [834, 1112, 2], [810, 1080, 2], [744, 1133, 2], [768, 1024, 2],
];
const THEMES = [
{ scheme: "light", background: "#f7f7f5", logo: "brand/logo.svg", suffix: "" },
{ scheme: "dark", background: "#121614", logo: "brand/logo-dark.svg", suffix: "-dark" },
];
const OUT_DIR = "public/splash";
const URL_PREFIX = "/splash";
const LOGO_RATIO = 0.28; // logo size relative to the shorter screen edge
async function renderImage({ width, height, background, logo, file }) {
const logoSize = Math.round(Math.min(width, height) * LOGO_RATIO);
const logoPng = await sharp(logo, { density: 384 }) // rasterize SVG crisply
.resize(logoSize, logoSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
await sharp({ create: { width, height, channels: 3, background } })
.composite([{ input: logoPng, gravity: "center" }])
.png({ compressionLevel: 9, palette: true }) // flat art compresses very well
.toFile(file);
}
function mediaQuery({ scheme, cssW, cssH, dpr, orientation }) {
// device-width/height stay in portrait terms for both orientations on iOS.
return (
`(prefers-color-scheme: ${scheme}) and (device-width: ${cssW}px) and ` +
`(device-height: ${cssH}px) and (-webkit-device-pixel-ratio: ${dpr}) and ` +
`(orientation: ${orientation})`
);
}
async function main() {
await mkdir(OUT_DIR, { recursive: true });
const links = [];
for (const theme of THEMES) {
for (const [cssW, cssH, dpr] of SCREENS) {
for (const orientation of ["portrait", "landscape"]) {
const portrait = orientation === "portrait";
const width = (portrait ? cssW : cssH) * dpr;
const height = (portrait ? cssH : cssW) * dpr;
const name = `apple-splash-${width}x${height}${theme.suffix}.png`;
await renderImage({
width,
height,
background: theme.background,
logo: theme.logo,
file: path.join(OUT_DIR, name),
});
links.push(
`<link rel="apple-touch-startup-image" href="${URL_PREFIX}/${name}" ` +
`media="${mediaQuery({ scheme: theme.scheme, cssW, cssH, dpr, orientation })}">`,
);
}
}
}
await writeFile(path.join(OUT_DIR, "startup-links.html"), links.join("\n") + "\n");
console.log(`Wrote ${links.length} images and ${OUT_DIR}/startup-links.html`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
The script writes a partial with all <link> tags for your templating system to include in <head>. Rows that share a size (the two 414 × 896 entries differ by ratio, so they don't collide) produce distinct file names.
Operational rules for startup images:
- Don't precache them in your service worker. Only iOS devices fetch them, and only when installing. They're dead weight in every other client's precache (Precaching & Runtime Caching).
- Serve them with long cache lifetimes and stable URLs. Changing file names on every deploy gains nothing for existing installs.
- Keep the design minimal and identical to your first frame: the app shell's background color with the logo where your shell shows it, or nothing but the color. A startup image that shows fake UI looks broken when real UI replaces it at a slightly different position.
- Budget the markup. Up to 92
<link>tags add a few kilobytes to every page's<head>. Include them only on pages that can be thestart_urlor be added to the Home Screen, which in practice means your app shell.
iOS & iPadOS covers the rest of the Home Screen web app lifecycle, including the iOS 26 change that made every added site open as a web app.
Desktop: no splash screen¶
Desktop platforms don't show a splash screen for installed PWAs. Chromium on Windows, macOS, Linux and ChromeOS opens the app window immediately, with its title bar in the theme color, and the content area stays blank until the page paints. Safari on macOS web apps (added to the Dock since Safari 17) behave the same. web.dev's Learn PWA manifest chapter notes that "Safari on iOS and iPadOS and most desktop browsers currently ignore" background_color.
That makes desktop launch quality entirely a matter of first paint:
- The blank content area is painted in the browser's default canvas color, which follows the
color-schemeyou declare in HTML. Without<meta name="color-scheme" content="light dark">, a dark app flashes white on every launch in dark mode. - A cached app shell renders in tens of milliseconds from the service worker; a network-dependent one leaves a blank window for as long as the network takes.
- For windows with Window Controls Overlay, your own title bar is part of the page, so it's missing until first paint too. Keep its markup and critical CSS inline.
theme_color and <meta name="theme-color">¶
There are two sources for an app's theme color, and they're designed to work together:
Manifest theme_color | <meta name="theme-color"> | |
|---|---|---|
| Scope | Whole app, fixed at install | Per document, changeable at runtime |
| Dark mode | No (until color_scheme_dark ships) | Yes, with the media attribute |
| Read when | Install, launch, before the page loads | While the document is loaded |
| Precedence | Default | Overrides the manifest for in-scope documents |
The manifest specification says the user agent may override the manifest's theme color with a document's meta element, but "SHOULD NOT override the default theme color via a meta element … for documents' URL are not within scope". MDN's guide puts the practical rule simply: "If both are set, the theme-color meta element value overrides the theme_color manifest member."
Where the theme color appears¶
| Surface | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
| Android status bar, installed app | ✅ theme_color, overridden by meta | – | ✅ manifest theme_color (Firefox for Android 79) |
| Android browser toolbar, regular tab | ✅ meta (Chrome for Android 39, full support since 92) | – | ❌ |
| Desktop browser tab | ❌ (MDN: "Chrome uses the color only on installed progressive web apps") | ❌ since Safari 26 | ❌ |
| Desktop app window title bar | ✅ | ✅ Safari 17 web apps on macOS | – |
| iOS/iPadOS Safari tab bar | – | ⚠️ Safari 15–18 only | – |
| iOS/iPadOS Home Screen web app status bar | – | ✅ with default status bar style | – |
| Task switcher / recents | ✅ Android | – | – |
The Safari rows reflect a Safari 26 change recorded in MDN's compatibility data: "From Safari 26, the theme color is only used for installed web apps." In Safari 26 tabs, on iOS as on macOS, the toolbar tint is derived from the page itself (its background color and fixed or sticky elements at the viewport edges) rather than from theme-color. Apple hasn't documented that algorithm, and developers have reported cases where a fixed overlay's color takes over the tint. Safari 27 (September 2026) didn't change any of this: its release notes don't mention theme-color, startup images or the status bar, and MDN's compatibility data still records the Safari 26 behavior.
Light and dark theme colors with media¶
The meta element's media attribute takes a media query, and the first theme-color meta whose media matches wins. Chrome honors it since Chrome 93 and Safari since Safari 15:
<meta name="theme-color" content="#1f6f5c" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#121614" media="(prefers-color-scheme: dark)">
Keep a manifest theme_color as well. The manifest value colors the launch surfaces (Android splash status bar, desktop title bar) before your HTML is parsed, and it's the fallback for browsers that ignore media. Choose the manifest value to match the scheme most users see at launch, since it can't vary yet.
Choosing theme colors¶
- Match the top of your UI. On Android and in desktop windows, the theme color sits directly above your app bar. If they match, the app bar appears to extend into the system bar, which is the native look. A contrasting color is a deliberate brand stripe.
- Contrast is the platform's job, but check it. Chromium picks light or dark foreground (status bar icons, title text, window controls) based on the color's luminance. Mid-tones near the threshold can produce low-contrast icons. Test both schemes on real devices.
- Use opaque colors. The manifest editor's draft notes that "in most environments, the theme color cannot be transparent", and web.dev's Learn PWA course recommends "color functions without transparency" for manifest colors. Use a solid color that matches the composited result you want.
- Use simple color syntax. The spec accepts any CSS color, but hex or
rgb()values avoid surprises with older parsers and native code paths that consume the color.
Changing the theme color at runtime¶
Browsers observe the content (and media) of theme-color meta elements while the page is loaded, so changing the attribute updates the status bar or title bar. Uses in app-like UIs:
- A manual light/dark toggle that overrides the system scheme (App-Like UX Patterns has the full toggle).
- Modal scrims: dim the status bar together with the page when a dialog or bottom sheet opens, as native apps do.
- Per-section colors: a media viewer that goes black, a camera view, a colored header per workspace.
The module below manages all three without fighting over the meta elements. It keeps the original values, applies overrides as a stack (a dialog opened over a black viewer restores the viewer's color when it closes), and resolves CSS custom properties, including light-dark() tokens, to concrete colors:
/**
* Runtime theme-color management.
*
* const release = pushThemeColor("#000000"); // e.g. open a photo viewer
* const releaseScrim = pushThemeColor(scrimOver()); // dialog over it
* releaseScrim(); release(); // restore in any order
*
* pushThemeColor("var(--surface)") // CSS values are resolved to rgb()
*/
const metas = () => [...document.querySelectorAll('meta[name="theme-color"]')];
// Remember the authored values once, so overrides can always be undone.
const originals = new Map(metas().map((m) => [m, m.content]));
if (originals.size === 0) {
const meta = document.createElement("meta");
meta.name = "theme-color";
document.head.append(meta);
originals.set(meta, "");
}
const stack = []; // [{ id, raw }] – raw CSS value, resolved at render time
let nextId = 0;
/** Resolve any CSS color expression (custom properties, light-dark()) to rgb(). */
export function resolveColor(value) {
// An invalid value would be ignored and the probe would report the
// inherited text color instead, so reject it up front. (var() always
// passes this check; an undefined custom property still falls back.)
if (!CSS.supports("color", value)) {
throw new TypeError(`Not a CSS color: ${value}`);
}
const probe = document.createElement("span");
probe.style.display = "none";
probe.style.color = value;
document.body.append(probe);
const resolved = getComputedStyle(probe).color; // "rgb(r, g, b)" or "rgba(...)"
probe.remove();
return resolved || value;
}
function render() {
const top = stack.at(-1);
// Resolve now, so light-dark() and scheme-dependent custom properties
// pick up the current scheme.
const color = top ? resolveColor(top.raw) : null;
for (const [meta, original] of originals) {
// With an override, every media variant gets it, so whichever one
// matches the current scheme shows the override.
meta.content = color ?? original;
}
}
/** Apply a theme color until the returned function is called. */
export function pushThemeColor(color) {
const entry = { id: nextId++, raw: color };
stack.push(entry);
render();
return () => {
const index = stack.findIndex((e) => e.id === entry.id);
if (index !== -1) {
stack.splice(index, 1);
render();
}
};
}
/** Color of the status bar under a 32% black scrim, for dialogs. */
export function scrimOver(base = currentThemeColor(), alpha = 0.32) {
const resolved = resolveColor(base);
// Computed colors are serialized as rgb()/rgba() for sRGB inputs. Wide-gamut
// inputs (oklch(), color(display-p3 …)) serialize differently; use black.
if (!/^rgba?\(/.test(resolved)) return "#000000";
const [r, g, b] = resolved.match(/\d+(\.\d+)?/g).map(Number);
const mix = (c) => Math.round(c * (1 - alpha));
return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`;
}
/**
* Replace the base (non-override) values, e.g. from a manual theme toggle,
* so releasing an override restores the toggled colors, not the authored ones.
* setBaseThemeColors((meta) => meta.media.includes("dark") ? "#121614" : "#f7f7f5");
*/
export function setBaseThemeColors(valueFor) {
for (const meta of originals.keys()) originals.set(meta, valueFor(meta));
render();
}
/** The theme color currently in effect (first matching meta). */
export function currentThemeColor() {
const active = metas().find((m) => !m.media || matchMedia(m.media).matches);
return active?.content || "#ffffff";
}
/** Re-resolve overrides that came from scheme-dependent CSS when the scheme flips. */
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
import { pushThemeColor, scrimOver } from "./theme-color.js";
const dialog = document.querySelector("#confirm");
let releaseThemeColor = null;
export function openConfirm() {
releaseThemeColor = pushThemeColor(scrimOver());
dialog.showModal(); // also closes on Esc and, in Chromium, on Android back
}
dialog.addEventListener("close", () => {
releaseThemeColor?.();
releaseThemeColor = null;
});
Caveats:
resolveColor()must run after<body>exists; call these functions from module scripts or afterDOMContentLoaded.- The module snapshots the meta values when it first loads. If you also run a manual theme toggle (such as the one on App-Like UX Patterns), route its updates through
setBaseThemeColors()instead of writing the meta elements directly; otherwise releasing an override restores the pre-toggle colors. - Theme color changes animate on some platforms (Chrome for Android's toolbar), which looks good for scrims and odd for rapid changes. Don't tie theme color to scroll position.
- On iOS, the theme color only affects the status bar with the
defaultstatus bar style; withblack-translucentyour page draws under the status bar and runtime changes totheme-colorhave no visible effect. - A value set by JavaScript isn't known at launch. Launch surfaces always use the manifest color and the meta values in your HTML.
iOS status bar styles¶
In a Home Screen web app, iOS always shows the status bar (time, signal, battery). The Apple-specific apple-mobile-web-app-status-bar-style meta tag decides how it relates to your content. Apple's reference defines the three values:
| Value | Apple's description | Your content starts | Theme color |
|---|---|---|---|
default (or absent) | "the status bar appears normal" | Below the status bar | Tints the status bar (Safari 15+) |
black | "the status bar has a black background" | Below the status bar | Not used |
black-translucent | "the status bar is black and translucent … the web content is displayed on the entire screen, partially obscured by the status bar" | At the top of the screen, under the status bar | Ignored; web.dev: "The theme color is ignored in this mode" |
Apple's reference also says the tag "has no effect unless you first specify full-screen mode", which today means the page runs as a Home Screen web app. Since iOS 26, every site added to the Home Screen opens as a web app by default, so the tag applies more widely than it used to.
default is the simplest correct choice: the system draws the status bar above your content, tinted with your theme color, and there's nothing to pad. Pair it with light and dark theme-color values.
black-translucent gives the edge-to-edge look: your app bar's background extends under the status bar, as in native apps. The status bar text is light, so the area under it must be dark enough for contrast, and you must pad content with the safe-area inset:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
.app-bar {
/* Background runs under the status bar; content starts below it. */
padding-top: env(safe-area-inset-top, 0px);
background: var(--app-bar-bg); /* dark enough for light status bar text */
}
Developers commonly report that iOS applies status bar style changes only after the web app is removed and re-added, so decide on the style before users install, and test changes with a fresh install. The safe-area mechanics are covered in App-Like UX Patterns, and iOS & iPadOS lists the other Apple meta tags (apple-mobile-web-app-title for the icon label, apple-touch-icon).
Desktop title bar color¶
On desktop, an installed PWA's window title bar is the most visible theming surface:
- Chromium (Chrome, Edge) on Windows, macOS, Linux and ChromeOS colors the title bar with the theme color: the manifest
theme_colorat launch, then the page's<meta name="theme-color">when present. Themediaattribute works here, so the title bar follows the system's light or dark mode. Chromium chooses the title text and control colors for contrast. - Safari web apps on macOS (Safari 17 and later) use the manifest
theme_color(MDN lists Safari 17 support) and, per Safari 26's behavior, the meta theme color for installed web apps. - Window Controls Overlay replaces the title bar with your own content next to the window controls. The theme color then colors the controls' background area, and it's worth matching exactly to your custom title bar. Window Controls Overlay covers the details.
<!-- Matches the app bar in each scheme; the title bar blends into it. -->
<meta name="theme-color" content="#f7f7f5" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#121614" media="(prefers-color-scheme: dark)">
When your app offers its own theme toggle, update the meta elements as shown above, or the title bar stays in the system scheme while the content switches.
Avoiding background flashes¶
A launch "flash" is any frame whose color differs from the frames around it: white between a dark splash and a dark app, the default canvas before your CSS arrives, a light shell before a script applies the user's saved dark theme. The launch sequence has up to five surfaces, and each must hand off to a surface of the same color:
flowchart LR
A["Splash / startup image<br/>(background_color or image)"] --> B["Browser canvas<br/>(color-scheme)"]
B --> C["Inline html background<br/>(critical CSS)"]
C --> D["App shell first frame<br/>(skeleton)"]
D --> E["Content"] Checklist, in launch order:
- Splash:
background_colorequals the app shell's background in your primary scheme. iOS startup images use the same color, with dark variants. - Canvas: declare
<meta name="color-scheme" content="light dark">as early as possible in<head>, so the browser's initial canvas is dark in dark mode before any stylesheet loads. - Critical CSS: inline the
htmlbackground (and the app bar's) in a<style>in<head>, so the first paint doesn't wait for an external stylesheet. - Saved theme preference: apply a manually chosen theme from a tiny blocking inline script before first paint. Doing it in a module script or after hydration guarantees one frame in the wrong scheme.
- App shell: serve it from the service worker cache so first paint is fast, and render skeletons in the shell's colors (App-Like UX Patterns).
- Navigations: in multi-page apps, cross-document View Transitions avoid a flash of blank page between documents.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f7f7f5" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#121614" media="(prefers-color-scheme: dark)">
<script>
// Blocking on purpose: must run before first paint. Keep it tiny.
(function () {
var theme = null;
try { theme = localStorage.getItem("theme"); } catch (e) { /* storage blocked */ }
if (theme === "light" || theme === "dark") {
var root = document.documentElement;
root.setAttribute("data-theme", theme);
root.style.colorScheme = theme;
var color = theme === "dark" ? "#121614" : "#f7f7f5";
var metas = document.querySelectorAll('meta[name="theme-color"]');
for (var i = 0; i < metas.length; i++) metas[i].setAttribute("content", color);
}
})();
</script>
<style>
/* Critical: identical to background_color and the startup images. */
html { background: #f7f7f5; color: #1b1c1f; }
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) { background: #121614; color: #e3e2e6; }
}
html[data-theme="dark"] { background: #121614; color: #e3e2e6; }
</style>
<link rel="manifest" href="/app.webmanifest">
<link rel="stylesheet" href="/css/app.css">
<!-- startup images, icons … -->
</head>
The inline script uses ES5 syntax and try/catch deliberately: it runs before anything else and must never throw, even in private browsing modes where localStorage access fails. If you use a strict Content Security Policy, allow it with a hash rather than 'unsafe-inline' (Content Security Policy).
A remaining Android limitation: because background_color can't vary by scheme yet, the splash itself may not match a dark app in dark mode. Choosing a brand color for background_color that's neither your light nor your dark surface turns that mismatch into a deliberate branded moment rather than a flash.
Browser support¶
Support data as of September 2026. See MDN for theme-color, theme_color and background_color for live data.
| Feature | Chrome / Edge (desktop) | Chrome (Android) | Safari (macOS) | Safari (iOS/iPadOS) | Firefox | Firefox (Android) | Samsung Internet |
|---|---|---|---|---|---|---|---|
Manifest background_color | ⚠️ parsed, no splash | ✅ splash | ❌ | ❌ | ❌ | ✅ 79 | ✅ |
| Generated splash screen | ❌ | ✅ | ❌ | ❌ | ❌ | ⚠️ | ⚠️ |
Manifest theme_color | ✅ 46 | ✅ | ✅ 17 | ✅ 15 | ❌ | ✅ 79 | ✅ |
<meta name="theme-color"> | ⚠️ 73, installed apps only | ✅ 92 | ⚠️ 15; installed web apps only since 26 | ⚠️ 15; installed web apps only since 26 | ❌ | ❌ | ✅ 6.2 |
theme-color media attribute | ✅ 93 | ✅ 93 | ✅ 15 | ✅ 15 | ❌ | ❌ | ⚠️ |
apple-touch-startup-image | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
apple-mobile-web-app-status-bar-style | ❌ | ❌ | ❌ | ✅ web apps | ❌ | ❌ | ❌ |
Manifest color_scheme_dark | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
<meta name="color-scheme"> | ✅ 81 | ✅ 81 | ✅ 12.1 | ✅ 12.2 | ✅ 96 | ✅ 96 | ✅ |
⚠️ Firefox for Android reads manifest colors (Firefox for Android 79) but doesn't document a generated splash screen equivalent to Chromium's; neither does Samsung Internet, whose theme-color media handling is also undocumented. Desktop Chromium parses background_color but shows no splash. Chrome desktop and Safari 26 ignore theme-color in ordinary tabs.
Common pitfalls¶
background_colorthat doesn't match the page. The most common launch flash on Android: a white splash followed by a dark app, or the reverse.- Transparent startup images or icons on the splash. A transparent
apple-touch-startup-imagerenders against a system color you don't control; generators default to transparent unless you set a background. - Startup images that are one pixel off, or missing landscape variants for iPad users who launch in landscape. iOS silently shows nothing.
- Precaching dozens of startup images in the service worker for every client.
- No
<meta name="color-scheme">, so dark-mode users get a white canvas before your CSS loads. - Applying a saved theme in a deferred script, producing one frame in the wrong scheme on every launch.
- Theme toggle that doesn't update
theme-color, leaving a light status bar above a dark app. - Expecting theme color in desktop browser tabs or Safari 26 tabs. It's an installed-app feature on desktop, and Safari 26 tabs tint from page content.
black-translucentwithoutviewport-fit=coverand safe-area padding, which puts your app bar's buttons under the status bar and clock.- Out-of-scope pages changing the theme color. Per the spec, browsers shouldn't honor it there; don't rely on third-party pages (OAuth providers, payment pages) inheriting your colors.
Debugging¶
- Chromium DevTools → Application → Manifest shows the parsed
theme_color,background_colorand icons, and warns about invalid colors and icon problems. The Identity and Presentation sections are where color and icon errors surface. - Android splash: install, then launch from the launcher, not from Chrome. After changing colors or icons, uninstall and reinstall rather than waiting for a WebAPK update.
chrome://webapkson the device lists installed WebAPKs and their last update check. - iOS startup images: remove the web app from the Home Screen, clear the site's data if needed, add it again and launch it. Test each orientation and both appearances. Use the iOS Simulator with Safari's Web Inspector to confirm which
<link>media queries match (matchMedia(link.media).matchesin the console). - Theme color: in the console, list
[...document.querySelectorAll('meta[name=theme-color]')].map(m => [m.media, m.content])to see what's in effect, and toggle the OS appearance to confirm themediavariants switch. - Flashes: record the launch with a screen recorder or Chrome DevTools' Performance panel screenshots and step through frames. A flash usually lasts a single frame and is invisible in normal viewing on a fast device, then obvious on a slow one.
Further reading¶
On this site
- Members Reference:
theme_color,background_colorandcolor_scheme_darkin detail - Icons & Maskable Icons: icon sets, maskable safe zones and generators
- Display Modes: how display modes change status and title bars
- iOS & iPadOS: Home Screen web apps, Apple meta tags and safe areas
- Window Controls Overlay: custom desktop title bars
- App-Like UX Patterns: dark mode tokens, safe areas and skeletons
- App Shell Model: the fast first paint that shortens every splash
- App Identity & Updates: when manifest color and icon changes reach installed apps
External references