Blog
Why Your Exported Gradients Look Like Bullseye Rings (And How to Fix It)

Why Your Exported Gradients Look Like Bullseye Rings (And How to Fix It)

Fixing gradient banding with floating point math and dithering

graphicsperformance

This came up while we were generating print-ready business cards from SVG assets.

The cards have a subtle dark radial background. The exported PNGs had visible concentric rings around the center. That artifact is gradient banding.

We tried the usual tricks first: more gradient stops, slight color tweaks. Things got a bit better, but the rings were still there at 600 DPI. The actual problem turned out to be 8-bit quantization at export time. The fix was to do the math in floats and dither when converting to bytes.


Why Banding Happens

A PNG channel is 8 bits. That gives you 256 values per channel, from 0 to 255.

A gradient is continuous, but the pixels you export are not. Each pixel has to round to one of those 256 integer values.

On a dark background with a soft vignette, opacity might change by 0.001 from one pixel to the next. After rounding, a whole stretch of pixels lands on the same RGB value. Then one pixel jumps to the next integer, and you see a ring.

Roughly:

pixel 100  →  RGB (14, 24, 36)
pixel 101  →  RGB (14, 24, 36)
pixel 102  →  RGB (14, 24, 36)
...
pixel 140  →  RGB (15, 25, 37)   ← jump → visible ring

The slower the gradient, the wider each band, the more obvious the ring.

Same gradient, two export methods:

Dark radial gradient exported without dithering showing visible concentric banding rings

Without dithering: visible concentric rings.

Dark radial gradient exported with Floyd-Steinberg dithering appearing visually smooth

With dithering: bands are gone in normal viewing.


Why More Gradient Stops Don't Help

Adding stops makes the interpolation smoother, but the export still rounds to 8 bits at the end. You hit the same wall.


The Fix

Three steps:

  1. Compute each pixel yourself.
  2. Keep the values as floats until the very end.
  3. Dither when you finally round to bytes.

Step 1: compute pixels by hand

For a radial gradient you need the distance from the center, normalized to 0–1, and an opacity value at that distance.

const dx = x - cx
const dy = y - cy
const t = Math.sqrt(dx * dx + dy * dy) / maxR   // 0 at center, 1 at edge

const opacity = lerp(t, stops)                  // your gradient curve

stops is just an array of { t, opacity } pairs. The lerp function walks the array and linearly interpolates between the two stops t falls between. Nothing fancy.

Step 2: keep everything in floats

Use a Float32Array, not a regular byte buffer. The point is to preserve the fractional part of every color value.

floats[i]     = bgR + (255 - bgR) * opacity   // e.g. 34.617
floats[i + 1] = bgG + (255 - bgG) * opacity
floats[i + 2] = bgB + (255 - bgB) * opacity

So far, no rounding has happened. The image still has full precision.

Step 3: dither when converting to bytes

Rounding 34.617 to 35 throws away 0.4 of brightness. Floyd-Steinberg dithering doesn't throw it away — it pushes that error onto the neighboring pixels that haven't been processed yet.

                [pixel]   →  +7/16
   +3/16    +5/16    +1/16        (next row)

In code, the core idea is just:

const old = floats[i]
const rounded = Math.round(old)
const error = old - rounded

floats[i] = rounded
floats[right]      += error * 7 / 16
floats[downLeft]   += error * 3 / 16
floats[down]       += error * 5 / 16
floats[downRight]  += error * 1 / 16

That's the whole trick. The error from each pixel is spread across four neighbors, so over a region the average brightness stays correct. What were smooth bands turn into very fine noise.

Zoomed in, the difference is obvious:

Zoomed crop of a dark gradient without dithering showing stepped quantization bands

Without dithering: stepped bands.

Zoomed crop of a dark gradient with Floyd-Steinberg dithering showing fine distributed noise

With dithering: fine noise instead of bands.

If you only remember one thing: do the math in floats, then dither when you round to bytes.


