DEV Community

SEN LLC
SEN LLC

Posted on

What's actually in a modern favicon set (and why everyone gets it wrong)

What's actually in a modern favicon set (and why everyone gets it wrong)

A tiny Python CLI that takes one SVG or PNG and produces the full list of icons a modern website actually needs: multi-size ICO, nine PNGs, apple-touch, a maskable PWA icon with a proper safe zone, a webmanifest, and a copy-paste HTML snippet.

There is a strange conspiracy of silence around favicons. Every senior web developer has, at some point, opened a browser tab, seen a broken square where their logo should be, and silently promised to "fix it later." Later never comes, because the answer is neither one file nor ten files but somewhere around fourteen, half of which you have never heard of, and the canonical HTML block to reference them all is impossible to remember.

I got tired of this and wrote a CLI that does the whole thing in one command. favicongen takes a single source image (SVG or a PNG at least 512×512) and emits the complete set — favicon.ico with embedded sizes, PNGs at all the sizes modern browsers and operating systems actually ask for, apple-touch-icon, a maskable PWA icon with correct safe-zone padding, site.webmanifest, and a copy-paste favicons.html snippet you paste into <head>. It's about 400 lines of Python and one dependency.

🔗 GitHub: https://github.com/sen-ltd/favicongen

Screenshot

This article is the long form of the problem. I'll walk through what a correct modern favicon set actually contains, why SVG input is qualitatively better than PNG, the "render at target size vs rasterize-then-downscale" distinction, the maskable icon spec, and why the HTML snippet is a first-class output.

The problem: the full set is obscure

Let's enumerate what a well-installed modern favicon actually needs.

Browsers still use favicon.ico. Chrome, Firefox, and Safari will all accept PNG icons via <link rel="icon">, but older Windows utilities, Electron tools, and some feed readers still expect a literal favicon.ico file at the site root. The ICO format is a container that can hold multiple sizes, so a properly-built favicon.ico has 16×16, 32×32, and 48×48 embedded in one file. Most half-built favicon setups ship only a 16×16 ICO, which looks fuzzy in pinned tabs.

Multiple PNG sizes for the <link rel="icon"> tags. Different browsers pick different ones. 16 and 32 are for browser tabs; 48 is used by some Windows tile setups; 96 and 192 show up in Android Chrome, and 512 is required for PWA install prompts. The full set is 16, 32, 48, 64, 96, 128, 192, 256, 512.

apple-touch-icon.png at 180×180. iOS home-screen pinning uses a completely different tag (<link rel="apple-touch-icon">) that doesn't read the webmanifest. If you skip this, an iOS user who taps "Add to Home Screen" gets a squished screenshot of your page instead of your logo. iOS expects exactly 180×180 these days; older recommendations mention 152 and 167, which are still accepted but don't need to be separate files.

A maskable icon for Android PWAs. This one is the big unknown. When a user installs your PWA on Android, the OS decides the shape of the icon — circle, rounded square, squircle, teardrop, pentagon, depending on which OEM made the phone. The shape is applied as a mask to your icon, which means whatever falls outside the mask gets clipped. The W3C maskable icon spec reserves an 80% diameter circle in the center as the "safe zone": put your logo inside that and it survives every mask. Nothing outside the safe zone is guaranteed to show.

The wrong way to produce a maskable icon is to declare your regular 512×512 icon as maskable. Try this on a Pixel, and you'll see your logo clipped at the edges because Android is filling the corners with whatever background color your PWA specified. The right way is to scale your logo down to fit inside a 410×410 safe zone and pad the rest with your brand background color.

site.webmanifest. This is the PWA-installability JSON — name, short_name, theme_color, background_color, and an icons array. Chrome will not show an install prompt without a manifest that lists at least one 192-and-one-512 icon, and ideally a maskable 512. Most teams copy a template once and never look at it again, which is fine until the icons they reference have moved.

The HTML snippet. This is the worst part. You need roughly ten <link> tags in <head>, in a particular order, with precise sizes= attributes. I do not have this memorized, and neither does anyone else I have ever worked with. Everyone ends up copying it from whichever project they last shipped. If you miss the apple-touch-icon link, iOS is broken. If you miss the manifest link, PWA install is broken. If you miss one of the PNG entries, some browser somewhere picks an ugly fallback.

RealFaviconGenerator handles all of this, but it's a hosted web app that you paste your logo into. For one-off projects, fine. For a project-setup script, or a CI step, or a CLI you can run forty times and get the same output, it's the wrong shape. favicongen fills that specific niche.

Design: render at target size, don't rasterize-then-downscale

