Browsers are happy with a PNG favicon these days. But favicon.ico still matters: some crawlers, old browsers, Windows shortcuts and RSS readers request /favicon.ico directly, whatever your HTML says.
I assumed making one needed a native tool or a heavy library. It doesn't. An .ico file is one of the simplest binary formats you'll ever write.
The whole format on one screen
An ICO file has three parts:
ICONDIR 6 bytes reserved=0, type=1 (icon), count
ICONDIRENTRY 16 bytes one per image: width, height, ..., size, offset
image data N bytes the images, back to back
The key fact that makes this easy: since Windows Vista, the image data inside an ICO can be a complete PNG file. Every current browser accepts that too. So you don't need to produce old-style BMP bitmaps with AND masks. You render PNGs at 16, 32 and 48 pixels and pack them.
The encoder
interface IcoImage {
size: number // square edge in px
png: Uint8Array // raw PNG bytes for this size
}
export function encodeIco(images: IcoImage[]): Blob {
const count = images.length
const header = new Uint8Array(6)
const hv = new DataView(header.buffer)
hv.setUint16(0, 0, true) // reserved, must be 0
hv.setUint16(2, 1, true) // type: 1 = icon (2 would be a cursor)
hv.setUint16(4, count, true)
const dir = new Uint8Array(count * 16)
const dv = new DataView(dir.buffer)
let offset = 6 + count * 16 // first image starts right after the directory
images.forEach((img, i) => {
const b = i * 16
const dim = img.size >= 256 ? 0 : img.size // 0 means 256
dv.setUint8(b + 0, dim) // width
dv.setUint8(b + 1, dim) // height
dv.setUint8(b + 2, 0) // palette colours (0 = none)
dv.setUint8(b + 3, 0) // reserved
dv.setUint16(b + 4, 1, true) // colour planes
dv.setUint16(b + 6, 32, true) // bits per pixel
dv.setUint32(b + 8, img.png.length, true) // payload size
dv.setUint32(b + 12, offset, true) // payload offset
offset += img.png.length
})
return new Blob([header, dir, ...images.map((im) => im.png)], { type: "image/x-icon" })
}
Three details that trip people up:
-
Little-endian. ICO comes from Windows, so every multi-byte number is little-endian. That's the
truein eachsetUint16andsetUint32. Forget it and the file looks valid in a hex editor but no program can read it. -
One byte for the size. Width and height get one byte each, so 256 can't fit. The spec says to write
0, which means 256. -
Offsets are absolute. Each entry stores where its PNG starts from the beginning of the file. That's why
offsetstarts at6 + count * 16.
Getting the PNGs
In the browser, render each size on a canvas and export it:
async function pngAt(source: ImageBitmap, size: number): Promise<Uint8Array> {
const canvas = new OffscreenCanvas(size, size)
const ctx = canvas.getContext("2d")!
ctx.imageSmoothingQuality = "high"
ctx.drawImage(source, 0, 0, size, size)
const blob = await canvas.convertToBlob({ type: "image/png" })
return new Uint8Array(await blob.arrayBuffer())
}
const bitmap = await createImageBitmap(file)
const images = await Promise.all([16, 32, 48].map(async (size) => ({ size, png: await pngAt(bitmap, size) })))
const ico = encodeIco(images)
Transparency survives, because PNG keeps the alpha channel.
Which sizes?
16×16 is the browser tab. 32×32 covers high-DPI tabs and Windows taskbar shortcuts. 48×48 is used by Windows for desktop icons. Put all three in one file, and each consumer picks the closest one.
Shrinking a detailed logo to 16 pixels usually turns it to mush. If your logo has fine text, draw a simpler mark for the 16 px version and pass that PNG instead of a downscaled one. The encoder doesn't care where each PNG came from.
Shipping it
Put the file at /favicon.ico on your site root. For the rest of the favicon setup (<link> tags, the web manifest, Apple touch icons and cache problems), I wrote a separate post.
If you just need the file, this encoder runs inside BatchSet's free PNG to ICO converter (16, 32 and 48 px in one .ico), and the favicon generator builds the full set. Both work in the browser without uploading your logo.
This article was drafted with an AI assistant from the code of a production tool. The encoder is taken from that code.
Top comments (0)