The Complete Script

The snippets above leave out the boundary checks (don't write past the edge of the image) and the final write to PNG. Here's the full version:

const sharp = require('sharp')

const SIZE = 1024

function interpolateOpacity(t, stops) {
  for (let i = 0; i < stops.length - 1; i++) {
    const a = stops[i], b = stops[i + 1]
    if (t >= a.t && t <= b.t) {
      const p = (t - a.t) / (b.t - a.t)
      return a.o + (b.o - a.o) * p
    }
  }
  return 0
}

async function generateBackground() {
  // Base background color
  const bg = { r: 0x0C, g: 0x16, b: 0x22 }

  // Radial vignette configuration
  const vignette = {
    cx: 0.50,
    cy: 0.25,
    stops: [
      { t: 0.00, o: 0.18 },
      { t: 0.30, o: 0.12 },
      { t: 0.60, o: 0.05 },
      { t: 1.00, o: 0.00 },
    ]
  }

  const cx = SIZE * vignette.cx
  const cy = SIZE * vignette.cy
  const maxR = SIZE * 1.0

  const floats = new Float32Array(SIZE * SIZE * 3)

  // Step 1: compute float-precision colors
  for (let y = 0; y < SIZE; y++) {
    for (let x = 0; x < SIZE; x++) {
      const idx = (y * SIZE + x) * 3
      const t = Math.min(
        Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxR,
        1.0
      )

      const opacity = interpolateOpacity(t, vignette.stops)

      floats[idx]     = bg.r + (255 - bg.r) * opacity
      floats[idx + 1] = bg.g + (255 - bg.g) * opacity
      floats[idx + 2] = bg.b + (255 - bg.b) * opacity
    }
  }

  // Step 2: Floyd-Steinberg dithering
  for (let y = 0; y < SIZE; y++) {
    for (let x = 0; x < SIZE; x++) {
      const idx = (y * SIZE + x) * 3
      for (let c = 0; c < 3; c++) {
        const old = floats[idx + c]
        const nw  = Math.round(Math.max(0, Math.min(255, old)))
        const err = old - nw
        floats[idx + c] = nw

        if (x + 1 < SIZE)
          floats[(y * SIZE + (x + 1)) * 3 + c]         += err * 7 / 16
        if (y + 1 < SIZE) {
          if (x - 1 >= 0)
            floats[((y + 1) * SIZE + (x - 1)) * 3 + c] += err * 3 / 16
          floats[((y + 1) * SIZE + x) * 3 + c]         += err * 5 / 16
          if (x + 1 < SIZE)
            floats[((y + 1) * SIZE + (x + 1)) * 3 + c] += err * 1 / 16
        }
      }
    }
  }

  // Step 3: write to PNG via Sharp with optimal settings
  const buf = Buffer.alloc(SIZE * SIZE * 3)
  for (let i = 0; i < floats.length; i++) {
    buf[i] = Math.max(0, Math.min(255, Math.round(floats[i])))
  }

  await sharp(buf, { raw: { width: SIZE, height: SIZE, channels: 3 } })
    .png({ compressionLevel: 2, palette: false, effort: 10, adaptiveFiltering: true })
    .toFile('background.png')

  console.log('Done.')
}

generateBackground()

Wrap-up

Banding isn't really a gradient problem, it's a rounding problem. More stops smooth the curve, but they can't fix what 8-bit quantization breaks at the very last step. Doing the math in floats keeps the precision around long enough for dithering to spread the rounding error somewhere harmless.

In our business-card pipeline, this runs across five variants (primary, balanced, legacy, winter, light) and exports print assets at 85x54mm, 600 DPI. A few things on top of the basic version that were worth doing:

  • Generic stop interpolation, so the gradient shape is data, not code.
  • Lanczos3 resampling when compositing the logo on top.
  • PNG export with zlib level 2 and adaptive filtering — good speed without giving up much size.

Floyd-Steinberg is from 1976. It still solves this exact problem today.