When your input is SVG, there are two ways to produce a 16×16 PNG. One is to rasterize the SVG once at some large size — say 512×512 — and then downscale with Lanczos to 16. The other is to rasterize the SVG directly at 16×16, handing the target width and height to the rasterizer before it starts.

These produce visibly different results at small sizes. Rasterize-then-downscale at 16 gives you a blurred version of the full logo; render-at-target at 16 gives you a crisp 16-pixel grid because the rasterizer's stem-width hinting places strokes on pixel boundaries. For body text-sized icons (16 and 32 pixels), this is the difference between a favicon you can read and one that looks like a smudge.

favicongen takes the second path. Every output size triggers a fresh render from the SVG source:

def render_svg(svg_bytes: bytes, size: int) -> Image.Image:
    png_bytes = cairosvg.svg2png(
        bytestring=svg_bytes,
        output_width=size,
        output_height=size,
    )
    img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
    if img.size != (size, size):
        img = img.resize((size, size), Image.LANCZOS)
    return img
Enter fullscreen mode Exit fullscreen mode

Note the normalization at the bottom: cairosvg can produce an off-by-one result on SVGs with a non-square viewBox, so we nudge the output to the exact target size as a defensive measure. In the common case it's a no-op.

The full renderer is a two-function dispatch. If the input is PNG, we decode it once, refuse anything smaller than 512×512 (scaling tiny logos up is the single biggest source of blurry favicons), and Lanczos-downscale per target size:

def render_at_size(
    source_bytes: bytes,
    source_format: str,
    size: int,
    *,
    cached_png: Image.Image | None = None,
) -> Image.Image:
    if source_format == "svg":
        return render_svg(source_bytes, size)
    if source_format == "png":
        img = cached_png if cached_png is not None else load_png(source_bytes)
        return downscale_png(img, size)
    raise ValueError(f"unknown source format: {source_format}")
Enter fullscreen mode Exit fullscreen mode

The cached_png kwarg is the small optimization: when the input is PNG, we decode it once and reuse the decoded image for all ten target sizes instead of re-parsing the bytes every loop.

The SVG rasterizer: cairosvg over resvg-py

I initially planned to use resvg-py, which wraps the Rust resvg crate and ships small pre-built wheels. It's a much smaller dependency than cairosvg — no cairo, no pango, no cffi — and in a clean Debian environment it installs instantly.

On Alpine musl-libc, which is the base image for the Docker build, wheel availability for resvg-py is inconsistent. At build time I couldn't get a clean pip install on python:3.12-alpine, and falling back to building from Rust source adds a full Cargo toolchain to the builder stage, which defeats the purpose. So this CLI ships with cairosvg. That brings in libcairo as a runtime dep and pushes the final Alpine image to about 90 MB, compared to the 40-something we would have had with resvg-py.

I'm noting this honestly because "use the small Rust thing" was the plan, and the plan didn't survive contact with Alpine. If you're running on glibc Linux or macOS, you can swap the cairosvg import in renderer.py for resvg-py and the rest of the code works unchanged. The abstraction is shallow on purpose.

Design: the maskable icon safe zone

The maskable icon is produced by a separate small function that takes any RGBA image and letterboxes it into a 512×512 canvas with a 410×410 safe zone:

CANVAS_SIZE = 512
SAFE_ZONE_SIZE = 410


def build_maskable(source: Image.Image, background: str = "#ffffff") -> Image.Image:
    bg_rgba = _parse_hex(background)
    canvas = Image.new("RGBA", (CANVAS_SIZE, CANVAS_SIZE), bg_rgba)

    src = source.convert("RGBA")
    w, h = src.size
    scale = SAFE_ZONE_SIZE / max(w, h)
    new_w = max(1, int(round(w * scale)))
    new_h = max(1, int(round(h * scale)))
    resized = src.resize((new_w, new_h), Image.LANCZOS)

    offset_x = (CANVAS_SIZE - new_w) // 2
    offset_y = (CANVAS_SIZE - new_h) // 2

    canvas.alpha_composite(resized, dest=(offset_x, offset_y))
    return canvas
Enter fullscreen mode Exit fullscreen mode

The logic is: fit the source inside the 410×410 safe zone while preserving aspect ratio, center it, fill the margin with your brand background color. That margin is what your Pixel or your Samsung turns into a circle, a squircle, or a teardrop depending on the launcher, and because nothing meaningful lives in it, clipping is harmless.

Two subtleties worth mentioning. First, the background color matters. If your logo is transparent and you set --background "#ffffff", the circle-masked version will have a white ring, which might clash with your launcher theme. The safest default for most brands is the same color as the logo's dominant edge — or, if your logo is centered on white, just white. Second, we letterbox inside the safe zone, not to the canvas edges. It's tempting to scale up so the logo fills all 512 pixels "because it looks bigger in the preview," but that's exactly the mistake that produces clipped icons on real devices.

