make-app-icons
v1.0.0
Published
Master image (SVG or PNG) -> the modern favicon/PWA icon set (Evil Martians' "How to Favicon in 2021": favicon.ico, icon.svg with dark-mode support, apple-touch-icon, maskable + PWA manifest icons, manifest.webmanifest, head snippet), with the classic 16-
Maintainers
Readme
make-app-icons
Turns a master image into the icon files a modern web or app project
actually needs. By default that means the file set from Evil Martians’
“How to Favicon in 2021: Six files that fit most needs” —
favicon.ico, icon.svg, apple-touch-icon.png, icon-192.png,
icon-mask.png, icon-512.png, manifest.webmanifest, and a
copy-paste head-snippet.html — in one pass, through sharp alone (resize,
compositing, and PNG compression). The classic 16–1024 cascade this tool
originally shipped with (a 2026 rewrite of a 2019 personal tool) is still
available behind --classic.
npx make-app-icons logo-master.svg
npx make-app-icons logo-master.png --classic --optimize
npx make-app-icons brand-a.png brand-b.png --out dist/icons --sizes 32,180,512Or install it: npm i -g make-app-icons, then run make-app-icons ....
Hardened: 2026-10-31 · ed59f21 · gate CLOSED WITH FOLLOW-UPS
What it does
Reads one or more master images — an SVG is the best master (see “SVG
masters and dark mode” below), a 1024×1024 (or at least 512×512) PNG is the
next best thing, and anything else sharp can decode still works — and
generates the modern file set by default. Non-square input is letterboxed
onto a transparent square rather than cropped, so nothing important gets cut
off. Smaller masters still work — they’re upscaled with a warning, since
upscaled output can look soft.
With a single input, files land directly in --out. With multiple inputs,
each one gets its own subfolder named after its filename (e.g.
brand-a.png → icons/brand-a/), so a light/dark or multi-brand run
doesn’t overwrite files across inputs. Two inputs that would share a
folder — a/logo.png and b/logo.png, or logo.png and logo.svg — are
refused before anything is written (exit 1, naming both); rename one or
run them separately with their own --out. Names that differ only in case
count as the same folder, since the default macOS and Windows file systems
treat them as one.
A run that would write an output over one of its own masters — say a
1024 px site/icon-512.png with --out site, or site/icon.svg with
--out site — is refused the same way, so the master is never replaced by
its own downscaled copy.
The modern default
make-app-icons logo-master.svgproduces the article’s six files, plus the manifest and head snippet it describes pasting them with:
| File | Size | Notes |
|---|---|---|
| favicon.ico | 32×32 | Single size, no palette (“32 bpp, 8-bit alpha, no palette” per the article) — not the old multi-size ico |
| icon.svg | vector | SVG masters only — passed through with an injected dark-mode <style> scaffold; skipped for raster masters |
| apple-touch-icon.png | 180×180 canvas | Artwork scaled to 140×140 and centered (20px padding), on a background color |
| icon-192.png | 192×192 | Manifest icons entry |
| icon-mask.png | 512×512 canvas | Maskable variant — artwork confined to a centered 409×409 safe zone; manifest purpose: "maskable" |
| icon-512.png | 512×512 | Manifest icons entry |
| manifest.webmanifest | — | The icons array, as its own file |
| head-snippet.html | — | The four <link> lines to paste into <head> |
The head snippet, verbatim (the icon.svg line is omitted for a raster
master):
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest">Paths are root-relative (/icon-192.png) by default, matching the
article’s own examples — these six files are meant to live at the site
root, not tucked into an /icons subfolder. Pass --base-href <path> if
your deployment puts them somewhere else (e.g. --base-href /assets/icons
→ /assets/icons/icon-192.png).
--no-modern turns this default off (pair it with --classic to get only
the old cascade, or with --sizes — though --sizes already skips modern
output on its own; see below).
apple-touch-icon.png: padding and background
The article’s guidance: “scale the image itself to 140×140 and set the
canvas to 180×180”, because “an Apple touch icon will look better if you
place 20px padding around the icon and add some background color.” This
tool follows that exactly — 140px content, centered with 20px of padding on
every side, on a --apple-bg background (default #ffffff; pass
--apple-bg transparent to opt out).
--apple-fill <color> additionally flat-recolors the artwork itself for
this one output — the same silhouette-preserving full recolor the
environment variants use (style injection for SVG masters, alpha-preserving
flat tint for rasters). Its typical use is an inverted tile, e.g. a dark
mark that should render as white-on-black on the home screen:
make-app-icons logo.svg --apple-bg "#000000" --apple-fill "#ffffff"Only apple-touch-icon.png is affected; every other output keeps the
master’s own colors.
icon-mask.png: the maskable safe zone
The article’s guidance for maskable icons: “The safe zone is a central circle of 409×409” within the 512×512 canvas, and it points to maskable.app for checking your result against real mask shapes (circle, squircle, rounded square, …), since different platforms crop maskable icons differently.
This tool implements that as a centered 409×409 content square on the
512×512 canvas — the practical reading most maskable-icon tooling uses,
rather than the stricter (and rarely followed) geometry of inscribing a
square inside the 409-diameter circle. The article doesn’t mandate a
background color for the maskable variant, so this defaults to transparent;
--maskable-bg <color> overrides it (many real deployments use a solid
brand color here, since the OS may not fill transparent regions the way you
expect once cropped).
Either way: check your actual result at maskable.app. This tool gets the geometry right; whether your specific artwork reads well once masked is a design judgment call it can’t make for you.
manifest.webmanifest
Written exactly as the article shows it — just the icons array, in the
article’s own order:
{
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-mask.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}If your project needs a fuller manifest (name, short_name, start_url,
display, theme_color, …), add those fields yourself — the article
doesn’t specify them, so this tool doesn’t invent them.
SVG masters and dark mode
Give this tool an .svg master and, on top of the rasterized PNGs above
(rendered by sharp directly at each target size — it reads SVG natively,
which beats scaling one pre-rasterized 1024 down repeatedly and keeps every
size crisp), it also writes icon.svg: your source SVG, passed through
verbatim, with a <style> block injected right after the opening <svg>
tag:
<style>
@media (prefers-color-scheme: dark) {
/* ... */
}
</style>That’s the article’s own dark-mode technique — the article’s worked example gives a shape a class and overrides it in the media query:
<style>
@media (prefers-color-scheme: dark) {
.a { fill: #f0f0f0 }
}
</style>
<path class="a" fill="#0f0f0f" d="…" />This tool can’t know which shapes in your SVG should change for dark mode — that’s a hand-tuning problem, not a mechanical one — so it gives you three ways to fill in that block:
Nothing (default): an empty, commented scaffold. The
<style>block is present so you can see where it goes, but it does nothing until you edit it or pass one of the flags below.--dark-fill <color>: a blunt, naive override —* { fill: <color> !important; }— useful for a quick “does dark mode even work here” check, almost never right for a finished dark variant (it recolors every shape the same way, ignoring the article’s per-shape-class approach). Give a plain CSS color (#f0f0f0,white,rgb(240 240 240), …). The check is on shape, not meaning: a hex value, a word of letters only, orword(…)holding only letters, digits, spaces and. , % / + -passes, and anything else is refused. So a typo such aswhteis written as given, and a function with a hex value inside (light-dark(#000, #fff)) is refused.--dark-css <value>: exact control.<value>is either a path to a CSS file or inline CSS text; either way its contents are injected verbatim inside the media query. This is how to reproduce the article’s own.a { fill: #f0f0f0 }pattern — add matching classes to your SVG’s shapes first, then pass the rules that target them.
Each of the three blocks opens with its own comment, so an icon.svg this
tool wrote can be fed back in as the master: the old block is replaced, not
stacked under a new one. A hand-authored <style> in your master is left as
it is.
A raster master never gets an icon.svg. This tool doesn’t trace or
fake a vector from a PNG/JPEG source — icon.svg is simply skipped, with a
console note and the corresponding <link> line dropped from
head-snippet.html. Provide an SVG master if you want this file and
dark-mode support at all.
Environment icons (--env)
make-app-icons logo-master.svg --env dev,staging
make-app-icons logo-master.svg --env dev --env qa --env-color qa=#22aa55Dan Gaidula’s personal convention, carried over from his production Craft CMS projects: non-production environments get their own favicon, flat- recolored to a fixed marker color, so a glance at the browser tab says which environment you’re looking at. Two built-in names cover the common case —
| Environment | Color |
|---|---|
| dev | #e11d2c (red) |
| staging | #0080ff (blue) |
— and --env-color <name>=<color> (repeatable) defines or overrides any
name, including ones with no built-in default: --env qa --env-color
qa=#22aa55 works, --env qa alone doesn’t (it errors, since there’s
nothing sane to guess for an arbitrary name).
--env itself takes a comma-separated list and is repeatable — --env
dev,staging and --env dev --env staging are equivalent, and combine if
both are used. Names go into file names, so they’re limited to letters,
digits, - and _.
Only favicon.ico and icon.svg get variants — favicon-dev.ico,
icon-dev.svg, favicon-staging.ico, icon-staging.svg, and so on.
Nothing else does: icon-192.png, icon-mask.png, icon-512.png, and
apple-touch-icon.png stay production-colored on every environment, by
design. In Dan’s Craft projects the manifest and home-screen icons are
never what gives an environment away — the tab favicon is — so only the
favicon pair gets the treatment.
This is a full flat recolor of the glyph, not a badge or corner flag: every visible pixel becomes the marker color, alpha untouched.
SVG masters get it via a single injected style block —
<style>* { fill: <color> !important; } [stroke] { stroke: <color> !important; }</style>— rather than rewriting each shape’sfill/strokeattribute. The blunt fill rule wins: it needs no SVG parsing (so it can’t miss a shape whose color comes from a CSS class,currentColor, or a gradient reference), and!importantbeats the artwork’s own more-specific rules without having to know what they are. It’s the same technique--dark-filluses above, except here it’s the intended outcome, not a documented fallback. Stroke deliberately does not get the universal-selector treatment: forcingstrokeonto elements that never had one enables stroking at the SVG defaultstroke-widthof 1 user unit, which blobs fine detail shut and clips at the viewBox edge. The[stroke]attribute selector recolors real strokes and leaves never-stroked artwork alone.Raster masters get it by discarding their color entirely and keeping only their alpha: extract the alpha channel, fill a solid canvas with the marker color, rejoin that alpha as the new alpha channel. Every opaque pixel becomes exactly the marker color regardless of how many colors the source art used — unlike a hue-preserving tint (which would go muddy on multi-color art), “keep the silhouette, replace every color with one flat color” can’t fail that way, so raster masters get a real recolor here, not a skip notice. The silhouette is the alpha, though: a master with no transparency (a JPEG, or a PNG on a solid background) recolors to a solid square, and the run warns when that happens — for
--envand for--apple-fill, which uses the same recolor.
Dark mode is deliberately stripped from environment variants, not just
left unconfigured. Even if the same run passes --dark-fill or
--dark-css for the production icon.svg, the environment variants never
carry a prefers-color-scheme: dark block — this tool strips any existing
one (its own injected scaffold, or a hand-authored one already in the
master, including the typed <style type="text/css"> form Illustrator and
Inkscape write) before writing the variant. A dev/staging marker that quietly
turns white under dark browser chrome would stop being a marker; env
variants are hardcoded to their flat color on purpose, everywhere.
head-snippet.html gets a commented block documenting the variant hrefs
(not live conditional markup — Dan’s Craft projects switch the icon pair
server-side by environment, so the snippet’s job is just to show the
variant files exist and what they’re named).
--classic: the old 16–1024 cascade
make-app-icons logo-master.png --classicproduces the full cascade this tool shipped with before this upgrade:
| Size | Filename | Platform / use |
|---|---|---|
| 16×16 | favicon-16.png | Browser tab favicon (low-density) |
| 32×32 | favicon-32.png | Browser tab/bookmark favicon (high-density) |
| 48×48 | favicon-48.png | Windows “site icons” pinned-site / taskbar use |
| 64×64 | icon-64.png | General small app icon, legacy Windows icon size |
| 128×128 | icon-128.png | macOS Finder small icon, Chrome Web Store icon, Linux desktop icon |
| 180×180 | apple-touch-icon.png | iOS/iPadOS home-screen bookmark icon |
| 192×192 | icon-192.png | Android/Chrome PWA install icon |
| 256×256 | icon-256.png | macOS icon, Windows large icon, store listing thumbnails |
| 512×512 | icon-512.png | Android splash-screen source, Play Store icon |
| 1024×1024 | icon-1024.png | App Store / Play Store submission master, macOS .icns source |
--classic combines with the modern default (on by default, so
make-app-icons logo.png --classic runs both). Where the two would write
the same filename — apple-touch-icon.png, icon-192.png, icon-512.png
— the classic cascade skips its own plain version and leaves the modern,
purpose-built one (padded apple-touch-icon, etc.) in place. Run
--classic --no-modern for the old cascade exactly as it was, with no
deduping.
The old --ico (multi-size favicon.ico) and --manifest
(manifest-icons.json fragment) flags still work, paired with --classic
— modern mode already emits its own favicon.ico and
manifest.webmanifest, so those flags no-op with a notice unless
--classic is also given.
Custom sizes
--sizes overrides modern and classic output entirely with a
comma-separated list of pixel sizes, output as generic icon-<size>.png
files — this is the one flag that bypasses everything else in this file
(--env variants and --optimize still apply):
make-app-icons logo-master.png --sizes 16,32,64,512
make-app-icons logo-master.png --sizes 16,32,64,512 --ico --manifestAn empty list (--sizes ',') is refused rather than writing nothing.
--optimize: re-optimize PNGs with lithocinch
make-app-icons logo-master.svg --optimizeIf the lithocinch CLI is on
PATH (own tool; npm i -g lithocinch or npm link from a clone), this
re-runs its perceptually-lossless PNG optimizer over every generated PNG
after the fact — sharp’s built-in palette quantization is decent, but
lithocinch measures per-image against an SSIM target instead of guessing.
Invocation, for the curious:
lithocinch <staged-pngs> -o <staged-out> -s '' --overwrite --format png --no-resize--format png locks the format (lithocinch’s own default would happily
convert these to WebP/AVIF, which breaks apple-touch-icon.png and the
manifest’s "type": "image/png"); --no-resize guarantees dimensions are
preserved; the PNG engine is left at lithocinch’s own default — adaptive
palette quantization with a 0.92 SSIM floor, falling back to lossless,
which is exactly the “measured, not guessed” perceptually-lossless mode
this needs. Only the PNGs are touched; icon.svg, favicon.ico,
manifest.webmanifest, and head-snippet.html are left alone.
lithocinch is never a package dependency — it’s discovered on PATH
at runtime only. If it isn’t found, --optimize prints a notice and falls
back to the sharp palette quantization already applied during generation
(the same thing --no-minify controls).
Compression
PNG output is written through sharp’s built-in palette quantization
(palette: true) by default — the same class of lossy, reduced-color-palette
compression pngquant did in the original 2019 tool, but without pulling in
imagemin or its (now largely unmaintained) plugin ecosystem. Pass
--no-minify to skip quantization and keep full truecolor PNGs.
favicon.ico’s source render always skips quantization regardless of this
flag — the article calls for “no palette” there specifically.
Options
-o, --out <dir> Output directory (default: ./icons)
-s, --sizes <list> Comma-separated custom pixel sizes, e.g. 16,32,64,512
Overrides modern + classic output entirely.
--no-modern Skip the modern six-file output (on by default)
--classic Also emit the classic 16-1024 icon cascade
--base-href <path> Href prefix for manifest.webmanifest / head-snippet.html
(default: '', i.e. root-relative "/icon-192.png")
--dark-fill <color> icon.svg dark mode: naive global fill override
--dark-css <val> icon.svg dark mode: inline CSS, or a path to a CSS
file, injected verbatim inside the media query
--apple-bg <color> apple-touch-icon.png canvas background
(default: #ffffff; "transparent" for none)
--apple-fill <color> apple-touch-icon.png artwork recolor: flat-recolors
the mark itself (silhouette preserved), e.g.
--apple-bg "#000000" --apple-fill "#ffffff"
--maskable-bg <color> icon-mask.png canvas background
(default: transparent)
--env <list> Comma-separated environment names (dev,staging,...);
repeatable. Emits favicon-<env>.ico and
icon-<env>.svg only, flat-recolored - no PNG
variants. dev/staging have built-in colors
--env-color <n>=<c> Set/override an environment's marker color
(repeatable), e.g. --env-color qa=#22aa55
--optimize Re-optimize PNG outputs with the lithocinch CLI, if
found on PATH (falls back to the sharp quantizer)
--ico [classic] Also emit a multi-size favicon.ico
--manifest [classic] Also emit manifest-icons.json
--manifest-path <p> [classic] Path prefix for manifest-icons.json "src"
--no-minify Skip palette-quantized PNG compression, keep truecolor
--cheat Print a short card of common invocations in tldr page format and exit
-h, --help Show helpKnown limits
PNG (and one ICO, one SVG) output only. There’s no JPEG/WebP output mode.
A CRLF SVG master gives mixed line endings in
icon.svg. The injected dark-mode block uses LF while the master’s own lines keep CRLF. Browsers don’t care; normalize the file if your repo does.icon.svgrequires an SVG master. Raster masters get every PNG/ICO output, just noticon.svg— this tool never traces or fakes a vector from a raster source.The maskable safe zone is a practical approximation, not the strict circle-inscribed-square geometry — see “icon-mask.png” above. Verify your result at maskable.app.
--optimizeneedslithocinchonPATHto do anything beyond the sharp fallback. It’s never installed as a dependency of this tool.Classic
.icosizes are capped by convention, not by--sizes. If none of your custom sizes land on 16/32/48/64/128/256, classic--icoskips with a warning rather than forcing an unconventional size into the container.--env’sicon-<env>.svgneeds an SVG master, same asicon.svgitself — a raster master still getsfavicon-<env>.ico, recolored via the alpha-preserving tint described above, just not the SVG half of the pair.Flat recolors of a raster master need transparency.
--envand--apple-fillkeep only the master’s alpha, so an opaque master gives a solid square. The run warns but still writes it.--dark-cssis trusted input. Its value goes intoicon.svgverbatim, and a value that isn’t a readable file is used as literal CSS — a mistyped path (darkk.css) is injected as text, with no warning. Unlike--dark-fill, it isn’t validated.--base-hreftakes a root-relative path, not a URL. An absolute URL such ashttps://cdn.example.com/iconscomes out as/https://cdn.example.com/icons/icon-192.png.Very large declared SVG sizes fail. An SVG whose
width/heightdeclare something like 20000×20000 stops with sharp’s “Input image exceeds pixel limit”; give the master a smaller declaredwidthandheight(theviewBoxcan stay as it is).A flag’s value is whatever comes next. A value flag given last is refused, but
--out --classictakes--classicas the folder name.Same-folder detection is case-insensitive everywhere, including on case-sensitive Linux file systems, where
Logo.pngandlogo.pngwould in fact get separate folders; such a run is refused.
Requirements
Node 20.9+ (sharp 0.35 needs 20.9, png-to-ico 3 needs 20).
Credits
The modern icon stack this tool builds by default follows Evil Martians’
“How to Favicon in 2021: Six files that fit most needs”
— the file list, the sizes, the apple-touch-icon padding, the maskable
safe zone, the dark-mode <style> technique, all of it traces back to
that article. This tool takes their ideas, adds a few of Dan Gaidula’s own
conventions on top — environment-variant icons (--env) among them — and
automates the whole thing into one pass over a master image.
