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#111is 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 therevealedclass 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.imgwrapper (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 aloadlistener. Then callstartEffect(img, canvas, staggerDelay).
Step 1 — image → ASCII grid (imageToAsciiGrid)
- Center-crop to 4:5. Compare
imageAspect = naturalWidth/naturalHeightagainstitemAspect = 4/5. If the image is wider, crop width tonaturalHeight * 4/5and center horizontally; else crop height tonaturalWidth / (4/5)and center vertically. (This mirrors CSSobject-fit:cover.) - Downsample. Draw the cropped region into a tiny offscreen canvas sized
ASCII_COLUMNS × ASCII_ROWS(25×20) viadrawImage(img, cropX, cropY, cropW, cropH, 0,0, 25, 20), thengetImageData. - Per pixel → glyph. For each of the 500 cells compute luminance
brightness = (R*0.299 + G*0.587 + B*0.114) / 255, thencharIndex = 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) andbrightnessGrid[row][col] = charIndex(its numeric index).
Step 2 — prepare canvas (prepareCanvas)
dpr = 2. Setcanvas.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. TrackscrambleState = new Array(500).fill(null)andsettledCount = 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,colfrom its index.isDark = brightnessGrid[row][col] > denseCharIndex(i.e.charIndex > 7). - Bright cell (
!isDark): draw its final glyph immediately, setscrambleState = 0, incrementsettledCount. It's done in one shot. - Dark cell: draw a random glyph from
denseCharsand setscrambleState = 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 whosescrambleStateis> 0: - If
remaining === 1: draw the final glyph (asciiGrid[row][col]), set state0, incrementsettledCount(cell settles). - Else: draw a new random
denseCharsglyph and decrementscrambleStateby 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,clearIntervalthe ticker. - Reveal: the moment
settledCount === totalCells(reached in either the pop-in path or the ticker), callscheduleImageReveal(canvas)→setTimeout(() => canvas.closest(".img").classList.add("revealed"), REVEAL_DELAY_MS). Therevealedclass 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.
100svhheight andsvh-safe sizing keep the grid within one viewport on mobile browser chrome.- The
#111used 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.