DEV Community

Dev Nestio
Dev Nestio

Posted on

I Built a Browser-Only Favicon Generator — Text, Emoji & SVG to ICO/PNG (136 Tests, Zero Dependencies)

Favicons are tiny but they matter. A sharp, recognizable 32×32 icon in a browser tab signals polish. But most favicon tools either require uploads, email sign-ups, or bloated JavaScript frameworks. I wanted something you can open, use in 10 seconds, and close — with nothing sent to a server.

So I built Favicon Generator: a single HTML file that converts text, emoji, or pasted SVG code into PNG files (16×16 through 180×180) and a multi-size .ico. Everything runs in the browser. Zero upload. Zero tracking.

Live tool → devnestio.pages.dev/favicon-generator/


What it does

  • Text or emoji input — type 1–3 characters ("Fg", "JS", "🚀") and the Canvas API renders them live
  • SVG paste — paste any SVG code; it renders via URL.createObjectURL and scales to fill the canvas
  • Background & foreground color pickers with hex text inputs (three-char shorthand works)
  • Auto-contrast button — uses WCAG 2.1 relative luminance to pick #000000 or #ffffff automatically
  • Border-radius slider — 0% (sharp square) to 50% (perfect circle)
  • Font family selector — System sans-serif, Serif, Monospace, Impact
  • Manual font-size override — or keep it on "auto" and the tool scales based on glyph count
  • 10 one-click presets — language logos (JS, Go, Py, TS) and emoji styles
  • Live previews — 256px main canvas, browser-tab mockup (with fake chrome UI), phone home-screen mockup
  • Download individual PNGs — 16, 32, 64, 128, 180px buttons
  • Download ZIP — all selected sizes bundled, implemented in pure JS (no JSZip, no external library)
  • Download ICO — 16px + 32px images packed into a real .ico binary, hand-rolled from the ICO spec

The interesting bits

ICO format from scratch

ICO is a binary container format. Modern tools embed PNG data directly inside it (rather than raw BMP), and browsers understand that since IE 10+.

The structure is straightforward:

ICONDIR (6 bytes)
  Reserved       [0x0000]
  Type           [0x0001]   ← 1 = ICO, 2 = CUR
  Count          [N]

ICONDIRENTRY × N (16 bytes each)
  Width          [1 byte]   ← 0 means 256
  Height         [1 byte]
  ColorCount     [0]        ← 0 = no palette (for PNG)
  Reserved       [0]
  Planes         [0x0001]
  BitCount       [0x0020]   ← 32 bpp
  BytesInRes     [uint32]   ← size of PNG blob
  ImageOffset    [uint32]   ← byte offset from file start

PNG data × N
Enter fullscreen mode Exit fullscreen mode

The first image offset = 6 + 16 * count. Each subsequent image sits right after the previous one. Simple.

function createIco(images) {
  // images = [{width, height, pngData: Uint8Array}]
  const count = images.length;
  const dirSize = 6 + count * 16;
  let offset = dirSize;
  const enriched = images.map(img => {
    const e = { ...img, offset };
    offset += img.pngData.length;
    return e;
  });
  // ... write header, directory, then concatenate PNG blobs
}
Enter fullscreen mode Exit fullscreen mode

Getting the PNG bytes from a Canvas element:

function canvasToPng(canvas) {
  const dataUrl = canvas.toDataURL("image/png");
  const base64 = dataUrl.split(",")[1];
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}
Enter fullscreen mode Exit fullscreen mode

ZIP format from scratch (STORE method)

For the ZIP download I implemented a minimal STORE (no compression) ZIP creator. It is only ~80 lines including CRC32:

Local file header  (30 + filename_length bytes)
File data
...repeat for each file...
Central directory entries
End of central directory record
Enter fullscreen mode Exit fullscreen mode

Key signatures to know:

  • Local file header: 0x04034B50 (PK\x03\x04)
  • Central directory entry: 0x02014B50 (PK\x01\x02)
  • End of central directory: 0x06054B50 (PK\x05\x06)

All multi-byte integers are little-endian. That is the only gotcha.

CRC32 uses the standard IEEE 802.3 polynomial 0xEDB88320 (bit-reversed):

const CRC32_TABLE = (() => {
  const t = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    let c = i;
    for (let j = 0; j < 8; j++)
      c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
    t[i] = c;
  }
  return t;
})();

function crc32(data) {
  let crc = 0xFFFFFFFF;
  for (let i = 0; i < data.length; i++)
    crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xFF];
  return (crc ^ 0xFFFFFFFF) >>> 0;
}
Enter fullscreen mode Exit fullscreen mode

Test vector: crc32("123456789") should equal 0xCBF43926.

Emoji rendering

Emoji need special handling on Canvas. The trick is skipping fillStyle and using sans-serif (not your custom font), letting the OS emoji font take over:

if (isEmoji(text)) {
  ctx.font = `${fontSize}px sans-serif`;
  // do not set fillStyle — OS emoji font handles color
} else {
  ctx.fillStyle = fgColor;
  ctx.font = `bold ${fontSize}px ${fontFamily}`;
}
ctx.fillText(text, size / 2, size / 2 + size * 0.03);
Enter fullscreen mode Exit fullscreen mode

The tiny + size * 0.03 vertical nudge corrects the optical center — Canvas textBaseline: middle tends to sit slightly high for square layouts.

Auto-contrast via WCAG 2.1

function luminance(r, g, b) {
  const srgb = [r, g, b].map(v => {
    const s = v / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
}

function autoContrast(bgHex) {
  const rgb = hexToRgb(bgHex);
  const lum = luminance(rgb.r, rgb.g, rgb.b);
  return lum > 0.179 ? "#000000" : "#ffffff";
}
Enter fullscreen mode Exit fullscreen mode

The threshold 0.179 is derived from the WCAG 2.1 contrast ratio formula — at this luminance, white and black text have roughly equal contrast against the background.


Testing: 136 tests, zero test framework

The tool is one HTML file, so the pure-logic functions (ICO, ZIP, CRC32, color math) are mirrored in test/test.js and run with plain node:

node test/test.js
Enter fullscreen mode Exit fullscreen mode

Test categories:

Category Count
CRC32 algorithm 15
ICO binary format 23
ZIP binary format 25
Color utilities (hexToRgb, luminance, autoContrast) 25
Hex validation & normalization 11
Text & emoji processing 22
Utility functions (clamp, readU16LE, readU32LE) 10
Integration (ICO + ZIP end-to-end) 5
Total 136

Standard test vector sanity:

test("CRC32 of 123456789 = 0xCBF43926", () => {
  assert.strictEqual(crc32(new TextEncoder().encode("123456789")), 0xCBF43926);
});

test("ICO dir entry 0: offset = 6 + 16 (1 image)", () => {
  const ico = createIco([{ width: 16, height: 16, pngData: fakePng }]);
  assert.strictEqual(readU32LE(ico, 18), 22); // 6 header + 16 dir entry
});
Enter fullscreen mode Exit fullscreen mode

Tech used

  • Canvas API — rendering and PNG export
  • Blob + URL.createObjectURL — SVG to Canvas pipeline and file downloads
  • DataView — writing ICO and ZIP binary structures
  • TextEncoder — filename bytes for ZIP
  • Intl.Segmenter — accurate grapheme cluster count (emoji sequences like 🏳️‍🌈)

No build step. No bundler. No npm install for the browser. Open the file, it works.


Try it

Happy favicon-ing. 🎨

Top comments (0)