GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Pixel Art Image Converter: 9 Canvas Dot Patterns

A pixel art image converter, explained: Why does a zoomed photo turn into squares of color?

Auto-plays · click a tile to jump to its section · all nine in one zip

All nine start from the same procedurally-drawn scene — drawScene() paints a set of geometric shapes and gradients with seedRand(), a seeded pseudo-random generator, so nothing here is an uploaded photo. Every item below reduces that same scene through getImageData()/putImageData() in a different way, so the order runs from calculations that only touch a pixel's color — block averaging, palette quantization, error diffusion, and brightness banding — through calculations that also read the pointer or a timer, and ends with the compare scene that lays the original and the pixel-art result side by side.

Where a Pixel Art Image Converter Actually Gets Used

Most of these nine end up as a one-time conversion baked into a static asset rather than something a visitor watches loop forever — the classic pixel art photo converter use case. Item 1's block averaging turns a profile photo into a dot-art icon at whatever block size reads clearest at avatar dimensions, item 2's palette quantization locks a hero image onto the same handful of colors as an 8-bit game-style banner, item 4's brightness banding steps an illustration card's shadow into flat color bands instead of a smooth gradient, item 7's retro palette lock gives a retro event page's hero image the fixed color count of an old game screen, and item 8's brightness-to-character map turns a photo into text art for an about page background.

The other four stay interactive because their one value keeps changing after the page loads. Item 3's error-diffusion dithering is the one worth reaching for when the source is already grayscale and needs to read as black-and-white dots instead of gray. Item 5's pointer-radius lens only enlarges the block size inside a circle around the cursor, which fits a magnifier interaction laid over a product photo. Item 6's stepped reveal shrinks the block size across a gallery image's loading entrance, and item 9's before/after scene ties a slider position directly to how much of a landing page hero shows the original photo versus the pixel-art result.

01Block Average Downsample Sized by One Block Value

Item 1 divides the photo into square blocks and paints each block one averaged color, so a portrait reads as visible large pixels instead of its original detail — useful for turning a profile photo into a dot-art icon. The value that drives it is PIXEL_BLOCK_SIZE_PX, read into blockAverage() once per block inside the per-frame loop that walks the canvas buffer.

Block Average Downsample
Open in a new tab

01 · Block Average Downsample — Measured area 39.534% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

PIXEL_BLOCK_SIZE_PXblockAveragegetImageData
  function blockAverage(buf, w, h, blockPx, outBuf) {
    for (var by = 0; by < h; by += blockPx) {
      var bh = Math.min(blockPx, h - by);
      for (var bx = 0; bx < w; bx += blockPx) {
        var bw = Math.min(blockPx, w - bx);
        var rs = 0, gs = 0, bs = 0, count = 0;
        for (var y = 0; y < bh; y += 1) {
          for (var x = 0; x < bw; x += 1) {
            var idx = ((by + y) * w + (bx + x)) * 4;
            rs += buf[idx]; gs += buf[idx + 1]; bs += buf[idx + 2]; count += 1;
          }
        }
        var r = Math.round(rs / count), g = Math.round(gs / count), b = Math.round(bs / count);
        for (var y2 = 0; y2 < bh; y2 += 1) {
          for (var x2 = 0; x2 < bw; x2 += 1) {
            var idx2 = ((by + y2) * w + (bx + x2)) * 4;
            outBuf[idx2] = r; outBuf[idx2 + 1] = g; outBuf[idx2 + 2] = b; outBuf[idx2 + 3] = 255;
          }
        }
      }
    }
  }

Measured: 39.5% of the frame repaints across the loop, intensity averages 42.5, and 15 of 23 sampled frames moved.

02Palette Color Quantization Locked to One Color-Count Value

Item 2 replaces every pixel's color with whichever color in a small fixed palette sits closest to it, so the scene locks onto a handful of flat tones — useful for an 8-bit game-style banner. The value that drives it is PALETTE_COLOR_COUNT, read into nearestPaletteColor() once per pixel inside the per-frame loop that walks the canvas buffer.

