Skip to content

Icons & Maskable Icons

The icons member of the web app manifest is the list of images a browser can use to represent your installed app: on the home screen or launcher, in the install dialog, on the splash screen, in the task switcher, in notifications and in OS settings. Each surface wants a different size and shape, so the browser runs a selection algorithm over your list, and a set that is wrong for one platform produces blurry, clipped, or letter-boxed icons there. This page covers the icon object at spec level, the selection logic Chromium actually runs, the sizes each platform asks for, maskable and monochrome icons, how icons relate to favicons, and scripts that generate a complete set.

Key takeaways

  • Each icon is an image resource with src (resolved against the manifest URL), sizes, type and purpose. purpose is a space-separated set of any, maskable and monochrome. An icon whose purposes are all unrecognized is dropped entirely.
  • Chromium only treats a manifest as installable if it lists a PNG, SVG or WebP icon with purpose any, declared sizes, and at least 144 px. Ship 192×192 and 512×512 any icons as the floor.
  • Ship separate maskable icons: full-bleed, opaque, with the important content inside a centered circle whose radius is 40% of the icon size. Do not combine "any maskable" on one file.
  • Android, ChromeOS and macOS (in Chromium) prefer maskable icons. Windows and Linux use any icons. Safari uses apple-touch-icon first, then manifest icons with purpose any, and never maskable ones.
  • Treat icon URLs as immutable. Put a content hash in the file name, because browsers decide whether an icon changed by looking at its URL, and iOS never updates a Home Screen icon after it has been added.
  • Monochrome icons contribute alpha only. The browser supplies the color, so any detail has to be cut out as transparency.

The icon object: src, sizes, type, purpose

The icons member is an array of manifest image resources. The shape comes from the Image Resource spec (src, sizes, type, label). The Web Application Manifest spec adds purpose. When the manifest is processed, each entry goes through the "process image resources" algorithm, and entries that fail are dropped quietly rather than failing the whole manifest.

Member Type Required Default What the browser does with it
src URL string Yes none Parsed relative to the manifest URL (not the page URL). If parsing fails, the entry is dropped.
sizes string No none Parsed like <link rel="icon" sizes>: space-separated WxH tokens or the keyword any. Used to pick an icon before downloading anything.
type MIME string No none An advisory hint. A browser can skip types it cannot decode without fetching them. The real type is always sniffed from the bytes.
purpose string No "any" A set of any, maskable and monochrome. Unknown tokens are ignored. If no token is recognized, the whole icon is ignored.
label string No none Accessible name. The manifest's name/short_name serves as the external label when it is absent.

src: resolution, schemes, CSP and service workers

Relative URLs resolve against the URL of the manifest file. A manifest at /app/manifest.webmanifest containing "src": "icons/192.png" points to /app/icons/192.png, even if the page that links the manifest lives at /. This is one of the most common causes of 404s on icons.

Browsers fetch icons with request destination image and with the document that linked the manifest as the request client. Two consequences:

  • The document's CSP img-src directive governs icon fetches. With img-src 'self', an icon on cdn.example.com fails to load, and in Chromium a missing required icon makes the app non-installable. See Content Security Policy.
  • The fetch goes through the service worker that controls the document. A broken fetch handler can therefore break installation, and a precached icon can be served at install time.

