/TUTORIAL · JAVASCRIPT

How to Convert an Image to ASCII Art in JavaScript

The short answer

Draw the image onto a <canvas> scaled down to the number of columns you want, read the pixels with getImageData, turn each pixel into brightness, and swap it for a character from a dark-to-light ramp like @%#*+=-:. plus a space. Squash the rows by about half so monospace characters don't stretch the result. About 60 lines, no libraries, nothing uploaded.

ASCII art used to live in IRC banners, README headers and the loading screens of software that took itself a little less seriously. I'm a designer and I build ASCII Magic, so I spend an unreasonable amount of time turning images into other images, and ASCII is still the one I come back to.

The nice part is how small the core is. This post builds the whole pipeline from scratch, in the browser, with nothing but a canvas and the pixel data it hands you. Every ASCII output below is the real result of the code in this post.

The mental model

An image is a grid of pixels. Each pixel has a brightness. ASCII art swaps each small block of the image for a character whose visual density matches that brightness: a dense @ where the image is dark, a sparse . where it's light, a space where it's brightest. Everything below is plumbing to get from "an image" to "a brightness per cell" to "a character per brightness."

Step 1: Load an image onto a canvas

Three elements: a file input, a hidden canvas to do the pixel work, and a <pre> to print into.

<input type="file" id="file" accept="image/*" />
<canvas id="canvas" hidden></canvas>
<pre id="output"></pre>

When a file is picked, load it into an Image and hand it to the renderer. URL.createObjectURL gives a local reference to the file, so nothing leaves the page.

const fileInput = document.getElementById("file");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

fileInput.addEventListener("change", (e) => {
  const img = new Image();
  img.onload = () => renderAscii(img);
  img.src = URL.createObjectURL(e.target.files[0]);
});
The source image: a 3D character in a red t-shirt and sunglasses on a flat blue background
The source image. A clear subject on a plain background converts best, more on why below.

Step 2: Downsample by drawing small

Here's the move that makes the rest easy. Instead of reading millions of pixels and averaging them into blocks by hand, let the canvas do it: draw the image scaled down to the number of columns you want, and every canvas pixel already is one character cell.

function renderAscii(img) {
  const cols = 120; // ASCII "resolution": 80 is chunky, 200 is fine
  const scale = cols / img.width;
  const rows = Math.floor(img.height * scale);

  canvas.width = cols;
  canvas.height = rows;
  ctx.drawImage(img, 0, 0, cols, rows);

  const { data } = ctx.getImageData(0, 0, cols, rows);
  // data is a flat array: [r, g, b, a, r, g, b, a, ...]
}

getImageData returns a Uint8ClampedArray where every four entries are one pixel's red, green, blue and alpha, each 0 to 255. cols is the single knob that controls detail.

Step 3: Turn each pixel into brightness

Eyes don't weigh red, green and blue equally (we're most sensitive to green), so a naive (r + g + b) / 3 looks muddy. Use the standard luminance weights:

function brightness(r, g, b) {
  return 0.299 * r + 0.587 * g + 0.114 * b; // 0 to 255
}
The same image converted to grayscale brightness using the luminance formula
The brightness map the algorithm actually sees.

Step 4: Map brightness to a character ramp

A ramp is a string of characters ordered from densest to lightest. Index into it with each pixel's brightness:

const ramp = "@%#*+=-:. "; // dark -> light

function charFor(b) {
  const i = Math.floor((b / 255) * (ramp.length - 1));
  return ramp[i];
}

One decision up front: this ramp assumes dark text on a light background (more ink reads darker). If you render light text on a dark terminal, reverse the string, otherwise your portrait comes out as a photo negative. Now walk the grid:

let ascii = "";
for (let y = 0; y < rows; y++) {
  for (let x = 0; x < cols; x++) {
    const i = (y * cols + x) * 4;
    ascii += charFor(brightness(data[i], data[i + 1], data[i + 2]));
  }
  ascii += "\n";
}
document.getElementById("output").textContent = ascii;
========================================================
========================================================
========================================================
========================================================
==========================-::-==========================
=========================-:::===========================
=========================---=+++========================
=========================-=+*###**+=====================
==========================+#######*=====================
==========================#%**##+++=====================
==========================*%*++=========================
=========================+##*++=-:======================
==========================++++++-:-=====================
=============================+*+=-======================
============================+**++=======================
==========================+*#*==========================
==========================+**+==========================
=========================***++==========================
========================+***+===========================
========================*****=-=========================
========================*****+==========================
=======================+*++##*==========================
=======================+*++***+=========================
=======================+*++##*+=========================
=======================+**+*#**=========================
=======================+=-+****+========================
======================+*=--****+========================
======================#%+-:+****========================
======================*%%+--****========================
======================#%%#+-=+++========================
======================*%%##++*++========================
=======================*##%%#**+========================
=======================+##%%##*+========================
========================+*#%%#*+-=======================
=========================*##%#*+-=======================
========================+###%#*+========================
========================*%%####*=-======================
=======================+#%%%##%#*=======================
=======================*#%%%%%%#*=-=====================
======================*##%%#*##*+=-=====================
======================+*###+=*#*++======================
=======================+*#*==+#**=======================
=================--===++=+==============================
================::-==+============-=====================
==============-:::-=+-============--:-====--============
=============-:::::-=-==============-:::::..-===========
==============::-:::---===========+=:::::...:===========
==============-::-:::::-========---::::::::::===========
================::--:::::-======-------:::--============
=================-:-::::::-=====------::--==============
=================--------:-=====--:::--=================
==========================-====================-========
----=====================================---------------
====---------------------------------------------==-====
==================================-------===============
========================================================
First run, real output at 56 columns. Recognizable, but look how tall and thin the character got.

