DEV Community

Joe Lin for BeGoodTool.com

Posted on

Why transparent PNGs turn white when you convert them to JPG in the browser

I thought an image format converter would be mostly plumbing: load a file, pick a MIME type, download the result. The part that ended up being more interesting was transparency.

The first time I converted a transparent PNG to JPG, the transparent areas came back solid white. That isn't a random browser quirk. It's a direct consequence of how canvas re-encoding works, and in my converter I had to make that behavior explicit instead of pretending it would sort itself out.

HEIC has to be normalized before the browser can treat it like a normal image

The converter accepts PNG, JPEG, WEBP, and HEIC/HEIF, but the code doesn't send HEIC straight into the normal preview/export path. It first decodes HEIC locally with heic2any, and specifically asks for a PNG blob:

const detectSourceFormat = (file) => {
  const name = (file.name || "").toLowerCase();
  const type = (file.type || "").toLowerCase();
  if (type.includes("heic") || type.includes("heif") || /\.(heic|heif)$/.test(name)) {
    return "heic";
  }
  if (type === "image/png" || /\.png$/.test(name)) return "png";
  if (type === "image/webp" || /\.webp$/.test(name)) return "webp";
  if (type === "image/jpeg" || type === "image/jpg" || /\.(jpe?g)$/.test(name)) {
    return "jpeg";
  }
  return "unknown";
};

if (fmt === "heic") {
  const heic2anyModule = await import("heic2any");
  const heic2any = heic2anyModule.default || heic2anyModule;
  let result = await heic2any({ blob: file, toType: "image/png", quality: 0.92 });
  if (Array.isArray(result)) result = result[0];
  setPreviewUrl(URL.createObjectURL(result));
}
Enter fullscreen mode Exit fullscreen mode

That design is practical. Once HEIC is turned into a browser-friendly blob, the rest of the tool can use the exact same <img> + <canvas> pipeline as PNG, JPG, and WEBP. It also keeps everything local: no upload step, no server-side image library, just a decoded blob handed back to the preview.

JPG output works because the canvas gets painted white first

The core conversion logic lives in the preview renderer. It redraws the source image onto a canvas, then exports that canvas with toBlob() using the selected MIME type.

function renderPreviewNow() {
  const w = naturalWidth.value;
  const h = naturalHeight.value;
  const canvas = outputCanvasEl.value;
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, w, h);
  if (targetFormat.value === "jpeg") {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, w, h);
  }
  ctx.drawImage(imgEl.value, 0, 0, w, h);
  const mime = mimeForFormat(targetFormat.value);
  const q = needsQuality.value ? qualityPct.value / 100 : undefined;
  canvas.toBlob((blob) => {
    convertedBlob.value = blob;
    convertedSize.value = blob ? blob.size : 0;
  }, mime, q);
}
Enter fullscreen mode Exit fullscreen mode

That fillRect() is the whole transparency story.

If the target format is JPEG, the code paints a #ffffff background before drawing the image. So a transparent PNG or transparent WEBP doesn't stay transparent after conversion — its alpha gets composited onto white. That's why the downloaded JPG matches the preview instead of giving you black fringes, undefined background color, or browser-dependent behavior.

The quality slider only exists where it actually means something

Another detail I liked here: the tool doesn't pretend every format has a universal "quality" knob.

const targetFormat = ref("jpeg");
const qualityPct = ref(90);
const needsQuality = computed(() => targetFormat.value === "jpeg" || targetFormat.value === "webp");

const mimeForFormat = (fmt) => {
  if (fmt === "jpeg") return "image/jpeg";
  if (fmt === "webp") return "image/webp";
  return "image/png";
};

watch(targetFormat, scheduleRender);
watch(qualityPct, scheduleRender);
Enter fullscreen mode Exit fullscreen mode

Only JPEG and WEBP show the slider, because those are the lossy outputs in this implementation. PNG just gets exported as image/png, with no fake "quality" control layered on top.

The other nice touch is scheduleRender(), which wraps updates in requestAnimationFrame(). Dragging the slider doesn't try to synchronously re-encode on every tiny input event; it batches that work into the next paint frame, which is exactly the kind of small UX detail image tools need.

The gotchas are the real product decisions

A few limitations are very much on purpose here:

  • HEIC is supported as an input format, but not as an output format. The UI only offers JPG, PNG, and WEBP, and the source text explicitly warns that converting back into HEIC is not offered.
  • Conversion does not resize the image. The canvas is always set to naturalWidth and naturalHeight, so the only thing changing is encoding format and compression.
  • File size can absolutely go up. That's not a bug. A PNG exported from a photo, or a high-quality WEBP exported from a tiny JPG, can be larger than the original.
  • Transparent images turning white in JPG is intentional here, because the converter uses a white matte (#ffffff) instead of trying to preserve alpha in a format that doesn't support it.

I turned that behavior into a small free tool here: Image Format Converter.


Available in other languages

Top comments (2)

Collapse
 
knutt3 profile image
knutt3

That fillRect() is such a small detail but explains the whole transparency issue. Also makes sense to normalize HEIC to PNG first — much cleaner than having a separate conversion path @@

Collapse
 
yuntao_lin profile image
Joe Lin BeGoodTool.com

Thanks for noticing that! 😄
The ⁠fillRect()⁠ trick definitely saved a lot of headaches with the transparency issue. And exactly—normalizing HEIC to PNG early on keeps the pipeline so much cleaner and easier to maintain in the long run. Glad you enjoyed the read!