Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

256 colors turned a soft PNG shadow into a black band. I tried 4 fixes

I cut a transparent sticker PNG down to 256 colors and dropped it on a white page, and its soft grey shadow came back as a solid black band with a hard outer edge. On a black background the same file looked almost normal

256 colors turned a soft PNG shadow into a black band. I tried 4 fixes


I cut a transparent sticker PNG down to 256 colors and dropped it on a white page, and its soft grey shadow came back as a solid black band with a hard outer edge. On a black background the same file looked almost normal. I'm not a graphics person (I studied literature and drifted into writing internal tools), so I skipped the palette theory and just tried every fix I could, scoring each by file size and by how far the semi-transparent pixels drift from the original on white. The drift is the mean absolute RGB difference (0 to 255) over pixels that are semi-transparent in the source, after straight-alpha blending onto the background.

The sample is a pink ribbon sticker with a big soft shadow, 760×1662 and 441,038 bytes, a third-party image from Wikimedia Commons (by K B, CC BY 2.0). I only show its top loop. A Noto Color Emoji face (Apache 2.0) was my second sample. Everything ran on an Apple M4 Mac.

The black band comes from two alpha levels

First I counted distinct alpha values per file with Pillow and NumPy.

for img in sys.argv[1:]:
    alpha = np.asarray(Image.open(img).convert("RGBA"))[..., 3]
    soft = ((alpha > 0) & (alpha < 255)).sum()
    print(f"{img.split('/')[-1][:28]:28}  levels={len(np.unique(alpha)):3}  soft={soft:>7,}")
ribbon.png                    levels=256  soft=225,652
ribbon-png8-default.png       levels=  2  soft=      0
ribbon-34-levels.png          levels= 34  soft=215,937
ribbon-q84.webp               levels=256  soft=225,652

The original has 256 alpha values and 225,652 semi-transparent pixels, 17.9% of the image. The second file is the PNG-8 that ImgIng's image compressor gave me on 2026-09-30 with default settings (256 colors, dithering on), and only two alpha values are left. Every pixel at 128 or above became opaque and everything below became fully transparent, matching a plain 128 threshold pixel for pixel on both samples. The shadow's dark core turns into a block and its faint rim vanishes. ImgIng's png-compress page says its transparent PNG-8 supports multi-level alpha, but the files I exported that day had two. The background matters too. On white the two-level ribbon scores 44.17, on blue #1976D2 it's 20.79, and on black only 2.33, because a near-black shadow disappears into a black page. Preview only on dark and you'll miss it.

More alpha levels bring the soft edge back

The format isn't the limit. An indexed PNG's tRNS chunk gives every palette entry its own alpha, so I wrote a rough reducer of my own that keeps some palette slots semi-transparent. It isn't pngquant or any product's output, so compare its sizes only with each other.

Mean error on white for the ribbon's semi-transparent area: 2 alpha levels 44.17, 10 levels 8.23, 98 levels 1.75, lossy WebP 0.61

Each bar is the same ribbon on white, shorter is closer. Ten alpha levels drop the error from 44.17 to 8.23 and 98 levels reach 1.75. At 10 you still see contour rings in the shadow, while 98 is close to the source. Size barely moved: 111,178 bytes for two levels, 121,346 for 34, about 9.1% more. A Chromium 149 open-source build, Firefox 151 and WebKit 26.5 (not a Safari release) all read back the same 34 levels as Pillow.

Dithering only made the file bigger

On the two-level output dithering never touches alpha. The alpha channel was identical with it on or off and the white error stayed at 44.07 versus 44.06, while the ribbon grew from 93,317 to 148,242 bytes and the emoji from 11,213 to 23,528.

Ribbon shadow edge on white at 4x: original, 34 alpha levels without dithering, 34 alpha levels with dithering

Left is the original, middle my 34-level file, right the same with Floyd-Steinberg dithering on color and alpha. The steps turn into grain and look smoother, but stray light dots show up in the pink where solid pixels went semi-transparent. Error rose from 3.48 to 4.29 and the file grew 65% to 200,717 bytes. The number of alpha levels decides the edge, not dithering.

WebP kept every alpha level

For the WebP conversion I used ImgIng (https://imging.ai/) at its default quality 84 in the same Chromium build, and the ribbon went from 430.7 KB to 98.8 KB with alpha matching the original pixel for pixel, error 0.61 on white, black and blue. It ran in the browser tab with zero non-GET requests during export. Color is still lossy, and on the emoji the 4.02 error came from color while all 17 alpha levels survived. The same tool's default PNG-8 was 144.8 KB with the band.

My order now is simple. Soft shadows, glows or feathered edges go to WebP, and I run the script above on the result. If it must stay PNG-8, the palette needs dozens of alpha levels, and I leave dithering alone. I check on white and on a color, never only black. Colored shadows, glassy art, pngquant, low color counts and social-app recompression are all untested, and two images make a way to check your own files, not a rule.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.