All components

ASCII Image Reveal Effect

JavaScript animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

On page load every photo in a 15-image editorial grid first renders as ASCII art on a canvas: cells pop in one by one in random order while dark cells scramble through dense glyphs before settling, and once a whole canvas settles it swaps to the real photo.

How it's built

Categorygallery
Techvanilla JS
Complexitysection
Performance costmedium
Mobile-safeyes

ascii canvas reveal scramble grid gallery load vanilla-js

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

ASCII Image Reveal Effect

Goal

Build a full-viewport editorial photo grid where, on page load, every photo first materialises as live ASCII art painted on a <canvas>, then swaps to the real photo. Each canvas fills itself cell-by-cell in random order; bright cells snap straight to their final glyph, while dark cells scramble through a stream of dense random glyphs for about a second before locking in. When a whole canvas has fully settled it dissolves and reveals the underlying photograph. Images kick off one after another in a staggered cascade across the grid.

Tech

Vanilla HTML/CSS/JS. No GSAP, no libraries, no framework, no npm dependencies — the entire effect is hand-rolled with the Canvas 2D API plus setTimeout / setInterval timers. A single ES-module script (<script type="module" src="./script.js">) drives everything. Do not reach for GSAP or a rAF tween library; the original is intentionally timer-driven and per-cell.

Layout / HTML

A single <section class="gallery"> containing 15 <div class="img"> wrappers. Each wrapper holds one <img class="ascii-reveal" src="…" />. The script inserts a <canvas> as a sibling of the <img> inside each .img at runtime.

<section class="gallery">
  <div class="img"><img class="ascii-reveal" src="/…/img1.jpg" /></div>
  <div class="img"><img class="ascii-reveal" src="/…/img2.jpg" /></div>
  … 15 total …
</section>

The .img DOM order is what drives the load stagger (item 1 starts first, item 15 last).

Styling

Reset & page
  • * { margin:0; padding:0; box-sizing:border-box; }
  • body { background:#111; } — near-black. This exact #111 is reused as the canvas cell background so the ASCII art sits seamlessly on the page.
Grid (this defines the scattered look)

.gallery:

  • position:relative; width:100%; height:100svh; padding:2rem;
  • display:grid; grid-template-columns:repeat(8, 1fr); grid-template-rows:repeat(4, 1fr); gap:2rem;

An 8×4 grid, but only 15 cells are filled and the items are scattered / sparse, not sequential. Place each .img:nth-child(n) at an explicit grid-column / grid-row (note some columns exceed 8, which pushes those items into implicit tracks — keep them as-is for the scattered composition):

| item | column | row | |------|--------|-----| | 1 | 1 | 1 | | 2 | 2 | 1 | | 3 | 5 | 1 | | 4 | 1 | 2 | | 5 | 3 | 2 | | 6 | 6 | 2 | | 7 | 8 | 2 | | 8 | 1 | 3 | | 9 | 2 | 3 | | 10 | 4 | 3 | | 11 | 7 | 3 | | 12 | 10 | 3 | | 13 | 2 | 4 | | 14 | 6 | 4 | | 15 | 9 | 4 |

Tiles, images, canvas
  • .img { position:relative; width:100%; aspect-ratio:4/5; align-self:center; overflow:hidden; } — every tile is a 4:5 portrait box.
  • .img img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:none; } — the real photo is hidden by default (display:none) and only shown after reveal.
  • .img canvas { position:absolute; inset:0; width:100%; height:100%; } — the runtime canvas is stretched to fill the tile.
  • Reveal swap classes: .img.revealed canvas { display:none; } and .img.revealed img { display:block; }. Adding the revealed class to a tile hides its canvas and shows the photo — this is the only "reveal" mechanism (a hard swap, no cross-fade).
Responsive

@media (max-width:1000px): .gallery { height:auto; grid-template-columns:repeat(2,1fr); grid-template-rows:none; } and .img:nth-child(n) { grid-column:auto; grid-row:auto; } — the scattered placement collapses to a plain 2-column auto-flow grid.

The effect (be exact — this is the whole component)

There is no timeline library. The animation is a per-cell state machine driven by setTimeout (initial pop-in) and one shared setInterval (the scramble ticker) per image. Reproduce these constants and formulas precisely.