Palette Color Quantization
Open in a new tab

02 · Palette Color Quantization — Measured area 59.009% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

PALETTE_COLOR_COUNTnearestPaletteColorquantizePalette
  function nearestPaletteColor(r, g, b, count) {
    var best = 0, bd = Infinity;
    for (var i = 0; i < count; i += 1) {
      var p = PALETTE_RGB[i];
      var dr = r - p[0], dg = g - p[1], db = b - p[2];
      var d = dr * dr + dg * dg + db * db;
      if (d < bd) { bd = d; best = i; }
    }
    return PALETTE_RGB[best];
  }

  function quantizePalette(buf, w, h, count, outBuf) {
    for (var i = 0; i < w * h; i += 1) {
      var idx = i * 4;
      var c = nearestPaletteColor(buf[idx], buf[idx + 1], buf[idx + 2], count);
      outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
    }
  }

Measured: 59.0% of the frame repaints across the loop, intensity averages 163.0, and 17 of 23 sampled frames moved.

03Error-Diffusion Dithering Spread by One Level Value

Item 3 rounds each pixel's channel down to one of a few brightness levels and pushes the rounding error it leaves behind onto the pixels to its right and below, so the eye reads the resulting dot pattern as a smooth gradient — useful for turning a grayscale photo into black-and-white dots. The value that drives it is DITHER_LEVELS, read into diffuseError() once per channel inside the per-pixel loop that walks the canvas buffer.

Error-Diffusion Dithering
Open in a new tab

03 · Error-Diffusion Dithering — Measured area 75.294% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

DITHER_LEVELSdiffuseErrorputImageData
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 3;
        for (var c = 0; c < 3; c += 1) {
          var old = work[idx + c];
          var q = Math.max(0, Math.min(255, Math.round(Math.round(old / step) * step)));
          var err = old - q;
          work[idx + c] = q;
          if (x + 1 < w) work[idx + 3 + c] += err * 7 / 16;
          if (y + 1 < h) {
            if (x > 0) work[idx + rowStride - 3 + c] += err * 3 / 16;
            work[idx + rowStride + c] += err * 5 / 16;
            if (x + 1 < w) work[idx + rowStride + 3 + c] += err * 1 / 16;
          }
        }
      }

Measured: 75.3% of the frame repaints across the loop, intensity averages 88.6, and 18 of 23 sampled frames moved.

04Brightness-Band Posterize Stepped by One Band-Count Value

Item 4 cuts each channel's brightness range into a fixed number of bands and flattens every value inside a band to the same output, so smooth shading collapses into visible steps — useful for the shadow steps on an illustration card. The value that drives it is POSTERIZE_BAND_COUNT, read into quantizeBand() once per pixel inside the per-frame loop that walks the canvas buffer.

Brightness-Band Posterize
Open in a new tab

04 · Brightness-Band Posterize — Measured area 70.713% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

POSTERIZE_BAND_COUNTquantizeBandgetImageData
  function quantizeBand(buf, w, h, bands, outBuf) {
    var step = 255 / (bands - 1);
    for (var i = 0; i < w * h; i += 1) {
      var idx = i * 4;
      for (var c = 0; c < 3; c += 1) {
        var v = buf[idx + c];
        outBuf[idx + c] = Math.max(0, Math.min(255, Math.round(Math.round(v / step) * step)));
      }
      outBuf[idx + 3] = 255;
    }
  }

Measured: 70.7% of the frame repaints across the loop, intensity averages 63.8, and 18 of 23 sampled frames moved.

05Pointer-Radius Dot Lens Sized by One Radius Value

Item 5 keeps a heavily block-averaged copy of the scene everywhere except inside a circle around the pointer, where it swaps back in the less-blocky copy, so only the area right under the cursor shows enlarged dots — useful for a magnifier interaction over a product photo. The value that drives it is PIXEL_LENS_RADIUS_PX, read into lensCompose() once per pixel inside the per-frame loop that compares distance to the pointer.

