DEV Community

SEN LLC
SEN LLC

Posted on

A Canvas Favicon Generator in 500 Lines — and Why a Single drawImage() Downscale Always Looks Blurry

Building a favicon is a surprising amount of work: seven PNG sizes (16, 32, 48, 64, 180, 192, 512), the matching <link> block, and a site.webmanifest for 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

Screenshot

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]);
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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)