Chromium accepts http:, https: and data: icon URLs (plus the document's own scheme) and drops other schemes with the console message property 'src' of 'icon' ignored, invalid scheme. Safari 26 also accepts data URLs for icons. Data URLs bloat the manifest and cannot be cached separately, so reserve them for tiny monochrome glyphs, if you use them at all.

sizes: what "192x192" actually promises

sizes uses the same grammar as the HTML sizes attribute on <link rel="icon">: one or more WIDTHxHEIGHT tokens separated by spaces (the x is case-insensitive and leading zeros are invalid), or the single keyword any. Multiple tokens exist for container formats such as ICO, which can hold several bitmaps:

manifest.webmanifest (excerpt)
{ "src": "/favicon.ico", "sizes": "16x16 32x32 48x48", "type": "image/x-icon" }

Browsers choose icons from the declared sizes, before any download, and render the actual pixels afterwards. A 512×512 file declared as 192x192 is picked for 192-pixel slots and then scaled down. A 96×96 file declared as 512x512 gets picked for the splash screen and upscaled into a blurry smear. Declare exactly what the file contains.

any means the image scales to any size, so use it for SVG. Chromium does accept any on raster files, but that throws away the size information it needs to pick the right bitmap. Keep any for vectors.

Non-square icons are ignored

Chromium's selector rejects any declared size whose aspect ratio is not exactly 1:1. Desktop Chromium also filters non-square downloaded bitmaps out of its icon set. Keep every manifest icon square. Wide artwork belongs in screenshots, not in icons.

type: a hint that saves downloads

type lets a browser skip formats it cannot use without fetching them. It is advisory: the spec requires the image sniffing rules to determine the real type. When type is missing, Chromium falls back to guessing from the file extension of src. An icon at /icon?size=192 with no type therefore has no detectable type, and Chromium skips it.

For the icon that satisfies installability, Chromium accepts only image/png, image/svg+xml and image/webp (the source calls this limited_image_types_for_installable_icon). A JPEG, GIF or ICO icon can still show up in some surfaces, but it never makes an app installable.

purpose: any, maskable, monochrome and combinations

purpose is an unordered set of unique, space-separated tokens. The spec's "determine the purpose of an image" algorithm works like this:

  1. If purpose is missing or not a string, the purpose set is « "any" ».
  2. Split the string on ASCII whitespace.
  3. Keep each keyword found in the icon purposes list (monochrome, maskable, any) and skip the rest.
  4. If nothing was kept, return failure and ignore the icon entirely.
purpose value Result
(missing) any
"maskable" maskable only. It is not a fallback for any surfaces.
"any maskable" Eligible for both any and maskable surfaces (discouraged, see below)
"monochrome fizzbuzz" monochrome (the unknown token is ignored)
"fizzbuzz" Icon dropped. Chromium logs found icon with no valid purpose; ignoring it.
"Maskable" Spec: dropped, because comparison is case-sensitive. Chromium: maskable, because it compares case-insensitively. Always write lowercase.

The spec says a browser SHOULD NOT use an icon for anything other than its stated purposes. A maskable-only icon is never used where an any icon is needed, so a manifest that lists only maskable icons fails Chromium's installability check. That check looks specifically for a purpose-any icon.

label is accepted but ignored today

The Image Resource spec defines label as the accessible name of an image. Chromium's manifest parser reads label for screenshots but not for icons. Adding it is harmless, but nothing reads it yet.

icons_localized

The current manifest draft makes icons a localizable member. icons_localized maps language tags to alternative icon arrays. Chrome and Edge 148+ on desktop process *_localized members and fall back from an exact locale match to a language-only match (en-US → en) before using icons. Other browsers ignore the member. The Members Reference covers localization as a whole.

manifest.webmanifest (excerpt)
{
  "lang": "en-US",
  "icons": [{ "src": "/icons/en-512.png", "sizes": "512x512", "type": "image/png" }],
  "icons_localized": {
    "fr": [{ "src": "/icons/fr-512.png", "sizes": "512x512", "type": "image/png" }]
  }
}

How browsers choose an icon for each surface

The spec leaves selection to the browser. Chromium (Chrome, Edge, Samsung Internet, Opera and others) uses one shared routine, ManifestIconSelector::FindBestMatchingIcon. Each surface calls it with its own parameters: a required purpose, an ideal size, a minimum and maximum size, and a policy for SVG. Knowing the routine explains most "why did it pick that icon?" questions.

Chromium's selection algorithm, step by step

For a requested purpose P, ideal size I, minimum min and maximum max:

  1. Skip every icon whose purpose set does not contain P.
  2. Skip every icon whose type (or file extension, if type is missing) is unsupported. For installability, only PNG, SVG and WebP count.
  3. For each declared size of each remaining icon:
    • A size of any on an SVG becomes the "best SVG" candidate. A size of any on any other format becomes a generic fallback candidate.
    • Skip sizes smaller than min, larger than max, or not square.
    • Otherwise compute delta = height − I. An icon larger than ideal always beats one smaller than ideal. Among icons on the same side, the one closest to I wins, and ties go to the icon that appears later in the array.
  4. An exact match (delta == 0) wins immediately.
  5. Otherwise the SVG policy decides. With "SVG as second priority" (the default), an any-sized SVG beats an inexact raster. With "SVG as fallback", rasters win and the SVG is only used if nothing else qualifies.
flowchart TD
    A["icons[]"] --> B{"purpose contains P?"}
    B -- no --> X[skip]
    B -- yes --> C{"type supported?"}
    C -- no --> X
    C -- yes --> D{"sizes == any?"}
    D -- "yes, SVG" --> S[best SVG candidate]
    D -- "yes, raster" --> F[fallback candidate]
    D -- no --> E{"square and within min..max?"}
    E -- no --> X
    E -- yes --> G["keep closest to ideal, prefer larger"]
    G --> H{"exact match?"}
    H -- yes --> R[use raster]
    H -- no --> P{"SVG policy"}
    P -- "second priority" --> S2["SVG, else fallback, else raster"]
    P -- "fallback" --> R2["raster, else SVG, else fallback"]

Two practical consequences follow:

  • Provide the sizes surfaces ask for. Without an exact match, Chromium scales down the next larger icon, which is fine, or scales up the largest smaller one, which is not.
  • An any-sized SVG can win on Android whenever no raster matches the device-specific ideal size exactly. That happens on most devices, because ideal sizes depend on screen density. Make sure the SVG looks right at launcher sizes.

Surface-by-surface: what each one requests

The values below come from Chromium's source (webapps_icon_utils, installable_icon_fetcher, shortcut_info, trusted_icon_filter and web_app_icon_generator). Android sizes are in dp, so multiply by the device scale factor: 3× on an xxhdpi phone and 4× on an xxxhdpi phone.

Surface Platform Purpose preference Ideal size Minimum / notes
Launcher icon (WebAPK) Chrome on Android maskable, then any Maskable: 83 dp (249 px @3×, 332 px @4×). Any: 48 dp (144 px @3×, 192 px @4×) Maskable ≥ 83 px. Any ≥ 48 dp × (density − 1)
Install bottom sheet / prompt Chrome on Android Same icon as the launcher as above Shows the primary icon
Splash screen Chrome on Android maskable, then any 128 dp (384 px @3×, 512 px @4×) ≥ 16 dp. See Splash Screens & Theming
App shortcuts Chrome on Android any only 48 dp ≥ 24 dp. See App Shortcuts
Installability check All Chromium any n/a ≥ 144 px, PNG/SVG/WebP, sizes declared. Desktop max 1024 px
Install and update dialogs ("trusted icon") Chromium desktop macOS and ChromeOS: maskable ≥ 256 px, then any. Windows and Linux: any Largest ≤ 1024 px An any-sized SVG is only a fallback and is rasterized at 1024 px
OS shortcut, taskbar, Dock, shelf Chromium desktop Windows and Linux: any. macOS and ChromeOS: maskable preferred Bitmaps generated at 32, 48, 64, 96, 128, 256 px Each is resized from the closest bitmap that is not smaller
Notification badge Chrome on ChromeOS monochrome 16 px Tinted with the app's theme_color
Home Screen / Dock Safari (iOS, iPadOS, macOS) apple-touch-icon, then manifest any 180 px (iPhone) Never uses maskable icons. See iOS below
Home-screen shortcut Firefox for Android Any or maskable ≥ 192 px n/a Maskable icons become Android adaptive icons

Desktop Chromium limits: 20 entries, 1,024 pixels

When desktop Chromium installs an app, it keeps at most 20 manifest icon entries. It counts each purpose of each icon separately, so "any maskable" uses two slots. Any size above 1,024 px is ignored on desktop. Entries past the limit are silently dropped, so a manifest with 30 Windows tile sizes followed by the maskable icons can lose the maskable icons altogether. List the important icons first. On Android there is no maximum size and no such cap.

Where installed desktop apps get their icons

On Windows, macOS, Linux and ChromeOS, Chromium downloads the chosen icons, discards bitmaps that are empty, non-square or larger than 1,024 px, and generates a fixed set of 32, 48, 64, 96, 128 and 256 px bitmaps. Each is resized from the closest downloaded bitmap that is not smaller, or from the largest one if none is big enough. It uses those bitmaps for the shortcut file, the taskbar or Dock, the window icon and the app management pages. If no icon can be downloaded, Chromium generates a letter icon from the app name.

On macOS, Chromium clips maskable icons to Apple's app-icon grid. On a 1,024 px canvas that grid is a rounded rectangle inset 100 px with a corner radius of 184 px, composited over white with a subtle drop shadow. Apple's rounded rectangle is never smaller than the maskable safe zone, so a correctly designed maskable icon comes out intact and looks like a native macOS icon. Without a maskable icon, your any icon is used as is, and a full-bleed square any icon then looks out of place in the Dock.

A baseline set that works everywhere

File Size purpose Why
icon-192.png 192×192 any Android launcher at 4× (48 dp), Chromium install fallback
icon-512.png 512×512 any Splash screen at 4×, desktop install dialog, Windows and Linux large sizes
maskable-192.png 192×192 maskable Android adaptive icon at lower densities
maskable-512.png 512×512 maskable Android at 4× (332 px ideal), splash, macOS and ChromeOS (≥ 256 px preferred)
apple-touch-icon.png 180×180 (HTML link) iPhone and iPad Home Screen, Safari on macOS
favicon.ico + icon.svg 16/32/48 + vector (HTML link) Browser tabs, bookmarks, history

That set is the minimum. Add sizes where platforms have exact slots, because exact matches avoid resampling.

Android (Chrome, Samsung Internet, Firefox)

  • Include any icons at 144 (48 dp at 3×) and 192 (48 dp at 4×), plus 512 for splash screens.
  • Include maskable icons at 192 and 512. The 512 px maskable covers the 332 px ideal on 4× devices by downscaling. Adding a 384 maskable gives an exact splash match at 3×.
  • Chrome converts a maskable icon into an Android adaptive icon. Android adaptive icons use a 108 dp canvas with a 66 dp safe circle (about 61%), while the web spec's safe circle is 80% of the icon. To reconcile the two, Chrome pads the maskable image by about 15% on each side. That is where the 83 dp ideal size comes from: 108 dp ÷ (0.8 ÷ 0.611).
  • For WebAPKs, the Android package is minted once and updated later, so icon changes follow the update rules. For Play Store distribution, see Trusted Web Activity.

Windows (Edge and Chrome)

Chromium on Windows uses any icons and ignores maskable ones. Windows shows app icons at many "target sizes" (Start, taskbar, Alt+Tab, Task Manager, Settings), and Microsoft recommends supplying 16, 24, 32, 48 and 256 at minimum, with 20, 30, 36, 40, 60, 64, 72, 80 and 96 as optional extras. Chromium derives its own 32–256 set from whatever you supply, so in practice 48, 96, 192, 256 and 512 any icons cover the browser-installed case.

Microsoft Store packages (generated with PWABuilder) are a different case. They use tile assets at five scale factors (44×44 app icon, 71×71, 150×150, 310×150, 310×310, 50×50 store logo, 620×300 splash, each at 1×, 1.25×, 1.5×, 2× and 4×). PWABuilder's image generator produces these from one source image. Keep them out of the web manifest unless you need them there. They eat into desktop Chromium's 20-entry budget.

macOS (Chromium and Safari)

  • Chromium: a maskable icon of at least 256 px (use 512) gets masked into the macOS rounded-rectangle grid. Otherwise the any icon appears unmodified.
  • Safari web apps (File → Add to Dock): Safari uses the apple-touch-icon or manifest any icons, and the user can pick a different image in the Add to Dock sheet. Give the 180 px touch icon an opaque background, and ideally supply a 512 px or SVG manifest icon for Retina Dock sizes.

ChromeOS

ChromeOS prefers maskable icons (≥ 256 px for security surfaces) and shows them in a circular shelf and launcher mask. It is also the one platform where Chromium currently uses monochrome icons: the notification badge of an installed app is the monochrome icon filled with the app's theme_color. Supply a 96 px or larger monochrome icon if your app sends notifications.

iOS and iPadOS: apple-touch-icon and its precedence

Safari has supported manifest icons since Safari 15.4 on iOS (Safari 17 on macOS), with two important rules:

  1. apple-touch-icon takes precedence. WebKit's Safari 15.4 announcement says icons defined with apple-touch-icon override manifest icons, to keep behavior consistent for existing sites. Manifest icons are used only when no touch icon is present.
  2. Only purpose any counts. Safari uses manifest icons whose purpose is missing or any. Maskable and monochrome icons are ignored.

Apple's Safari Web Content Guide also documents a fallback. When no icon is declared with a link element, iOS searches the site root for files with the apple-touch-icon… prefix (for example apple-touch-icon-80x80.png, then apple-touch-icon.png). Treat that as a legacy safety net and declare the file explicitly.

index.html (head)
<!-- 180×180: iPhone @3x. iOS downscales it where it needs a smaller size (iPad 167, 152). -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-4f7c1a9e2b.png">
<!-- Optional exact iPad sizes, if your artwork needs pixel-perfect variants -->
<link rel="apple-touch-icon" sizes="167x167" href="/icons/apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/apple-touch-icon-152.png">

Rules that follow from how iOS renders Home Screen icons:

  • Make it opaque and full-bleed. iOS applies its own rounded-corner mask and does not preserve transparency. Transparent regions end up filled with a color you do not control. Draw your own background color edge to edge and do not pre-round the corners.
  • Size: 180×180 is the value Apple documents for iPhone (60 pt @3×). When no exact size matches, iOS picks the smallest larger icon, and if none exists, the largest available.
  • SVG is accepted from Safari 26, which added SVG support "everyplace there are icons in the interface" and data-URL icons. Keep a PNG touch icon for earlier versions, which are still in use.
  • iOS 26 opens any site added to the Home Screen as a web app by default, with or without a manifest, and the user can turn "Open as Web App" off. Icons from the manifest are still used, so the icon matters even for sites that never planned to be installed.
  • The icon is captured when the user adds the app. Changing it later does nothing on devices that already have it. Users have to remove and re-add the app.

The iOS & iPadOS page covers the rest of the Apple-specific behavior.

Maskable icons in depth

A maskable icon is a full-bleed square image that the platform may crop to any shape: circle, squircle, rounded square, teardrop, or whatever an Android OEM launcher or the macOS grid uses. The spec guarantees exactly one thing, the safe zone.

The safe zone math

The safe zone is the area within a maskable icon which is guaranteed to always be visible, regardless of user agent preferences. It is defined as a circle with center point in the center of the icon and with a radius of ⅖ (40%) of the icon size [...]

The spec also constrains the browser: it MUST NOT make any pixel inside the safe zone transparent, it MAY apply a mask of any size outside it, it MAY add padding, and if the icon has transparent pixels it MUST composite the icon onto a solid fill of its choosing (for example white).

Icon size Safe circle diameter (80%) Largest centered square inside the circle (56.6%) Margin outside the circle at the middle of each edge
192 px 153.6 px 108.6 px up to 19.2 px
512 px 409.6 px 289.6 px up to 51.2 px
1024 px 819.2 px 579.3 px up to 102.4 px

The "largest square" column is the one that matters for layout. A square logo, or any artwork with important corners, has to fit inside a centered square that is 0.8 ÷ √2 ≈ 56.6% of the icon. A circular logo can use the full 80% diameter. The generator scripts below default to 56% for this reason.

What each platform does with a maskable icon

Platform Behavior
Chrome on Android (WebAPK) Pads the image by about 15% per side and hands it to Android as an adaptive icon, so the launcher's mask shape applies. Also preferred for the splash screen.
Firefox for Android Creates the home-screen shortcut as an adaptive bitmap when the chosen icon is maskable.
Chromium on ChromeOS Preferred for the launcher, shelf and install dialogs. Masked to a circle.
Chromium on macOS Preferred (≥ 256 px) and clipped to Apple's rounded-rectangle icon grid with a shadow.
Chromium on Windows and Linux Not used for OS integration. any icons are used instead.
Safari (all platforms) Ignored.

Designing maskable icons

  • Background to the edge. Fill the whole square with a solid color or gradient. Any transparent pixel will be composited onto a fill the browser chooses.
  • Content inside 56% (square content) or 80% (round content) of the canvas, centered.
  • No drop shadows or rounded corners baked in. The platform supplies the shape, and on macOS, Chromium adds its own shadow.
  • Start from a 1,024 px or vector master and export at 192 and 512, plus 384 if you want an exact 3× splash match.

Why not "purpose": "any maskable"?

A combined icon has to satisfy both contracts. A correct maskable icon has about 22% padding per side and an opaque background. Shown on a surface that expects an any icon (Windows taskbar, install dialog, Linux launcher), it looks like a small logo floating in a colored square. An icon designed as any (tight crop, transparent corners) gets its edges cut off when masked. web.dev's guidance is the same: using maskable icons as any icons adds unnecessary padding. Ship two files.

Testing maskable icons

  • Maskable.app shows your icon under every common mask shape, and its editor lets you adjust padding and background and export.
  • Chrome DevTools → Application → Manifest → Icons. Tick "Show only the minimum safe area for maskable icons", and DevTools crops each maskable icon to the safe circle. If the logo survives, the icon is correct. See Browser DevTools.
  • ImageMagick overlay. Draw the safe circle on the exported file (command below) and check it in your review tool or CI artifact.

Monochrome icons

What the spec says

A monochrome icon is displayed as a solid-fill silhouette. The spec says the color information "is discarded and only the alpha data is used", and the browser MUST NOT display the red, green or blue components independently. It SHOULD display each pixel with its original alpha and an RGB value of its choosing. In effect, your icon becomes a mask over a color the browser or OS picks.

Where monochrome icons are used today

  • ChromeOS notifications: Chromium draws a 16 px badge from the monochrome icon, tinted with the manifest theme_color. Chromium's own test asserts that a red theme_color produces a red badge.
  • Desktop Chromium shortcut menus: Chromium stores any, maskable and monochrome variants of shortcut icons separately, so monochrome icons declared on shortcuts are preserved.
  • Chrome on Android: the WebAPK protocol currently marks the monochrome purpose as unused, so manifest monochrome icons do not become Android 13+ themed icons today.
  • Safari: ignored.

Notification badges passed to showNotification({ badge }) are a separate mechanism with the same alpha-only rendering on Android. See Notifications API.

Authoring rules

  • Draw a single-color silhouette, meaning pure black or white with alpha. Every non-transparent pixel becomes fill.
  • Cut details out as transparency. A white letter on a blue circle has an alpha channel that is just a circle, so the monochrome result is a featureless disc. The generator below takes a separate --monochrome silhouette file for this reason.
  • Anti-aliased edges are fine (partial alpha becomes partial coverage), but avoid soft shadows and low-opacity decoration. They turn into grey smudges.
  • Use the same padding as the maskable icon, because the badge renderer may crop or pad.
icons/monochrome.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="512" height="512">
  <!-- evenodd cuts the letterform out of the disc, so the detail survives alpha-only rendering -->
  <path fill-rule="evenodd"
        d="M50 22a28 28 0 1 1 0 56a28 28 0 1 1 0-56zM40 36v29h7v-8h7a10.5 10.5 0 0 0 0-21z"/>
</svg>

SVG, PNG, WebP and other formats

Format Chromium (installability + OS icons) Safari (Home Screen, Dock) Firefox for Android Recommendation
PNG ✅ ✅ ✅ Default for every manifest icon
SVG (sizes: "any") ✅ (rasterized; desktop install dialogs rasterize at 1024 px) ✅ Safari 26+ ⚠️ Add alongside PNGs, never instead of them
WebP ✅ ⚠️ not documented for icons ⚠️ Only if you also ship PNG fallbacks
JPEG ❌ for installability ⚠️ ⚠️ Avoid. No alpha, not accepted for installability
ICO ❌ for installability n/a n/a Favicons only
data: URL ✅ ✅ Safari 26+ ⚠️ Tiny glyphs only

⚠️ = not documented by the vendor or behavior varies. Test on real devices.

SVG caveats:

  • Serve SVG as image/svg+xml. Some browsers ignore SVG favicons served with any other type.
  • Chromium renders SVG favicons in secure static mode: no scripts, no external resources, no web fonts. Outline text before exporting.
  • Give the root <svg> a viewBox. A small intrinsic width/height can cause blurry rasterization in some pipelines.
  • An SVG with sizes: "any" can beat your PNGs on Android when no PNG matches the device's ideal size exactly (see the selection algorithm above). Check it at 144–332 px, not only at 16 px.

Favicons and how they relate to manifest icons

Favicons (<link rel="icon">) and manifest icons are separate systems that sometimes feed into each other:

  • Tabs, bookmarks and history use favicons. Installed app windows and OS surfaces use manifest icons.
  • Browsers request /favicon.ico at the origin root when a page declares no rel="icon". Keep the file there under that exact name. It cannot be fingerprinted.
  • Chromium's "install any site" fallback: Chromium can create an app for a site without a usable manifest. It then falls back to the page's favicon for the icon (on Android only if the favicon is large enough), which is one more reason to ship a high-resolution favicon.
  • Safari 26 accepts SVG favicons. Chrome has supported them since 80 and Firefox since 41.

The modern minimal favicon markup:

index.html (head)
<!-- sizes="32x32" stops Chromium from preferring the ICO over the SVG -->
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-4f7c1a9e2b.png">
<link rel="manifest" href="/manifest.webmanifest">

The sizes="32x32" on the ICO line is a deliberate workaround, documented in Evil Martians' favicon guide, for a Chrome bug in which Chrome chooses the ICO file over the SVG when both are declared. Browsers without SVG favicon support still fall back to the ICO.

For dark mode, put a prefers-color-scheme media query inside the SVG favicon. The media attribute on <link rel="icon"> has worked in Chromium since version 91, but Firefox does not support it, so the in-SVG query is the portable option:

icon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
  <style>
    path { fill: #0b57d0; }
    @media (prefers-color-scheme: dark) { path { fill: #a8c7fa; } }
  </style>
  <path fill-rule="evenodd" d="M16 3a13 13 0 1 0 0 26a13 13 0 1 0 0-26zm-4 7v13h3v-4h3a4.5 4.5 0 0 0 0-9z"/>
</svg>

Generating icon sets

Start from one master: an SVG, or a PNG of at least 1,024×1,024. Keep a separate silhouette SVG for monochrome icons if your logo has interior detail. Every tool below produces the same kinds of assets. They differ in how much they automate and whether they fit your build.

pwa-asset-generator

pwa-asset-generator (8.x, Node.js ≥ 22.12) renders your logo in headless Chrome through Puppeteer. It emits manifest icons at 192 and 512, a 180 px apple-touch-icon, an optional favicon and Windows tiles, and every iOS splash image. It can also rewrite your manifest and index.html in place.

Terminal
# "any" icons: PNG with transparency, declared only as "any", patch manifest + HTML
npx pwa-asset-generator logo.svg public/icons \
  --icon-only --type png --opaque false --maskable false \
  --favicon --manifest public/manifest.webmanifest --index public/index.html

# Separate maskable files: opaque background, 22% padding per side (logo in the central 56%).
# No --manifest here: declare these files yourself with "purpose": "maskable".
npx pwa-asset-generator logo.svg public/icons/maskable \
  --icon-only --type png --background "#0b57d0" --padding "22%"

Know its defaults before you trust them:

  • --type defaults to jpg (manifest icons are always PNG), and --opaque defaults to true, which means a white canvas. Pass --type png --opaque false if you want transparency anywhere.
  • --maskable defaults to true and declares each manifest icon twice, once with "purpose": "any" and once with "purpose": "maskable", both pointing at the same file. With the default 10% padding (--padding "10%"), that file is too tight to be a safe maskable icon for square logos and too loose to be a crisp any icon. The two runs above avoid that. The first writes tight any icons into the manifest with --maskable false. The second produces padded, opaque files that you declare as maskable yourself.
  • On CI as root, Chromium's sandbox needs --no-sandbox. The README advises against it where you can avoid it.

@vite-pwa/assets-generator

@vite-pwa/assets-generator is the icon tool of the Vite PWA ecosystem. It is built on sharp and has presets. Version 2.0 (September 2026) is ESM-only and needs Node.js ≥ 20.19. It integrates with vite-plugin-pwa, which can inject the generated icons and <link> tags at build time.

pwa-assets.config.ts
import {
  defineConfig,
  minimal2023Preset as preset,
} from "@vite-pwa/assets-generator/config";

export default defineConfig({
  headLinkOptions: { preset: "2023" }, // ICO sizes="48x48", SVG icon, apple-touch-icon
  preset: {
    ...preset,
    // Defaults: transparent padding 0.05, maskable and apple padding 0.3 on white.
    maskable: { ...preset.maskable, resizeOptions: { fit: "contain", background: "#0b57d0" } },
    apple: { ...preset.apple, resizeOptions: { fit: "contain", background: "#0b57d0" } },
  },
  images: ["public/logo.svg"],
});
package.json (excerpt)
{ "scripts": { "generate-pwa-assets": "pwa-assets-generator" } }

The minimal-2023 preset generates transparent pwa-64x64.png, pwa-192x192.png and pwa-512x512.png, a maskable-icon-512x512.png, an apple-touch-icon-180x180.png and a 48 px favicon.ico. That matches the baseline set above, minus a 192 px maskable, which you can add through maskable.sizes.

PWABuilder Image Generator

The PWABuilder Image Generator is a web form. Upload one image, choose padding and a background (transparent, a best guess, or a custom color), tick the target platforms (Windows 11, Android, iOS), and download a zip containing every size those platforms use. It is the quickest way to get the Windows Store tile sizes that PWABuilder packaging expects. The same service backs PWABuilder's Windows packaging. If you do not upload images there, the packager derives the tiles from the largest square PNG or JPG icon of at least 512 px in your manifest, so a crisp 512 px any icon matters for Store builds too. Review the output before you commit it: it is a starting point, not a design.

A Node.js script with sharp

A script you own is the most controllable option. It lives in your repository, runs in CI, fingerprints file names and rewrites the manifest. The script below needs only sharp. It produces any, maskable and monochrome PNGs, an opaque 180 px Apple touch icon, and a multi-size favicon.ico built with a small ICO writer, so there is no extra dependency. It also emits icons.json and head.html, and it can optionally replace the manifest's icons array.

scripts/generate-icons.mjs
#!/usr/bin/env node
// Generate a complete, fingerprinted PWA icon set from one source logo.
//
//   node scripts/generate-icons.mjs logo.svg public/icons \
//     --background "#0b57d0" --manifest public/manifest.webmanifest
//
// Requires Node 20.9+ and `npm i -D sharp` (0.33 or later).
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import path from "node:path";
import { parseArgs } from "node:util";
import sharp from "sharp";

const { values: opts, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    background: { type: "string", default: "#ffffff" }, // fill for maskable + apple icons
    "any-scale": { type: "string", default: "0.9" }, // logo box / canvas for "any" icons
    "maskable-scale": { type: "string", default: "0.56" }, // fits a square logo in the 40% safe circle
    "apple-scale": { type: "string", default: "0.75" },
    monochrome: { type: "string" }, // optional silhouette source (shape encoded in alpha)
    manifest: { type: "string" }, // optional: rewrite the "icons" member in place
    "url-prefix": { type: "string", default: "/icons/" }, // how the files are served
  },
});

const [source, outDir = "icons"] = positionals;
if (!source) {
  console.error("Usage: generate-icons.mjs <logo.svg|png> [out-dir] [--background #hex]");
  process.exit(64); // EX_USAGE
}

const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
const ANY_SIZES = [48, 72, 96, 128, 144, 192, 256, 384, 512];
const MASKABLE_SIZES = [192, 512];
const MONOCHROME_SIZES = [96, 512];
const APPLE_SIZE = 180;
const ICO_SIZES = [16, 32, 48];

/** Short content hash so every visual change gets a new URL (icons are treated as immutable). */
const fingerprint = (buf) => createHash("sha256").update(buf).digest("hex").slice(0, 10);

async function loadMaster(file) {
  const input = await readFile(file);
  const isSvg = file.toLowerCase().endsWith(".svg");
  // Rasterize vectors at roughly 2048 px so downscaling, not upscaling, produces every
  // size. The density is derived from the SVG's own size (metadata reports it at 72 dpi):
  // a fixed high density on a large SVG would exceed sharp's input pixel limit.
  let density;
  if (isSvg) {
    const { width = 512, height = 512 } = await sharp(input).metadata();
    density = Math.min(2400, Math.max(72, Math.ceil((72 * 2048) / Math.max(width, height))));
  }
  const image = sharp(input, isSvg ? { density } : {});
  const meta = await image.metadata();
  if (!isSvg && Math.min(meta.width ?? 0, meta.height ?? 0) < 1024) {
    console.warn(`warning: ${file} is ${meta.width}x${meta.height}; use >= 1024px or an SVG`);
  }
  // Trim transparent margins so the *-scale options describe the visible logo, not the canvas.
  const trimmed = await image.ensureAlpha().trim({ background: TRANSPARENT }).png().toBuffer();
  return sharp(trimmed)
    .resize(2048, 2048, { fit: "contain", background: TRANSPARENT })
    .png()
    .toBuffer();
}

/** Center the logo, scaled to `scale` of the canvas, on a transparent or solid square. */
async function compose(master, size, scale, background = TRANSPARENT) {
  const inner = Math.max(1, Math.round(size * scale));
  const logo = await sharp(master)
    .resize(inner, inner, { fit: "contain", background: TRANSPARENT, kernel: "lanczos3" })
    .png()
    .toBuffer();
  return sharp({ create: { width: size, height: size, channels: 4, background } })
    .composite([{ input: logo, gravity: "center" }])
    .png()
    .toBuffer();
}

/** Opaque output: no alpha channel at all, so no platform can show "holes" or black corners. */
const opaque = (buf, background) =>
  sharp(buf).flatten({ background }).png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();

const optimized = (buf) => sharp(buf).png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();

/** Keep only coverage: black RGB + the logo's alpha channel (the UA picks the real color). */
async function monochrome(master, size, scale) {
  const shape = await compose(master, size, scale);
  const alpha = await sharp(shape).extractChannel("alpha").toBuffer();
  return sharp({ create: { width: size, height: size, channels: 3, background: "#000000" } })
    .joinChannel(alpha)
    .png({ compressionLevel: 9 })
    .toBuffer();
}

/** Minimal ICO writer: PNG-compressed entries (supported by every current browser and OS). */
function pngsToIco(images) {
  const header = Buffer.alloc(6 + 16 * images.length);
  header.writeUInt16LE(0, 0); // reserved
  header.writeUInt16LE(1, 2); // type 1 = icon
  header.writeUInt16LE(images.length, 4);
  let offset = header.length;
  images.forEach(({ size, data }, i) => {
    const entry = 6 + 16 * i;
    header.writeUInt8(size >= 256 ? 0 : size, entry); // 0 means 256
    header.writeUInt8(size >= 256 ? 0 : size, entry + 1);
    header.writeUInt8(0, entry + 2); // palette size
    header.writeUInt8(0, entry + 3); // reserved
    header.writeUInt16LE(1, entry + 4); // color planes
    header.writeUInt16LE(32, entry + 6); // bits per pixel
    header.writeUInt32LE(data.length, entry + 8);
    header.writeUInt32LE(offset, entry + 12);
    offset += data.length;
  });
  return Buffer.concat([header, ...images.map((image) => image.data)]);
}

async function main() {
  const background = opts.background;
  const anyScale = Number(opts["any-scale"]);
  const maskableScale = Number(opts["maskable-scale"]);
  const appleScale = Number(opts["apple-scale"]);
  for (const [name, v] of Object.entries({ anyScale, maskableScale, appleScale })) {
    if (!(v > 0 && v <= 1)) throw new RangeError(`${name} must be in (0, 1], got ${v}`);
  }
  if (maskableScale > 0.8) {
    console.warn("warning: maskable-scale > 0.8 puts the logo outside the safe zone");
  }

  await mkdir(outDir, { recursive: true });
  const master = await loadMaster(source);
  const icons = [];
  const written = [];

  async function emit(basename, buf) {
    const file = `${basename}-${fingerprint(buf)}.png`;
    await writeFile(path.join(outDir, file), buf);
    written.push(file);
    return opts["url-prefix"] + file;
  }

  for (const size of ANY_SIZES) {
    const buf = await optimized(await compose(master, size, anyScale));
    icons.push({ src: await emit(`icon-${size}`, buf), sizes: `${size}x${size}`, type: "image/png", purpose: "any" });
  }
  for (const size of MASKABLE_SIZES) {
    const buf = await opaque(await compose(master, size, maskableScale, background), background);
    icons.push({ src: await emit(`maskable-${size}`, buf), sizes: `${size}x${size}`, type: "image/png", purpose: "maskable" });
  }
  // Alpha-only icons need a silhouette whose details are cut out as transparency;
  // a full-color logo's alpha is usually just its outline.
  const silhouette = opts.monochrome ? await loadMaster(opts.monochrome) : master;
  for (const size of MONOCHROME_SIZES) {
    const buf = await monochrome(silhouette, size, maskableScale);
    icons.push({ src: await emit(`monochrome-${size}`, buf), sizes: `${size}x${size}`, type: "image/png", purpose: "monochrome" });
  }

  // iOS/iPadOS: opaque 180x180, referenced from HTML rather than the manifest.
  const appleBuf = await opaque(await compose(master, APPLE_SIZE, appleScale, background), background);
  const appleHref = await emit("apple-touch-icon", appleBuf);

  // favicon.ico keeps its fixed name: browsers request /favicon.ico without any markup.
  const icoImages = [];
  for (const size of ICO_SIZES) {
    icoImages.push({ size, data: await optimized(await compose(master, size, 1)) });
  }
  await writeFile(path.join(outDir, "favicon.ico"), pngsToIco(icoImages));
  written.push("favicon.ico");

  // icon.svg is your hand-optimized vector favicon; this script does not generate it.
  const head = [
    `<link rel="icon" href="/favicon.ico" sizes="32x32">`,
    `<link rel="icon" href="/icon.svg" type="image/svg+xml">`,
    `<link rel="apple-touch-icon" href="${appleHref}">`,
    `<link rel="manifest" href="/manifest.webmanifest">`,
  ].join("\n");
  await writeFile(path.join(outDir, "icons.json"), JSON.stringify(icons, null, 2) + "\n");
  await writeFile(path.join(outDir, "head.html"), head + "\n");

  if (opts.manifest) {
    const manifest = JSON.parse(await readFile(opts.manifest, "utf8"));
    manifest.icons = icons; // replace wholesale: stale entries are a common source of bugs
    await writeFile(opts.manifest, JSON.stringify(manifest, null, 2) + "\n");
  }

  console.log(`wrote ${written.length} files to ${outDir}`);
  console.log("copy favicon.ico to the site root; serve fingerprinted files with");
  console.log('"Cache-Control: public, max-age=31536000, immutable"');
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Run it after any logo change and commit the output, or run it in the build:

Terminal
npm i -D sharp
node scripts/generate-icons.mjs brand/logo.svg public/icons \
  --background "#0b57d0" --monochrome brand/silhouette.svg \
  --manifest public/manifest.webmanifest
cp public/icons/favicon.ico public/favicon.ico

Design decisions worth keeping if you adapt it:

  • Trim, then scale. Trimming transparent margins makes --maskable-scale 0.56 mean "the visible logo occupies 56% of the canvas", which is exactly the largest square that fits in the safe circle.
  • Flatten maskable and Apple icons. Removing the alpha channel altogether guarantees that no platform can show a black or white hole where a stray transparent pixel was.
  • Content-hashed names. A new image gets a new URL, which is what browsers use to detect icon updates (see below), and it lets you serve icons with immutable caching.

ImageMagick commands

ImageMagick 7 (magick) is enough for a shell-only pipeline. -density rasterizes vectors large before downscaling. -trim +repage removes margins. -extent pads to a square canvas. -alpha remove -alpha off flattens onto the background.

scripts/make-icons.sh
#!/usr/bin/env bash
# Generate a PWA icon set with ImageMagick 7 ("magick"). Usage: ./make-icons.sh logo.svg "#0b57d0"
set -euo pipefail
SRC=${1:?source SVG or >=1024px PNG}
BG=${2:-#ffffff}
MONO_SRC=${3:-$SRC}          # optional silhouette for monochrome icons
RASTER=(-background none -density 1200)   # rasterize vectors large, then downscale

# "any" icons: transparent canvas, logo fills ~90% of the square.
for size in 48 72 96 128 144 192 256 384 512; do
  inner=$(( size * 90 / 100 ))
  magick "${RASTER[@]}" "$SRC" -trim +repage -resize "${inner}x${inner}" \
    -gravity center -extent "${size}x${size}" -strip "icon-${size}.png"
done

# maskable icons: logo inside the 40%-radius safe circle, opaque background, alpha removed.
for size in 192 512; do
  inner=$(( size * 56 / 100 ))
  magick "${RASTER[@]}" "$SRC" -trim +repage -resize "${inner}x${inner}" \
    -background "$BG" -gravity center -extent "${size}x${size}" \
    -alpha remove -alpha off -strip "maskable-${size}.png"
done

# monochrome icons: RGB forced to black, alpha channel preserved.
for size in 96 512; do
  inner=$(( size * 56 / 100 ))
  magick "${RASTER[@]}" "$MONO_SRC" -trim +repage -resize "${inner}x${inner}" \
    -gravity center -extent "${size}x${size}" \
    -channel RGB -evaluate set 0 +channel -strip "monochrome-${size}.png"
done

# iOS/iPadOS: opaque 180x180.
magick "${RASTER[@]}" "$SRC" -trim +repage -resize 135x135 \
  -background "$BG" -gravity center -extent 180x180 -alpha remove -alpha off -strip apple-touch-icon.png

# favicon.ico with 16, 32 and 48 px images inside one file.
magick "${RASTER[@]}" "$SRC" -trim +repage -resize 256x256 -gravity center -extent 256x256 \
  -define icon:auto-resize=48,32,16 favicon.ico

Check a maskable icon against the safe zone by drawing the 40%-radius circle on it. For a 512 px icon, that is a circle centered at 256,256 through a point 204.8 px above the center:

Terminal
magick maskable-512.png -fill none -stroke "#ff0000" -strokewidth 4 \
  -draw "circle 256,256 256,51" safe-zone-check.png
# Verify there is no alpha channel left (prints "True")
magick identify -format "%[opaque]\n" maskable-512.png
# Inspect what is inside favicon.ico
magick identify favicon.ico

Serving, caching and updating icons

HTTP and service worker caching

Icons are fetched by the browser's installer, not by your page, but they still go through HTTP caching and your service worker:

Response headers for /icons/*
Content-Type: image/png
Cache-Control: public, max-age=31536000, immutable
  • Use immutable only on fingerprinted URLs. Serve /favicon.ico and the manifest with short or revalidating cache lifetimes. See HTTP Caching & Service Workers.
  • There is no need to precache icons for installation. If your offline shell renders the logo, precache one small icon for that purpose. See Precaching & Runtime Caching.
  • A service worker fetch handler that returns an offline HTML page for failed image requests hands the installer an HTML document labelled as an icon. Return an image fallback, or let image requests fail.

How icon changes reach installed apps

The manifest spec describes icons as a security-sensitive member (along with name and short_name): a changed icon SHOULD be shown to the user, and applied only with their permission. For detecting changes, the spec says a browser SHOULD consider an icon updated if its src changed. If src is the same, the browser MAY download the image and compare it, similar to Cache-Control: immutable semantics. In practice:

Platform What happens when you change an icon
Chromium desktop A change is detected from the icon entries (URL, sizes, purpose), not from the bytes. Changed icons are downloaded and compared with the installed ones. A visual difference under 10% is applied silently, at most once a day. Larger changes wait for the user to review them in an app identity update dialog.
Chrome on Android Icon changes ride on the WebAPK update check (at most once a day, when the app is launched). A pixel difference of less than 11% (Chromium's WEB_APK_ICON_UPDATE_BLOCKED_AT_PERCENTAGE) ships with the updated WebAPK. A larger change needs a confirmation dialog that current Chromium disables by default (PwaUpdateDialogForIcon), so it is not applied.
Safari (iOS, iPadOS) Never. The icon is fixed when the user adds the app.

The detailed update triggers, throttles and dialogs are covered in App Identity & Updates. For icons, the rule is simple: change the URL when you change the pixels. Content-hashed file names do that automatically.

Common mistakes

Only maskable icons. A manifest whose icons are all "purpose": "maskable" has no any icon. Chromium reports Manifest does not contain a suitable icon and does not offer installation. Safari ignores every entry.

Transparent maskable icons. The spec requires browsers to composite transparent pixels onto a solid fill of their choosing, often white. A logo with transparent corners shows up on Android as a logo on a white circle or squircle. Flatten maskable icons onto your own background.

Logo too large inside the maskable canvas. Content that reaches the edges gets cropped by circular masks. Keep square content within the central 56% and round content within the central 80%.

"any maskable" on one file. It is either too small on any surfaces or clipped on masked ones. Ship two files.

Declared sizes that lie. sizes drives selection before download. Declaring a 1,024 px file as 192x192 wastes bandwidth. Declaring a small file as 512x512 makes splash screens and install dialogs upscale it.

Missing sizes. Chromium's selector iterates over declared sizes, so an icon without sizes can never satisfy the installability check. The error message says it directly: "the sizes attribute must be set".

JPEG or extension-less icons without type. JPEG never counts for installability. /icon?size=512 without a type has no detectable type, so Chromium skips it.

Icons only larger than 1,024 px. Desktop Chromium ignores sizes over 1,024 px. A manifest whose only large icon is 2,048 px falls back to smaller ones on desktop.

Too many entries. Desktop Chromium keeps 20 icon entries, counting each purpose separately. Put the 192/512 any and maskable icons first, and move Store-only tile sizes out of the web manifest.

Relative paths resolved against the wrong base. src resolves against the manifest URL. Moving the manifest to a subdirectory breaks relative icon paths.

CSP blocks the icon. A strict img-src that does not include your icon host makes the fetch fail silently from the page's point of view. DevTools shows Could not download a required icon from the manifest.

Uppercase or misspelled purposes. "Maskable" works in Chromium but not per spec. "maskabel" alone drops the icon.

Stale icons after a redesign. A new image at the same URL may never be detected, and HTTP caches may serve the old bytes. Fingerprint the file names. On iOS, only removing and re-adding the app changes the icon.

A monochrome icon derived from a full-color logo. Its alpha channel is usually just the outline, so the result is a blob. Draw a dedicated silhouette.

Debugging icons

  • Chrome/Edge DevTools → Application → Manifest. The Icons section lists every parsed icon with its purpose. The maskable safe-area toggle crops each maskable icon to the safe circle. The Installability section shows the exact failure, for example:
    • Manifest does not contain a suitable icon - PNG, SVG or WebP format of at least 144px is required, the sizes attribute must be set, and the purpose attribute, if set, must include "any"...
    • No supplied icon is at least 144px square in PNG, SVG or WebP format
    • Could not download a required icon from the manifest
    • Downloaded icon was empty or corrupted
  • Console warnings from the manifest parser, for example found icon with no valid purpose; ignoring it. and found icon with no valid size., show up when DevTools loads the manifest.
  • chrome://web-app-internals (desktop) dumps installed apps as JSON, including which icons Chromium downloaded and stored per purpose.
  • Android: inspect the device from chrome://inspect to see the same Manifest pane. After installation, the launcher is the source of truth, so check it under several launcher mask shapes if your device offers them.
  • Safari: use Web Inspector on the page to confirm the apple-touch-icon resolves (Network panel), then add to the Home Screen on a real device or Simulator.

The Browser DevTools page walks through these panels in detail. The Installability Criteria page covers the rest of Chromium's checks.

Complete example

Project layout
public/
├── favicon.ico                         # 16/32/48, fixed name, short cache
├── icon.svg                            # SVG favicon with dark-mode styles
├── manifest.webmanifest
└── icons/
    ├── icon-192-7e4bcf18b6.png         # any
    ├── icon-512-5c3f6a8426.png         # any
    ├── icon.svg                        # any, vector
    ├── maskable-192-74bfe61c47.png     # maskable, opaque
    ├── maskable-512-87ea73be35.png     # maskable, opaque
    ├── monochrome-96-3b3de54580.png    # monochrome, alpha only
    └── apple-touch-icon-0fbef7ffa8.png # 180×180, opaque
public/manifest.webmanifest
{
  "id": "/",
  "name": "Field Notes",
  "short_name": "Notes",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0b57d0",
  "icons": [
    { "src": "/icons/icon-192-7e4bcf18b6.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icons/icon-512-5c3f6a8426.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icons/maskable-192-74bfe61c47.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
    { "src": "/icons/maskable-512-87ea73be35.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
    { "src": "/icons/monochrome-96-3b3de54580.png", "sizes": "96x96", "type": "image/png", "purpose": "monochrome" },
    { "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
  ]
}
public/index.html (head)
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field Notes</title>
<meta name="theme-color" content="#0b57d0">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-0fbef7ffa8.png">
<link rel="manifest" href="/manifest.webmanifest">

Why this set works:

  • Chromium's installability check passes on the 192/512 PNG any icons, and the SVG covers large desktop sizes.
  • Android gets opaque maskable icons for the launcher and splash. ChromeOS and macOS get a maskable icon of at least 256 px for the install dialog and Dock or shelf.
  • Windows and Linux get any icons from which Chromium derives its 32–256 px set.
  • iOS and macOS Safari get an opaque 180 px touch icon, which overrides the manifest by design.
  • ChromeOS notification badges get a real silhouette.
  • The manifest has six entries (seven purpose slots), well under desktop Chromium's 20.

The Manifest Cheat Sheet puts this next to the other members. Tutorial: Your First PWA wires it into a working app.

Browser support

Support data as of September 2026. For live data see MDN: icons and caniuse: SVG favicons.

Feature Chrome / Edge (desktop) Chrome (Android) Safari (macOS) Safari (iOS / iPadOS) Firefox (desktop) Firefox (Android)
Manifest icons ✅ ✅ ✅ 17 ✅ 15.4 ⚠️ ❌ ✅
purpose: "maskable" ✅ macOS, ChromeOS ⚠️ ✅ 79 ❌ ❌ ❌ ✅
purpose: "monochrome" ⚠️ ChromeOS notifications ❌ ❌ ❌ ❌ ❌
SVG manifest icons ✅ ✅ ✅ 26 ✅ 26 ❌ ⚠️
icons_localized ✅ 148 ❌ ❌ ❌ ❌ ❌
apple-touch-icon n/a n/a ✅ ✅ (takes precedence) n/a n/a
SVG favicon ✅ 80 ✅ ✅ 26 ✅ 26 ✅ 41 ✅

⚠️ Safari uses manifest icons only when no apple-touch-icon is present, and only those with purpose any (or no purpose). Desktop Chromium uses maskable icons for OS integration on macOS and ChromeOS only. Windows and Linux use any icons. Firefox on Android supports maskable icons for home-screen shortcuts. Firefox desktop has no manifest-based installation. Its Windows taskbar web apps (Firefox 143+) do not process manifest icons according to MDN's compatibility data.

Further reading

On this site

External references