DEV Community

hao jia
hao jia

Posted on

256-colour PNGs can keep soft shadows. tRNS shows whether yours did

Before letting 256-colour PNGs into an image pipeline, I wanted to understand one failure. A sticker with a soft drop shadow goes through colour reduction, and on a white page the shadow comes back as a solid black band with a knife-cut edge. My test file is a pink ribbon sticker from Wikimedia Commons (by K B, CC BY 2.0), a third-party image, and the figures only show its top loop. 17.9% of its pixels are semi-transparent. 256 colours were not the problem. An indexed PNG can store soft alpha fine, and the result depends on how many distinct alpha values the encoder writes into one small chunk.

Colour type 3 keeps alpha in its own chunk

The original is colour type 6, four bytes per pixel with alpha stored next to RGB. A 256-colour PNG is colour type 3. Each pixel is an index into PLTE, which holds up to 256 RGB entries of three bytes. Transparency lives in the optional tRNS chunk, one alpha byte per palette entry in the same order, and entries past its end count as opaque. How many distinct values those bytes hold is up to the encoder. I wrote a small reader in Node with no image library:

function paletteAlpha(bytes) {
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const report = { colourType: null, paletteSize: 0, alphaBytes: 0 };
  let alpha = [];
  for (let at = 8; at < bytes.length; ) {
    const size = view.getUint32(at);
    const tag = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
    const payload = bytes.subarray(at + 8, at + 8 + size);
    if (tag === 'IHDR') report.colourType = payload[9];
    if (tag === 'PLTE') report.paletteSize = size / 3;
    if (tag === 'tRNS') { report.alphaBytes = size; alpha = [...payload]; }
    at += size + 12;
  }
  while (alpha.length < report.paletteSize) alpha.push(255);
  const steps = [...new Set(alpha)].sort((a, b) => a - b);
  report.belowOpaque = alpha.filter((a) => a < 255).length;
  report.steps = steps.length;
  report.widestGap = Math.max(0, ...steps.slice(1).map((a, i) => a - steps[i]));
  return report;
}
Enter fullscreen mode Exit fullscreen mode

It walks the chunks, pads the alpha list to palette size per the spec, and reports entries below 255, distinct alpha values and the widest gap between neighbouring values. Count distinct values, not entries. My 98-level file has 129 entries below 255 but only 98 distinct values, because several colours share one alpha. Output for six versions of the sticker:

ribbon.png               type=6 tRNS=0B below255=0 steps=0 gap=0
ribbon-2.png             type=3 tRNS=256B below255=1 steps=2 gap=255
ribbon-10.png            type=3 tRNS=256B below255=9 steps=10 gap=43
ribbon-34.png            type=3 tRNS=256B below255=33 steps=34 gap=18
ribbon-98.png            type=3 tRNS=256B below255=129 steps=98 gap=12
ribbon-png8-export.png   type=3 tRNS=256B below255=1 steps=2 gap=255
Enter fullscreen mode Exit fullscreen mode

The two-step file is the black band. Its tRNS is a full 256 bytes, yet the only entry below 255 is the fully transparent colour, so every pixel at alpha 128 or above went opaque and the rest vanished. The 10, 34 and 98 level files come from a quick reducer I wrote for comparison, not from pngquant or any product. The last line is the PNG-8 export from ImgIng's image compressor at its defaults, run on 2026-09-30, and it has the same two steps. Its PNG compression page says transparent PNG-8 keeps multi-level alpha. My file did not, and I am reporting bytes, not guessing why.

Browsers read alpha per palette entry

A format feature only helps if decoders honour it. I decoded the 34-level file in the Chromium 149 open-source build, Firefox 151 and WebKit 26.5 (the engine, not a Safari release) and read the pixels back from a canvas. All three reported 34 alpha levels and matched Pillow's decode on semi-transparent pixel count and alpha sum.

Noto emoji outline at 4x on blue: original, two alpha steps, 20 alpha steps

This is a Noto Color Emoji outline (Apache 2.0) at 4x on blue. The original has 17 alpha levels and only 1,548 semi-transparent pixels. With two steps the anti-aliased rim becomes a staircase, and with 20 steps it looks like the original.

Fewer alpha steps show up as rings

A shadow is a smooth ramp, and every gap between neighbouring alpha values becomes a visible ring. Error here is the mean RGB difference (0 to 255) from the original over semi-transparent pixels, both composited on white. Two steps gave 44.17, 10 steps 8.23, 34 steps 3.48 and 98 steps 1.75. In the same encoder the 34-level file was 121,346 bytes against 111,178 for two steps, only 9.1% more. At 10 steps, with a widest gap of 43, the shadow shows clear contour lines. At 34 steps (gap 18) they thin out, and at 98 steps (gap 12) the shadow is close to the original.

Shadow at 4x on white with 10, 34 and 98 alpha steps

Dithering adds grain but no alpha steps

With two steps, dithering never touches alpha. ImgIng's exports with dithering on and off had identical alpha and the same white error (44.07 and 44.06), but the dithered file was 1.59 times larger, 148,242 bytes against 93,317. On my 34-level file, Floyd-Steinberg diffusion over colour and alpha did break the rings into grain. White error still rose from 3.48 to 4.29 and the file grew 65%. Opaque pixels pushed to semi-transparent went from 573 to 1,850, seen as white specks on the pink edge.

Shadow at 4x on white: original, 34 steps without dithering, 34 steps with dithering

What I check before shipping an indexed PNG

I run the reader first. A type 3 file with one tRNS entry below 255 has lost every soft pixel, however fine it looks on a dark page. Then I look on white and on a colour, never only on black, since the two-step ribbon scored 2.33 on black against 44.17 on white. For assets with large soft shadows I skip PNG-8. For the ribbon I used ImgIng (imging.ai) to convert to WebP at its default quality 84, and it went from 430.7 KB to 98.8 KB with alpha identical to the original pixel for pixel. The conversion ran in the browser and the export sent no non-GET requests.

This covers two samples. Coloured shadows, glows, Chrome stable, Safari, display pipelines and chat apps that recompress images are untested. Before switching a batch of PNGs to 256 colours, run a tRNS count over the exports and reject any file whose source had soft pixels but whose output has just two alpha steps.

Top comments (0)