Pointer-Radius Dot Lens
Open in a new tab

05 · Pointer-Radius Dot Lens — Measured area 41.126% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PIXEL_LENS_RADIUS_PXlensComposeaddEventListener
  function lensCompose(bigBuf, smallBuf, w, h, px, py, radius, outBuf) {
    var r2 = radius * radius;
    for (var y = 0; y < h; y += 1) {
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 4;
        var dx = x - px, dy = y - py;
        var src = (dx * dx + dy * dy) < r2 ? bigBuf : smallBuf;
        outBuf[idx] = src[idx]; outBuf[idx + 1] = src[idx + 1]; outBuf[idx + 2] = src[idx + 2]; outBuf[idx + 3] = 255;
      }
    }
  }

Measured: 41.1% of the frame repaints across the loop, intensity averages 47.2, and 23 of 23 sampled frames moved.

06Stepped Dot Reveal Paced by a List of Block Values

Item 6 walks a fixed list of block sizes from large to small on a timer and re-runs the same block-average calculation at each step, so a sharp photo appears to resolve gradually out of a blocky start — useful for a gallery image's loading entrance. The value that drives it is REVEAL_STEPS, read into stepIndexAt() once per frame inside the loop that measures elapsed time since the reveal began.

Stepped Dot Reveal Transition
Open in a new tab

06 · Stepped Dot Reveal Transition — Measured area 56.008% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

REVEAL_STEPSblockAveragerequestAnimationFrame
  function stepIndexAt(sinceStart) {
    var cycleMs = REVEAL_STEPS.length * STEP_DURATION_MS;
    var pos = sinceStart % cycleMs;
    return Math.min(REVEAL_STEPS.length - 1, Math.floor(pos / STEP_DURATION_MS));
  }

  function renderFrame(t) {
    var sinceStart = t - replayStart;
    if (sinceStart < 0) sinceStart += LOOP_MS;
    var idx = stepIndexAt(sinceStart);
    blockAverage(baseBuf, CALC_W, CALC_H, REVEAL_STEPS[idx], outBuf);
  }

Measured: 56.0% of the frame repaints across the loop, intensity averages 45.6, and 15 of 23 sampled frames moved.

07Retro Console Palette Lock Swapped by a Timer Value

Item 7 locks every block's color onto the nearest match in one of two small fixed palettes and swaps which palette is active on a timer, so the scene reads like an old low-color game screen without ever leaving that limited set — useful for a retro event page's hero image. The value that drives it is RETRO_BLOCK_PX, read into lockRetroPalette() once per block inside the per-frame loop that also picks between the two self-defined 8-color and 16-color palettes.

Retro Console Palette Lock
Open in a new tab

07 · Retro Console Palette Lock — Measured area 33.972% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

RETRO_PALETTE_8RETRO_PALETTE_16lockRetroPalette
  var outBuf = outImg.data;

  function renderFrame(t) {
    var phase = Math.floor((t % LOOP_MS) / 160) % 2;
    var set = phase === 0 ? RGB_8 : RGB_16;
    lockRetroPalette(baseBuf, CALC_W, CALC_H, RETRO_BLOCK_PX, set, outBuf);
  }

Measured: 34.0% of the frame repaints across the loop, intensity averages 91.1, and 16 of 23 sampled frames moved.

08Brightness-to-ASCII Character Map Set by One Ramp Value

Item 8 measures each block's average brightness and looks up one character from a fixed ramp string that runs from sparse to dense, then draws that character in the block's own color — useful for a text-art background on an about page. The value that drives it is BRIGHTNESS_RAMP, read into brightnessToChar() once per block inside the per-frame loop that also sets the fill color before calling fillText().

Brightness-to-ASCII Character Map
Open in a new tab

08 · Brightness-to-ASCII Character Map — Measured area 59.368% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