Design: the HTML snippet as a first-class output

The last thing favicongen writes is favicons.html — a plain text file you paste into <head>. It looks like this:

def build_html_snippet(cfg: ManifestConfig) -> str:
    theme = cfg.theme_color
    lines = [
        '<link rel="icon" type="image/x-icon" href="favicon.ico">',
        '<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">',
        '<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">',
        '<link rel="icon" type="image/png" sizes="48x48" href="favicon-48x48.png">',
        '<link rel="icon" type="image/png" sizes="96x96" href="favicon-96x96.png">',
        '<link rel="icon" type="image/png" sizes="192x192" href="favicon-192x192.png">',
        '<link rel="icon" type="image/png" sizes="512x512" href="favicon-512x512.png">',
        '<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">',
        '<link rel="manifest" href="site.webmanifest">',
        f'<meta name="theme-color" content="{theme}">',
    ]
    return "\n".join(lines) + "\n"
Enter fullscreen mode Exit fullscreen mode

Ten lines. It is the single most valuable output of the whole program, because if you forget one of them, one specific platform has broken icons and you won't notice until someone complains. Having it written to a file next to the PNGs means you can cat favicons/favicons.html in a one-liner at the end of a deploy script, or grep for it when you're adjusting a template, or paste it into a JSX file without thinking about order.

The order matters a little: the ICO goes first because it's still the highest-compatibility fallback; the PNG icon links come next from smallest to largest because some browsers pick the first match that fits; apple-touch-icon and manifest come later because they're consumed by specific platforms that know where to look; and theme-color goes last as a meta tag, not a link. None of this is enforced by any spec, but it matches what every big production site does, and it's the least surprising order.

Tradeoffs I didn't handle

Things this tool deliberately does not do, which I considered and cut:

  • Dark-mode icons. You can have a favicon that inverts when the user's OS is in dark mode, via <link rel="icon" media="(prefers-color-scheme: dark)">. This requires you to provide two sources, and more importantly it requires you to care. Most people don't, and adding a flag for it would mean everyone has to think about it. Skipped.
  • Android adaptive icons. Android's native adaptive icon format is XML with a foreground and background layer, not PNG. Modern Chrome on Android uses the maskable PNG from the webmanifest instead, and that's what this tool targets. If you want platform-native adaptive icons, you're shipping an Android app, not a PWA, and you should use Android Studio.
  • Animated favicons. .ico can contain animated frames, some browsers support animated PNG, and Safari supports SVG favicons that can contain CSS animations. I'll pass.
  • The "just give me one SVG favicon" dream. Modern Chrome and Firefox do accept <link rel="icon" type="image/svg+xml">, which is great — but Safari doesn't consistently, iOS home screen doesn't, and Windows tile support doesn't. Until the long tail catches up, you need the full set. If you only care about recent desktop Chrome and Firefox, yes, you can ship one SVG and call it a day.
  • Windows tile color metadata. browserconfig.xml and the msapplication-* meta tags are from the Edge-before-Chromium era. Even Microsoft has stopped recommending them. Cut.

Try it in 30 seconds

The Docker image is about 90 MB and works with zero Python setup on your host:

docker build -t favicongen https://github.com/sen-ltd/favicongen.git

# Make a test logo inside a throwaway container:
mkdir /tmp/favtest
docker run --rm -v /tmp/favtest:/work --entrypoint python favicongen \
    -c "from PIL import Image; Image.new('RGB', (512,512), 'steelblue').save('/work/logo.png')"

# Run the generator:
docker run --rm -v /tmp/favtest:/work favicongen logo.png --out /work/out --verbose

ls /tmp/favtest/out/
# favicon.ico  favicon-16x16.png  ...  apple-touch-icon.png  icon-maskable-512.png
# site.webmanifest  favicons.html
Enter fullscreen mode Exit fullscreen mode

From a local Python install:

pip install favicongen
favicongen logo.svg --name "Acme" --theme-color "#0f172a"
cat favicons/favicons.html
Enter fullscreen mode Exit fullscreen mode

Paste that favicons.html into <head> and your favicon setup is complete — every size, every platform, every edge case. And the next time you start a project, it's one command away instead of forty minutes of Stack Overflow.

What I learned

The main thing is that "get a favicon working" turned out to be four separate problems wearing a trenchcoat: (1) the ICO format is a container, (2) different platforms consume different files, (3) maskable icons need safe zones that nothing enforces, and (4) the HTML snippet that ties it all together is impossible to remember. Wrapping all four in one script turned a thing I always put off into a thing I run once and forget about.

If you catch a bug or want to support a case I cut, PRs are welcome. Find the source on GitHub.

Top comments (0)