Constants (top of the module)
const ASCII_CHARS      = "........:::=+xX#0369"; // 20 chars: 8 dots, then :::=+xX#0369
const FONT_SIZE        = 14;   // px, monospace
const ASPECT_WIDTH     = 4;
const ASPECT_HEIGHT    = 5;
const ASCII_COLUMNS    = 25;   // sampling columns
const IMAGE_STAGGER_MS = 100;  // delay between images (by DOM index)
const CELL_APPEAR_MS   = 2;    // delay between cells popping in within one image
const SCRAMBLE_COUNT   = 10;   // how many scramble steps a dark cell goes through
const SCRAMBLE_SPEED_MS= 100;  // scramble ticker interval
const REVEAL_DELAY_MS  = 0;    // delay after full settle before showing the photo
Derived values
  • denseCharIndex = ASCII_CHARS.lastIndexOf(".")7 (the last of the 8 leading dots).
  • denseChars = ASCII_CHARS.slice(denseCharIndex + 1).split("") → the dark/dense ramp [":",":",":","=","+","x","X","#","0","3","6","9"] (12 glyphs). These are the glyphs used for scrambling.
  • Measure a monospace glyph once with an offscreen context set to ${FONT_SIZE}px monospace: charWidth = Math.ceil(measureText("M").width) (≈ 9px), charHeight = FONT_SIZE (14px).
  • ASCII_ROWS = Math.round(ASCII_COLUMNS * (ASPECT_HEIGHT/ASPECT_WIDTH) * (charWidth/charHeight))round(25 * 1.25 * 9/14)20. So the sampling grid is 25 columns × 20 rows = 500 cells per image (matching the 4:5 tile aspect).
Setup per image

document.querySelectorAll("img.ascii-reveal").forEach((img, index) => { … }):

  • Create a <canvas>, append it into the .img wrapper (img.closest(".img").appendChild(canvas)).
  • staggerDelay = index * IMAGE_STAGGER_MS (0, 100, 200, … 1400 ms) — each image's whole animation is offset by its DOM index.
  • Start when the image is decoded: if img.complete && img.naturalWidth, run immediately; else attach a load listener. Then call startEffect(img, canvas, staggerDelay).