BRIGHTNESS_RAMPbrightnessToCharfillText
  function brightnessToChar(baseBuf, w, h, cellPx, ramp, outCtx) {
    outCtx.fillStyle = '#0b0b10';
    outCtx.fillRect(0, 0, w, h);
    outCtx.font = Math.max(5, cellPx) + 'px monospace';
    outCtx.textBaseline = 'top';
    for (var by = 0; by < h; by += cellPx) {
      var bh = Math.min(cellPx, h - by);
      for (var bx = 0; bx < w; bx += cellPx) {
        var bw = Math.min(cellPx, w - bx);
        var rs = 0, gs = 0, bs = 0, count = 0;
        for (var y = 0; y < bh; y += 1) {
          for (var x = 0; x < bw; x += 1) {
            var idx = ((by + y) * w + (bx + x)) * 4;
            rs += baseBuf[idx]; gs += baseBuf[idx + 1]; bs += baseBuf[idx + 2]; count += 1;
          }
        }
        var r = rs / count, g = gs / count, b = bs / count;
        var lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
        var ci = Math.min(ramp.length - 1, Math.floor(lum * ramp.length));
        var ch = ramp.charAt(ci);
        outCtx.fillStyle = 'rgb(' + Math.round(r) + ',' + Math.round(g) + ',' + Math.round(b) + ')';
        outCtx.fillText(ch, bx, by);
      }
    }
  }

Measured: 59.4% of the frame repaints across the loop, intensity averages 87.1, and 19 of 23 sampled frames moved.

09Before/After Hero Comparison Split by One Slider Value

Item 9 picks a horizontal pixel threshold from a percentage value and draws the pixel-art result left of it and the original photo right of it on the same canvas, so one slider position controls how much of the before/after scene shows each version — useful for a landing page's hero comparison slider. The value that drives it is COMPARE_SLIDER_PERCENT, read into splitCompare() once per pixel inside the per-frame loop that compares each pixel's x position to the threshold.

Before/After Hero Comparison Scene
Open in a new tab

09 · Before/After Hero Comparison Scene — Measured area 10.295% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

COMPARE_SLIDER_PERCENTsplitCompareaddEventListener
  function splitCompare(beforeBuf, afterBuf, w, h, percent, outBuf) {
    var threshold = Math.round((percent / 100) * w);
    for (var y = 0; y < h; y += 1) {
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 4;
        var src = x < threshold ? afterBuf : beforeBuf;
        outBuf[idx] = src[idx]; outBuf[idx + 1] = src[idx + 1]; outBuf[idx + 2] = src[idx + 2]; outBuf[idx + 3] = 255;
      }
    }
  }

Measured: 10.3% of the frame repaints across the loop, intensity averages 54.3, and 17 of 23 sampled frames moved.

Nine Pixel Art Patterns, Measured in Area and Intensity

Item Area % Intensity Frames moved
01 pixelart-block-downsample 39.5 42.5 15/23
02 pixelart-palette-quantize 59.0 163.0 17/23
03 pixelart-dither-diffuse 75.3 88.6 18/23
04 pixelart-posterize-band 70.7 63.8 18/23
05 pixelart-pointer-lens-radius 41.1 47.2 23/23
06 pixelart-reveal-transition-step 56.0 45.6 15/23
07 pixelart-retro-palette-lock 34.0 91.1 16/23
08 pixelart-ascii-brightness-map 59.4 87.1 19/23
09 pixelart-before-after-hero-scene 10.3 54.3 17/23

Turning Up Three Values to See How Far Pixel Art Can Stretch

Variant Value change Feel
Adjust the block-size value 01 PIXEL_BLOCK_SIZE_PX 4 → 24 Raising the block size from 4 to 24 makes each dot bigger, so the original shape reads blockier.
Adjust the pointer-lens radius value 05 PIXEL_LENS_RADIUS_PX 40 → 160 Widening the lens radius from 40 to 160 grows the area around the pointer where dots look enlarged.
Adjust the compare-slider value 09 COMPARE_SLIDER_PERCENT 20 → 80 Moving the compare slider value from 20 to 80 increases the share of the screen showing the pixel-art result.