Step 5: The gotcha that stretches every first attempt

That output is stretched tall, and it's not obvious why. Monospace characters aren't square: a character cell is roughly twice as tall as it is wide. So a grid that's correct in pixels becomes about 2x too tall once it's made of characters. The fix is one number, squash the rows when you compute them:

const rows = Math.floor(img.height * scale * 0.5); // font aspect fix

Exactly 0.5 isn't magic; it depends on the font. Anywhere from 0.45 to 0.55 covers most monospace stacks, so tune it once against your CSS.

========================================================
========================================================
==========================-::===========================
=========================-==+***+++=====================
==========================*#*###**+=====================
=========================+##*++==-======================
==========================++++*+=-======================
===========================+**+++=======================
=========================+***+==========================
========================+****===========================
========================**+***==========================
=======================+*++*#*+=========================
=======================++++****=========================
======================*#+--+***+========================
======================*%%*=-+*++========================
======================+####****+========================
========================*##%%#*+========================
========================+###%#*+========================
=======================+#%%%####+=======================
======================+*#%%%*##*+=-=====================
======================+**#*+=+#*++======================
================-:-===+==+==============================
==============-:::--=-==============------::-===========
==============-::-:::---========-==-::::::..:===========
================----::::::-=====------:-----============
===================--------=====-------=================
==============================-=========----------=-====
========================================================
Same image, same 56 columns, rows multiplied by 0.5. The proportions are right.

Step 6: Make it usable

Tighten the styling so the characters pack into a solid image, and give people a way to copy it:

#output {
  font-family: monospace;
  font-size: 6px;
  line-height: 6px;
  letter-spacing: 0;
  white-space: pre;
}
copyBtn.addEventListener("click", () => {
  navigator.clipboard.writeText(document.getElementById("output").textContent);
});
Final ASCII render of the character at 110 columns, produced by the code in this post
The finished output at 110 columns, straight from the 60 lines above.

That's the whole thing: load, downsample, brightness, ramp, render. No dependencies, and it never touches the network.

Why your output might look muddy

The source image for this post was picked on purpose, and it's worth knowing why. When I ran the exact same code on a moody portrait and on a dark cloudscape, both came out as a wall of @ and %. The basic algorithm maps brightness linearly, so a photo that lives mostly in the shadows crowds into two or three characters and the subject disappears.

Three fixes, from easiest to best: start with a high-contrast source (a clear subject against a plain background), stretch the contrast before mapping (rescale the darkest pixel to 0 and the brightest to 255), or add dithering so in-between tones are spread across neighbouring cells instead of collapsing into one character.

Where to take it next

A cloudscape rendered as colour ASCII art in ASCII Magic, the polished version of the same technique
The same idea taken further in ASCII Magic: colour kept per cell, a tuned ramp, and tone handling on the kind of dark photo the basic version turns to mush.

The core never changes: pixels in, brightness per cell, a character per brightness. Once that clicks, every variation above is a small tweak on the same loop.

Frequently asked questions

How do you convert an image to ASCII art in JavaScript?
Draw the image onto a canvas scaled down to the number of columns you want, read the pixels with getImageData, convert each pixel to brightness with 0.299R + 0.587G + 0.114B, and replace it with a character from a dark-to-light ramp. It takes about 60 lines and no libraries.
Why does my ASCII art look stretched vertically?
Monospace characters are roughly twice as tall as they are wide, so a grid that is correct in pixels comes out about 2x too tall in characters. Multiply the row count by about 0.5 (0.45 to 0.55 depending on the font).
What characters should I use for the ramp?
A 10-character ramp that runs from dense to sparse, such as @%#*+=-:. followed by a space, is a solid default. Longer ramps give smoother gradients. Reverse it when you render light text on a dark background.
Why does my ASCII output look muddy?
The basic algorithm maps brightness linearly, so dark or low-contrast photos crowd into two or three characters. Start from a high-contrast image, stretch the contrast first, or add dithering.
Can I convert video to ASCII in the browser too?
Yes. Draw each frame of a video element onto the canvas inside a requestAnimationFrame loop and run the same conversion per frame.
Keep reading
A photo turned into ASCII art for the photo-to-ASCII tutorial
Tutorial

How to Make ASCII Art From a Photo

No code: drop a photo in the editor, pick a ramp, tune contrast, export.

June 8, 2026
A photo turned into dither art for the complete dithering guide
Guide

The Complete Guide to Dithering

The fix for muddy tones: how dithering spreads in-between brightness across cells.

June 28, 2026

/SKIP THE CODE

Turn any photo into ASCII art, in your browser.

Every character ramp and slider updates the preview instantly. Export as PNG, GIF or MP4 at up to 4× resolution.

Open the editor →