Building a favicon is a surprising amount of work: seven PNG sizes (16, 32, 48, 64, 180, 192, 512), the matching
<link>block, and asite.webmanifestfor PWA. I built a 500-line vanilla JS tool that takes text, an emoji, or an uploaded image and emits all of that. The implementation hinge was something I didn't expect: single-step downscaling is always blurry.drawImage(srcCanvas, 0, 0, 16, 16)from 512×512 source to 16×16 destination loses the character of the icon every time, no matter how good the source. The fix is step downscaling — halve repeatedly until you're close to the target. Here's the why and the math.
🌐 Demo: https://sen.ltd/portfolio/favicon-generator/
📦 GitHub: https://github.com/sen-ltd/favicon-generator
The sad single-step output
First attempt — render text at the target size directly:
function renderAt(size, content) {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.font = `${size * 0.7}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(content, size / 2, size / 2);
return canvas;
}
At size = 16, the rasterised character is mush. Canvas font rendering doesn't do the subpixel hinting that browsers use for <p> text, so an 11-px-tall character on a 16-px canvas has no clear edges.
Second attempt — render at 512×512, then downscale in one step:
ctx.drawImage(masterCanvas, 0, 0, 16, 16);
Also mush. Why is the part most generators don't explain.
The signal-processing reason
drawImage() resampling uses bilinear interpolation by default. For each destination pixel, it samples a 2×2 neighbourhood in the source and computes a weighted average.
Going from 512 → 16, each destination pixel corresponds to a 32×32 = 1024-pixel block in the source. Bilinear samples 4 of them. The other 1020 source pixels contribute zero. Sharp text edges land entirely in pixels the filter discards.
This isn't a Canvas API limitation; it's a fundamental sampling problem. Downscaling by more than ~2× without a proper low-pass filter aliases — your text edges scatter into noise.
Step downscaling: halve, halve, halve
The correct solution is to apply the resampling in stages:
512 → 256 → 128 → 64 → 32 → 16
At each step, the bilinear filter samples a 2×2 source block per destination pixel. Across a 4-pixel block, bilinear with imageSmoothingQuality: "high" behaves approximately as a box filter — every source pixel contributes to exactly one destination pixel. No information thrown away.
The algorithm:
export function downscaleSteps(srcSize, dstSize) {
if (srcSize <= 0 || dstSize <= 0) return [];
if (srcSize <= dstSize * 2) return [dstSize];
const steps = [];
let cur = srcSize;
while (cur > dstSize * 2) {
cur = Math.max(dstSize, Math.round(cur / 2));
steps.push(cur);
if (cur <= dstSize) break;
}
if (steps[steps.length - 1] !== dstSize) steps.push(dstSize);
return steps;
}
Logic:
- If src is already within 2× of dst, one step is fine (bilinear handles it cleanly).
- Otherwise, halve, never going below dst, and ensure the last step lands exactly on dst.
Examples:
-
512 → 16:[256, 128, 64, 32, 16](5 steps) -
192 → 16:[96, 48, 24, 16](4 steps) -
64 → 32:[32](one step — src ≤ 2× dst)
The renderer applies the chain:
function downscale(srcCanvas, target) {
const steps = downscaleSteps(srcCanvas.width, target);
let current = srcCanvas;
for (const stepSize of steps) {
const next = document.createElement("canvas");
next.width = next.height = stepSize;
const ctx = next.getContext("2d");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(current, 0, 0, stepSize, stepSize);
current = next;
}
return current;
}
imageSmoothingQuality: "high" activates the best resampler the browser ships — Chrome/Edge go closer to bicubic, Firefox stays bilinear but tighter.
Test the step list
The step list is pure logic, perfectly Node-testable:
test("each step is ≤ 2x the previous", () => {
const steps = downscaleSteps(512, 16);
for (let i = 1; i < steps.length; i++) {
assert.ok(steps[i - 1] / steps[i] <= 2.5);
}
});
test("final step equals dst exactly", () => {
assert.equal(downscaleSteps(192, 16).pop(), 16);
});
test("when src ≤ 2× dst: single step", () => {
assert.deepEqual(downscaleSteps(64, 32), [32]);
});
The ≤ 2.5 tolerance allows for the rounding step in the algorithm. With strict 2.0, 192 / 96 = 2.0 is fine but the rounded form can edge over by a hair. 2.5 gives margin without compromising the quality property.
Master at 512, downscale to seven targets
Every config change re-renders the master at 512×512, then produces all seven target sizes from it:
const HI_RES = 512;
function renderAtMaster(config) {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = HI_RES;
// … background, then text/emoji/image at the high resolution
return canvas;
}
export function renderAllSizes(config) {
const master = renderAtMaster(config);
const out = new Map();
for (const s of [16, 32, 48, 64, 180, 192, 512]) {
out.set(s, downscale(master, s));
}
return out;
}
Why 512 specifically:
- The font renderer has enough room to apply hinting (vs. trying to draw at 16px directly).
- All target sizes are reached by downscaling, never upscaling.
- The shape mask (rounded / circle) applies at the final size, keeping mask edges crisp instead of pre-mask-then-downscaling them.
Drawing emoji on Canvas
For emoji input, the trick is naming the OS emoji fonts in font-family — Canvas will use them:
ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.fillText("🚀", canvas.width/2, canvas.height/2);
macOS uses Apple Color Emoji, Windows uses Segoe UI Emoji, Linux/Android picks up Noto Color Emoji. Color emoji fonts embed rasterised PNG glyphs, so Canvas renders them in full colour without extra work.
Shape masking
function applyShape(canvas, shape) {
if (!shape || shape === "square") return canvas;
const size = canvas.width;
const masked = document.createElement("canvas");
masked.width = masked.height = size;
const ctx = masked.getContext("2d");
ctx.save();
ctx.beginPath();
if (shape === "circle") {
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
} else if (shape === "rounded") {
const r = size * 0.22;
roundRect(ctx, 0, 0, size, size, r);
}
ctx.clip();
ctx.drawImage(canvas, 0, 0);
ctx.restore();
return masked;
}
clip() + drawImage() only writes pixels inside the path. Crucially, this runs after downscaling, not before — masking pre-downscale leaves the mask edges to be blurred by the bilinear filter.
PNG download
Standard Canvas → Blob → object URL → invisible link click:
export function downloadCanvasAsPng(canvas, filename) {
canvas.toBlob((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}, 100);
}, "image/png");
}
I considered zipping all seven sizes into one download, but JSZip adds ~100 KB of dependency. Per-size download buttons are smaller, simpler, and let users skip sizes they don't need.
Architecture
core.js ← SIZES, downscaleSteps, validateConfig, snippet generators (DOM-free, 21 tests)
render.js ← Canvas: master @ 512 → step downscale → shape mask
presets.js ← 6 starter configs
app.js ← UI glue: input → validate → render → preview + download
core.js doesn't touch the DOM. Node tests pin down the step-list properties (monotonic decrease, ≤ 2× ratio, exact final value, single-step fast path). The browser-only render.js then implements the Canvas pipeline, confident the chain is correct.
Try it
- Demo: https://sen.ltd/portfolio/favicon-generator/
- GitHub: https://github.com/sen-ltd/favicon-generator
Pick the "Initial" preset, look at the 16×16 favicon. The "S" reads cleanly. Now imagine if I'd rendered it at 16px directly — that letter would be a vague dark blob.
Takeaways
- A favicon set is seven PNG sizes plus HTML and a manifest — that's the modern standard, ICO no longer required for any current browser.
- Single-step downscaling more than ~2× is fundamentally aliased. Bilinear samples 4 pixels regardless of the source-block size, throwing away the rest.
- Step downscale by halving until you're within 2× of the target. Each halve is approximately a box filter — the highest-quality cheap resampler.
- Master at 512, downscale to every target, apply shape mask at the final size.
- Pure pipeline math belongs in a DOM-free module. Node tests verify the step list before any Canvas work.
- Per-size download buttons beat a single ZIP if your alternative is bundling a 100 KB dependency.
This is OSS portfolio #255 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)