Where a Pixel Art Image Converter Breaks Down on Real Photos

Every pattern above reads or writes CALC_W × CALC_H pixels through getImageData()/putImageData() at least once per animation frame — on the demo's 160×100 canvas that's 16,000 reads per pass, but run the same per-pixel loop against a 4000px photo upload and the pixel count climbs past two hundred times that — the same wall an image to pixel art converter free tool online would hit at full resolution. Items 5 and 9 add a second cost on top of that per-frame loop: item 5 recomputes which buffer to show for every pointer position, and item 9 recomputes the split threshold on every divider drag, keyboard press, or range-input change, so both are worth profiling first if the source photo itself is large. That per-pixel cost is also where the download earns its place — the 895nmedw guarding every index.html/style.scss pair for these nine sits in this paragraph, so you can decide which pattern to profile first before unzipping the rest.

Accessibility and What Reduced Motion Actually Changes Here

prefers-reduced-motion: reduce removes the is-demo class and stops each item's animation frame loop on items 1 through 8, but every one of them freezes at the midpoint of its own loop — half of LOOP_MS elapsed — rather than at frame zero, so a reader with motion sensitivity still sees each pattern's typical mid-cycle look instead of a blank starting state. Item 9 works differently because its threshold value only changes on a pointer, keyboard, or range-input action rather than every frame: the divider already answers ArrowLeft and ArrowRight key presses by moving the split four percentage points at a time, and the same range input at the top of the demo drives the identical setPercent() call, so a reader who never touches a pointer can still move the slider with a keyboard alone, reduced motion or not. Both getImageData() and requestAnimationFrame() — the two calls every one of these nine patterns is built on — are documented on MDN, along with the prefers-reduced-motion media query itself.

How Is This Pixel Art Image Converter Different From an Image to Pixel Art Converter Free?

An image to pixel art converter free online tool usually hides its block size, palette, and dithering behind one button and a few presets. What sits on this page instead is the nine separate calculations that a tool like that runs underneath — block averaging, palette quantization, error-diffusion dithering, brightness banding, and the rest — each shown as its own short function you can read end to end. Items 2, 4, and 7 lock or band an image's existing colors instead of adding a new layer, which puts them closer to a css pixel art filter applied over a photo than to a native CSS filter() function; items 1, 3, 5, 6, 8, and 9 instead resample or remap the pixel grid itself, which a CSS filter alone cannot do. For nearby patterns built the same way — one value read every frame instead of a fixed preset — CSS Image Filters: 9 Patterns, One Value covers the color-only half of that list in more depth, Canvas Particle Animation: 9 Engine Patterns is the closest relative for anyone who wants moving shapes instead of a still conversion, and Image Crop JavaScript: 9 Canvas Editor UIs is where a photo would get trimmed before any of these nine run on it.

Questions You Might Have About These Nine Pixel Art Patterns

Does any of these nine load a real photo or an external image?

No — drawScene() paints the scene from scratch with canvas shapes and gradients, seeded by seedRand() so the same seed always draws the same layout, and all nine pixel art calculations run on that canvas buffer with zero uploaded images and zero libraries.

Which pattern costs the most to run on a full-size photo?

Item 3's error-diffusion dithering covers 75.3% of the frame — the highest area of any pattern here, with intensity averaging 88.6 — since every channel of every pixel both reads a rounding error from its neighbors and writes one forward. That combination makes it the first pattern worth testing at real upload resolution, ahead of items 2, 4, or 8.

How is this different from pixel art particle effects?

These nine all start from one photo-like scene and recolor or resample its existing pixels — nothing here spawns or destroys anything. That other topic instead animates many small separate shapes such as sparks or dust, which is a different calculation from any of the nine block, palette, or dither loops shown above.

Enter the archive password

The password is inside this article. You will find it as you read.