After my post on PNG entries in .ico files, a commenter asked a fair question: when you generate the small sizes, do you pixel-align them? Their point was that a 16px icon looks soft even when nothing is "resampled badly".
The honest answer for the favicon tool in the image editor I run was no. It takes the artwork, crops it to a square, and draws it once at each target size with the browser's best smoothing:
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(src, sx, sy, square, square, 0, 0, size, size)
No per-size snapping and no hinting. So I measured what that pipeline actually produces, and found that the commenter was right about the cause.
The test
One glyph, a capital "I" (a vertical bar with a bar on top and bottom), drawn on a dark plate in a 512x512 canvas. I drew it two ways:
- By eye: 40px strokes at round-number positions (bars at y = 100 and 372, stem at x = 236). This is what you get when you design at 512 and don't think about 16.
- On a 16x16 grid: every edge on a multiple of 32px (512 / 16). Same shape, strokes exactly 2 grid units wide.
Then I downscaled each to 16, 32 and 48 with a single drawImage, the same call as above, in headless Chromium 141. I counted soft pixels: pixels whose value is between the plate and white by more than 10% at either end. These are the grey, half-covered pixels you see as blur.
| Size | By eye: soft pixels | By eye: fully white | On grid: soft pixels | On grid: fully white |
|---|---|---|---|---|
| 16 | 52 | 0 | 0 | 52 |
| 32 | 108 | 68 | 0 | 208 |
| 48 | 168 | 212 | 0 | 468 |
At 16px the by-eye version has no fully white pixels at all. All 52 pixels of the glyph came out grey.
The middle and right images are identical, pixel for pixel. A grid-aligned master downscaled with drawImage gives you exactly what you would have drawn by hand.
The resampler is doing its job
Here are the actual values at 16px for the by-eye version (plate = 30, white = 255):
- The stem runs from x = 236 to 276 at 512, which is 7.375 to 8.625 at 16. It covers 62.5% of column 7 and 62.5% of column 8. Both columns come out 170, which is 62% of the way to white.
- The top bar runs from y = 100 to 140, which is 3.125 to 4.375. Row 3 is 87.5% covered and comes out 226. Row 4 is 37.5% covered and comes out 114.
That is plain area averaging, and it is correct. A 1.25px-wide stroke sitting at x = 7.375 cannot be drawn on a pixel grid. The best any filter can do is split it across two columns, and two 62% grey columns read as one blurry 2px line.
Switching the smoothing setting doesn't help:
-
'medium'gave the same numbers as'high'here. -
'low'and smoothing off look crisp (0 soft pixels), but only because they pick samples instead of averaging. The glyph lost a third of its pixels (36 white instead of 52), and which strokes survive depends on where the samples land. At other sizes a 1px stroke can disappear entirely.
So the blur is a geometry problem. The fix has to happen in the artwork, not in the resampler.
Fix 1: design the master on the small grid
If every horizontal and vertical edge in the master sits on a 1/16 grid line, every edge lands on a whole pixel at 16. Edges that land on whole pixels don't produce grey.
For a 512px master, the grid unit is 32px. The useful part is that the same master also works at 32 and 48, as the table shows (0 soft pixels at each): one grid unit becomes exactly 2px and 3px. Any output size that is a multiple of 16 inherits the alignment.
What this doesn't fix:
- Curves and diagonals are always antialiased. Grid alignment only affects horizontal and vertical edges.
- Anything you apply after the master breaks the alignment again (see the next section).
- Detail smaller than one grid unit still turns to mush. The grid makes edges sharp, but it doesn't make the icon readable.
The padding trap
Most favicon generators, including mine, have a "safe padding" slider that shrinks the artwork inside the square. That rescales your carefully aligned grid.
I took the grid-aligned master from above and ran it through the tool's own padding math (pad = padding% × size, art drawn at size − 2 × pad):
| Padding | 16px: offset | 16px: soft pixels | 32px: soft pixels | 48px: soft pixels |
|---|---|---|---|---|
| 0% | 0 | 0 | 0 | 0 |
| 5% | 0.8px | 40 | 48 | 108 |
| 10% | 1.6px | 20 | 44 | 28 |
| 12.5% | 2px | 36 | 84 | 120 |
| 25% | 4px | 28 | 0 | 84 |
The 12.5% row shows why. The offset is a whole 2px, but the glyph is now drawn at 12px instead of 16, so one grid unit becomes 0.75px and every edge is fractional again. Only an accident like 25% at 32px (art drawn at exactly 16px) comes out clean.
Put the padding in the master, in whole grid units, and leave the slider at 0. The same applies to cropping: if your source isn't square, the center crop can shift everything by a fraction of a pixel. Start from a square master.
Fix 2: draw 16 (and maybe 32) separately
Grid alignment gets you sharp edges. It doesn't get you a recognizable icon. At 16x16 you have 256 pixels, and a design that works at 512 usually has too much in it.
This is what hinting means for icons: the small sizes are different drawings, not smaller copies. Typical changes at 16:
- Drop inner detail, shadows and secondary shapes
- Make strokes 1 or 2 full pixels, never 1.5
- Open up gaps between shapes to at least 1px so they don't merge
- Exaggerate the one feature that makes the icon recognizable
The usual split is to hand-draw 16 (and 32 if it matters to you), and generate 48 and up from the master. From 48 up there are enough pixels that a grid-aligned downscale is good enough.
The ICO format is built for this. Each entry is its own image, so nothing requires 16 to be derived from 48. Browsers pick the 16 or 32 entry for the tab, so whatever you put there is what people see.
Here is how to combine a hand-drawn 16 with generated larger sizes. Decode the hand-drawn file to RGBA at 1:1 (no scaling), and let the encoder write it as a DIB entry. That keeps you clear of the PNG-entry problem at small sizes.
import { encodeIco } from '@editpot/ico-encoder'
// Draws the file at size x size and returns RGBA.
// A size x size file is copied 1:1; a larger master is downscaled.
async function rgbaAt(url, size) {
const img = await createImageBitmap(await (await fetch(url)).blob())
const c = new OffscreenCanvas(size, size)
const ctx = c.getContext('2d')
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(img, 0, 0, size, size)
return ctx.getImageData(0, 0, size, size).data
}
const ico = encodeIco([
{ width: 16, rgba: await rgbaAt('/art/icon-16.png', 16) }, // drawn
{ width: 32, rgba: await rgbaAt('/art/icon-32.png', 32) }, // drawn
{ width: 48, rgba: await rgbaAt('/art/master-512.png', 48) } // master
])
Make sure icon-16.png really is 16x16. If it is, the drawImage call is a straight copy and nothing gets resampled.
What the tool does today
To be clear about my own tool: editpot's favicon generator does the single-downscale pipeline described at the top, with no snapping, no per-size hinting, and no way to upload your own 16px drawing. The padding slider behaves exactly as the second table shows. If you give it a grid-aligned square master and leave padding at 0, you get the clean column. If you give it artwork drawn by eye at 512, you get the grey one.
Letting you drop in your own 16 and 32 drawings is the obvious next step, and it's on my list because of that comment.
Check your own icon
Two quick checks. First, look at the 16px output scaled up with nearest-neighbour, not in a viewer that smooths it. Then count the grey pixels:
// Soft pixels in RGBA, given background and foreground values
function softPixels(rgba, bg, fg) {
let soft = 0
for (let i = 0; i < rgba.length; i += 4) {
const t = (rgba[i] - bg) / (fg - bg)
if (t > 0.1 && t < 0.9) soft++
}
return soft
}
For a flat, orthogonal design, the number should be zero or close to it. If most of your glyph is in the soft count, the master is off-grid.
The short version
- A high-quality downscale is area averaging. It is correct, and it still can't put a 1.25px stroke on a pixel grid
- In my test, a glyph drawn by eye at 512 had 0 fully white pixels at 16px. The same glyph on a 16-grid had 0 grey ones
- Align the master to a 16x16 grid (32px units at 512). 16, 32 and 48 all come out exact
- Padding and cropping after the fact rescale the grid. Put padding in the master instead
- Grid alignment fixes edges, not legibility. Hand-draw 16 (and maybe 32), and generate 48 and up
- ICO entries are independent images, so you can mix hand-drawn and generated sizes in one file
The measurements came from headless Chromium 141 on Linux. Firefox and Safari implement imageSmoothingQuality differently, so the soft-pixel counts will differ for the by-eye version. The grid-aligned result doesn't depend on the filter, because every output pixel is fully inside or fully outside a shape.
The encoder used above is @editpot/ico-encoder on npm (MIT). The favicon tool it came from is part of editpot, a browser-based image editor.

Top comments (0)