Step 1 — image → ASCII grid (imageToAsciiGrid)
  1. Center-crop to 4:5. Compare imageAspect = naturalWidth/naturalHeight against itemAspect = 4/5. If the image is wider, crop width to naturalHeight * 4/5 and center horizontally; else crop height to naturalWidth / (4/5) and center vertically. (This mirrors CSS object-fit:cover.)
  2. Downsample. Draw the cropped region into a tiny offscreen canvas sized ASCII_COLUMNS × ASCII_ROWS (25×20) via drawImage(img, cropX, cropY, cropW, cropH, 0,0, 25, 20), then getImageData.
  3. Per pixel → glyph. For each of the 500 cells compute luminance brightness = (R*0.299 + G*0.587 + B*0.114) / 255, then charIndex = Math.min(ASCII_CHARS.length - 1, Math.floor((1 - brightness) * ASCII_CHARS.length)). Bright pixels → low index (dots / :), dark pixels → high index (#0369). Store two parallel grids: asciiGrid[row][col] = ASCII_CHARS[charIndex] (the final glyph) and brightnessGrid[row][col] = charIndex (its numeric index).
Step 2 — prepare canvas (prepareCanvas)
  • dpr = 2. Set canvas.width = ASCII_COLUMNS * charWidth * dpr, canvas.height = ASCII_ROWS * charHeight * dpr (backing store at 2×), while CSS stretches it to fill the tile.
  • Fill the whole canvas with #111 (the page background) so it starts as a solid dark block.
Drawing a cell (drawCharacter)

Each cell paint = fill its charWidth × charHeight rect with #111 (erase), then fillText the glyph in #c8c8c8 (light gray) at (col*charWidth, row*charHeight). Before animating, the render context is set: ctx.setTransform(dpr,0,0,dpr,0,0), ctx.font = "${charHeight}px monospace", ctx.textBaseline = "top".

Step 3 — animate cells (animateCells) — the core
  • totalCells = 25*20 = 500. Track scrambleState = new Array(500).fill(null) and settledCount = 0.
  • Random pop-in order: build cellOrder = [0..499] and Fisher–Yates shuffle it. Cells appear in this shuffled order, so the picture assembles as random scattered speckle, not row-by-row.
  • Schedule each cell with setTimeout(fn, staggerDelay + i * CELL_APPEAR_MS) (i = position in the shuffled order, so cells within an image fire 2 ms apart on top of the image's stagger). When a cell fires:
  • Compute row, col from its index. isDark = brightnessGrid[row][col] > denseCharIndex (i.e. charIndex > 7).
  • Bright cell (!isDark): draw its final glyph immediately, set scrambleState = 0, increment settledCount. It's done in one shot.
  • Dark cell: draw a random glyph from denseChars and set scrambleState = SCRAMBLE_COUNT (10) — this cell now enters the scramble loop.
  • Scramble ticker: one setInterval(…, SCRAMBLE_SPEED_MS) (every 100 ms). Each tick, for every cell whose scrambleState is > 0:
  • If remaining === 1: draw the final glyph (asciiGrid[row][col]), set state 0, increment settledCount (cell settles).
  • Else: draw a new random denseChars glyph and decrement scrambleState by 1.
  • So a dark cell flickers through ~9 random dense glyphs over ~1 s (10 steps × 100 ms) before snapping to its true value. Bright cells never scramble.
  • When no cells are still scrambling and settledCount === totalCells, clearInterval the ticker.
  • Reveal: the moment settledCount === totalCells (reached in either the pop-in path or the ticker), call scheduleImageReveal(canvas)setTimeout(() => canvas.closest(".img").classList.add("revealed"), REVEAL_DELAY_MS). The revealed class hides the canvas and shows the real photo (instant swap).
Net timing feel
  • Image 1 begins at load; each subsequent image begins 100 ms later (staggered cascade across the sparse grid).
  • Within an image: 500 cells pop in over ~1 s (500 × 2 ms) in random order; dark regions keep flickering through dense glyphs for ~1 s each; once the last cell settles the canvas instantly becomes the photograph.
  • No easing curves, no interpolation, no scroll/hover triggers — it's a pure on-load autoplay, once.

Assets / images

15 images, a cohesive editorial fashion & cosmetics set that pairs clean studio product still lifes with grainy motion-blur portraiture, held together by a muted neutral palette (warm beige/taupe backgrounds, creamy off-whites, brushed silver/grey, soft blush-pink accents, plus a couple of near-black grounds and warm skin tones). All tiles are rendered as 4:5 portraits; the script center-crops to 4:5 regardless of source aspect (sources range from tall portrait through square to wide landscape) and downsamples heavily, so fine detail is lost and overall composition, tone and contrast matter far more than resolution. The set mixes these roles:

  • Product / packaging still lifes — the bulk of the set, softly studio-lit: a brushed-aluminium screw-top tin on a pale grey seamless; a cropped cluster of skincare/makeup on a warm beige backdrop (frosted cream jar, a rose-gold lipstick with a soft pink bullet, a frosted pump bottle); a cream cosmetic bottle with a glossy black cap staged on a stone slab among dried beige gypsophila (a wide landscape source); and an open silver compact with a nude/peach pressed powder shot on a dark near-black ground for high contrast.
  • Fashion / product-on-body detail — a cropped studio shot of a beige suede cowboy boot with a dark stacked heel mid-stride against a pale off-white seamless.
  • Motion-blur editorial portraits — grainy, moody figures caught in movement: a mid-body figure in a grey top and white trousers against cool grey; a high-key, washed-out pale-pink close-up of a fair-haired face; and a warm-toned, near-black nightlife frame of blurred faces and skin.

Any set of ~15 minimal, softly-lit fashion/cosmetics images (neutral product still lifes plus a few blurred editorial portraits) at 4:5 works. Do not use real brand imagery. Because dark cells scramble and light cells settle instantly, frames with clear light/dark separation (pale subject on a soft ground, or a dark subject on a light ground — e.g. the silver compact on black) read best; the mostly light, low-contrast product shots settle quickly with only small pockets of scramble in the shadows.

Behavior notes

  • Desktop-first. The scattered 8×4 grid is designed for wide viewports; below 1000px it collapses to a simple 2-column flow (see Responsive).
  • Runs once on load — there is no reduced-motion branch, no loop, no scroll/hover/click interaction in the original.
  • 100svh height and svh-safe sizing keep the grid within one viewport on mobile browser chrome.
  • The #111 used for the page background, canvas fill, and per-cell erase must all match, or the ASCII art won't sit invisibly on the page. Glyph color is #c8c8c8.