The Problem with Favicon Generators
Every time I updated the site logo, regenerating favicons meant uploading
an SVG to some "free" web tool, downloading a zip, extracting it, and hand-copying
files into public/. The output was never consistent either: different tools
give you different compression, different background handling, and different
size matrices, and none of it lives in version control.
What I wanted was a deterministic local pipeline: one SVG in, all the meta images out.
What Browsers Actually Need
The favicon landscape has consolidated a lot, so here's what modern browsers and platforms actually ask for:
| File | Size | Used By |
|---|---|---|
favicon.svg | Vector | Modern browsers (preferred) |
favicon.ico | 16+32+48px | Legacy browsers, bookmarks |
favicon-16x16.png | 16x16 | Browser tabs |
favicon-32x32.png | 32x32 | Browser tabs (Retina) |
favicon-48x48.png | 48x48 | Windows shortcuts |
favicon-96x96.png | 96x96 | Google TV, high-DPI contexts |
favicon.png | 512x512 | PWA fallback |
apple-touch-icon.png | 180x180 | iOS home screen |
android-chrome-192x192 | 192x192 | Android home screen |
android-chrome-512x512 | 512x512 | Android splash screen |
mstile-150x150.png | 150x150 | Windows tiles |
That's 11 files from one source SVG, and the favicon.ico is really three
PNGs bundled into one file (16, 32, and 48px) so the browser can pick
the best size for the context.
The Script
The pipeline leans on two tools: rsvg-convert for SVG-to-PNG rasterization
(from the librsvg package) and ImageMagick's magick for combining the
PNGs into a multi-resolution .ico. Both come from Homebrew.
The core loop generates each PNG size:
SIZES=(16 32 48 96 150 180 192 512)
for size in "${SIZES[@]}"; do
rsvg-convert -w "$size" -h "$size" \
--background-color=transparent \
"$SOURCE" -o "${TMPDIR}/${size}.png"
doneThen I map the temporary files to their final names:
cp "${TMPDIR}/16.png" "${OUTPUT}/favicon-16x16.png"
cp "${TMPDIR}/32.png" "${OUTPUT}/favicon-32x32.png"
cp "${TMPDIR}/180.png" "${OUTPUT}/apple-touch-icon.png"
cp "${TMPDIR}/192.png" "${OUTPUT}/android-chrome-192x192.png"
# ...The .ico combines three sizes into one file, and that's what makes it
display crisply across different contexts:
magick "${TMPDIR}/16.png" "${TMPDIR}/32.png" "${TMPDIR}/48.png" \
"${OUTPUT}/favicon.ico"Finally, I copy the source SVG over as favicon.svg for browsers that
support it. Modern browsers prefer the SVG when both are available, since it
scales perfectly at any resolution and can do dark mode via
prefers-color-scheme media queries baked into the SVG itself.
Making It Reusable
The script takes two arguments: the source SVG and an optional output
directory, which I defaulted to apps/root/public/:
./scripts/generate-favicons.sh ~/Downloads/logo.svg
./scripts/generate-favicons.sh icon.svg ./other-app/public/The Full Script
#!/usr/bin/env bash
set -euo pipefail
SRC="${1:?Usage: $0 <source.svg> [output-dir]}"
OUT="${2:-apps/root/public}"
if [ ! -f "$SRC" ]; then
echo "Error: source file '$SRC' not found" >&2
exit 1
fi
for cmd in rsvg-convert magick; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: '$cmd' is required but not installed" >&2
echo " brew install librsvg imagemagick" >&2
exit 1
fi
done
mkdir -p "$OUT"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cp "$SRC" "$OUT/favicon.svg"
SIZES=(16 32 48 96 150 180 192 512)
for size in "${SIZES[@]}"; do
rsvg-convert -w "$size" -h "$size" "$SRC" -o "$TMPDIR/icon-${size}.png"
done
cp "$TMPDIR/icon-16.png" "$OUT/favicon-16x16.png"
cp "$TMPDIR/icon-32.png" "$OUT/favicon-32x32.png"
cp "$TMPDIR/icon-48.png" "$OUT/favicon-48x48.png"
cp "$TMPDIR/icon-96.png" "$OUT/favicon-96x96.png"
cp "$TMPDIR/icon-150.png" "$OUT/mstile-150x150.png"
cp "$TMPDIR/icon-180.png" "$OUT/apple-touch-icon.png"
cp "$TMPDIR/icon-192.png" "$OUT/android-chrome-192x192.png"
cp "$TMPDIR/icon-512.png" "$OUT/android-chrome-512x512.png"
cp "$TMPDIR/icon-512.png" "$OUT/favicon.png"
magick "$TMPDIR/icon-16.png" "$TMPDIR/icon-32.png" "$TMPDIR/icon-48.png" \
"$OUT/favicon.ico"I also wired it up as a Claude Code skill, so regenerating favicons
after a logo change is a one-command job. The skill checks that
rsvg-convert and magick are installed, runs the script, and reports
back which files it generated.
The Takeaway
Favicon generation is a solved problem that doesn't need a web service: two CLI tools, 64 lines of bash, and a clear mapping from sizes to filenames. The script lives in version control next to the source SVG, so the pipeline is reproducible and the output is auditable. When the logo changes, and it will, I run one command on my own machine instead of going back to some website.