Skip to content

Instantly share code, notes, and snippets.

@s3thi
Created July 28, 2026 16:26
Show Gist options
  • Select an option

  • Save s3thi/04f3fcc81910ef70feb64a45ac7e06bc to your computer and use it in GitHub Desktop.

Select an option

Save s3thi/04f3fcc81910ef70feb64a45ac7e06bc to your computer and use it in GitHub Desktop.
Dithering
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="./style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dithering</title>
</head>
<body>
<main>
<h1>Dithering</h1>
<p>
This pen implements the
<a href="https://en.wikipedia.org/wiki/Floyd–Steinberg_dithering"
>Floyd-Steinberg algorithm</a
>
for <a href="https://en.wikipedia.org/wiki/Dither">image dithering</a>.
I don't have this working <em>quite</em> right yet, but it looks good
just the same.
</p>
<p>
Use the file-picker button to select an image from your computer. For
best results, pick a relatively small image. If you pick a larger image,
you'll have to zoom in to see the effect.
</p>
<p>
All code runs locally in your browser. View source to see how the
algorithm is implemented.
</p>
<form>
<input type="file" accept="image/*" id="image" name="image" required />
<input type="submit" value="Dither!" />
</form>
<div class="preview"></div>
</main>
<footer>
<p>A pen by <a href="https://ankursethi.com">Ankur Sethi</a>.</p>
</footer>
<script src="./script.js"></script>
</body>
</html>
// Grab some elements from the DOM for later.
const imageFormElt = document.querySelector("form");
const fileInputElt = document.querySelector("input[type='file']");
const canvasContainerElt = document.querySelector(".preview");
let canvasElt = null;
/**
* Handles the `change` event on the file input.
*/
async function onFileInputChanged() {
// If the input has no files, bail out.
if (fileInputElt.files.length === 0) {
return;
}
// Turn the selected file into an ImageBitmap. We need this so we can draw it
// to a <canvas> later.
const bitmap = await createImageBitmap(fileInputElt.files[0]);
// Create a fresh <canvas> that matches the dimensions of the selected image.
// Then replace any existing <canvas> with the new one.
canvasElt = document.createElement("canvas");
canvasElt.width = bitmap.width;
canvasElt.height = bitmap.height;
canvasContainerElt.replaceChildren(canvasElt);
// Draw the image into our newly-created <canvas>. There might be a less
// roundabout way of getting an image from a user into a <canvas>, but I don't
// know what it is.
const ctx = canvasElt.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
}
/**
* Handles the `submit` event on the image upload form.
*/
function onImageFormSubmitted(e) {
e.preventDefault();
// If the user tried to submit an empty form, bail out.
if (fileInputElt.files.length === 0) {
return;
}
// Let's go!
dither(canvasElt);
}
/**
* Dithers an image that's been drawn into a <canvas> element using the
* Floyd-Steinberg algorithm. Modifies the image in-place.
*/
function dither(canvasElt) {
const ctx = canvasElt.getContext("2d");
const width = canvasElt.width;
const height = canvasElt.height;
// Get pixel data out of the <canvas>.
const imageData = ctx.getImageData(0, 0, width, height);
const pixels = imageData.data;
// Iterate over the pixel data. Note that the image data we get from the
// <canvas> context is a one-dimensional array. It's easier apply the
// dithering algorithm when we're working in terms of (x, y) coordinates, so
// that's what our loop uses. When we want to index into our ImageData, we do
// a bit of math to turn (x, y) coordinates into an index we can use.
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// Use the (x, y) coordinates to get an index into the one-dimensional
// ImageData array.
const imageIdx = mapCoordinatesToImageData(x, y, width);
const red = pixels[imageIdx];
const green = pixels[imageIdx + 1];
const blue = pixels[imageIdx + 2];
// We have our (r, g, b) components from above, but we want to turn them
// into a grayscale value. For this, we use the HSL luminance formula.
const luminance =
(Math.max(red, green, blue) + Math.min(red, green, blue)) / 2;
let quantizeError;
if (luminance > 127) {
// If the luminance is above 127, turn the pixels at this coordinate
// white.
pixels[imageIdx] = 255;
pixels[imageIdx + 1] = 255;
pixels[imageIdx + 2] = 255;
// Make note of the quantization error.
quantizeError = luminance - 255;
} else {
// If the luminance is below 127, turn the pixels at this coordinate
// black.
pixels[imageIdx] = 0;
pixels[imageIdx + 1] = 0;
pixels[imageIdx + 2] = 0;
// Make note of the quantization error.
quantizeError = luminance;
}
// Now diffuse the quantization error to the surrounding pixels, as
// described by Floyd-Steinberg's error diffusion formula. We have a bunch
// of conditionals here to make sure we're not writing outside our pixel
// array.
let idx;
if (x < width - 1) {
idx = mapCoordinatesToImageData(x + 1, y, width);
pixels[idx] = pixels[idx] + (quantizeError / 16) * 7;
}
if (y < height - 1) {
if (x > 0) {
idx = mapCoordinatesToImageData(x - 1, y + 1, width);
pixels[idx] = pixels[idx] + (quantizeError / 16) * 3;
}
idx = mapCoordinatesToImageData(x, y + 1, width);
pixels[idx] = pixels[idx] + (quantizeError / 16) * 5;
if (x < width - 1) {
idx = mapCoordinatesToImageData(x + 1, y + 1, width);
pixels[idx] = pixels[idx] + (quantizeError / 16) * 1;
}
}
}
}
// Finally, draw the modified image data back into the <canvas>.
ctx.putImageData(imageData, 0, 0);
}
/**
* Turns a (x, y) coordinate into an index that can be used to index into a
* one-dimensional ImageData array.
*/
const mapCoordinatesToImageData = (x, y, width) => 4 * (y * width + x);
// Register events.
fileInputElt.addEventListener("change", onFileInputChanged);
imageFormElt.addEventListener("submit", onImageFormSubmitted);
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
--section-gap: 1.5rem;
font-size: 112.5%;
}
body {
font-family:
Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif;
font-weight: normal;
line-height: 1.65;
margin: 0;
padding: 1.5rem;
height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
}
body > * {
width: min(100%, 48rem);
}
main {
flex-grow: 1;
}
footer {
font-size: 0.875rem;
text-align: center;
}
h1 {
margin: 0;
}
h1 + p {
margin-block-start: 0.25rem;
}
form {
margin-block-start: 1.5rem;
display: grid;
grid-template-columns: 3fr 1fr;
gap: 0.5rem;
}
input[type="submit"],
input[type="file"],
input[type="file"]::file-selector-button {
font-size: inherit;
padding: 0.25rem;
min-width: 8rem;
}
input[type="file"] {
border: solid 1px silver;
border-radius: 4px;
}
input[type="file"]:user-invalid {
border-color: red;
}
.preview {
width: 100%;
margin-block-start: 1.5rem;
display: flex;
justify-content: center;
}
.preview canvas {
max-width: 100%;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment