Pixelate Image Render Effect
A canvas-based effect that overlays a pixelated version of an image and progressively sharpens it in steps until the full image is revealed. Trigger modes include load, inview (IntersectionObserver), hover, and click. All parameters are overridable per-element via data attributes.
Code
<div data-pixelate-render="" data-pixelate-render-trigger="inview" class="pixelated-render-image">
<div class="pixelated-render-image__before"></div>
<img data-pixelate-render-img="" class="pixelated-render-image__img" src="https://cdn.prod.website-files.com/68999c401f65a664b49035ad/6899e6b1a8eef7f41f2ee4cd_Portrait%20of%20Man%20in%20Blue.avif" alt="">
</div>.pixelated-render-image {
justify-content: center;
align-items: center;
width: 55vw;
display: flex;
position: relative;
overflow: hidden;
}
.pixelated-render-image__before {
padding-top: 75%;
}
.pixelated-render-image__before.is--portrait {
padding-top: 133.333%;
}
.pixelated-render-image__img {
object-fit: cover;
width: 100%;
height: 100%;
display: block;
position: absolute;
}function initPixelateImageRenderEffect() {
let renderDuration = 150; // Duration per step (in ms)
let renderSteps = 12; // Number of steps from chunky to sharp
let renderColumns = 12; // Starting number of columns at the most pixelated stage
document.querySelectorAll('[data-pixelate-render]').forEach(setupPixelate);
function setupPixelate(root) {
const img = root.querySelector('[data-pixelate-render-img]');
if (!img) return;
const trigger = (root.getAttribute('data-pixelate-render-trigger') || 'load').toLowerCase();
// Per-element overrides
const durAttr = parseInt(root.getAttribute('data-pixelate-render-duration'), 10);
const stepsAttr = parseInt(root.getAttribute('data-pixelate-render-steps'), 10);
const colsAttr = parseInt(root.getAttribute('data-pixelate-render-columns'), 10);
const fitMode = (root.getAttribute('data-pixelate-render-fit') || 'cover').toLowerCase();
const elRenderDuration = Number.isFinite(durAttr) ? Math.max(16, durAttr) : renderDuration;
const elRenderSteps = Number.isFinite(stepsAttr) ? Math.max(1, stepsAttr) : renderSteps;
const elRenderColumns = Number.isFinite(colsAttr) ? Math.max(1, colsAttr) : renderColumns;
const canvas = document.createElement('canvas');
canvas.setAttribute('data-pixelate-canvas', '');
canvas.style.position = 'absolute';
canvas.style.inset = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
root.style.position ||= 'relative';
root.appendChild(canvas);
const ctx = canvas.getContext('2d', { alpha: true });
ctx.imageSmoothingEnabled = false;
const back = document.createElement('canvas');
const tiny = document.createElement('canvas');
const bctx = back.getContext('2d', { alpha: true });
const tctx = tiny.getContext('2d', { alpha: true });
let naturalW = 0, naturalH = 0;
let playing = false, stageIndex = 0, stageStart = 0;
let backDirty = true, resizeTimeout = 0;
let steps = [elRenderColumns];
function fitCanvas() {
const r = root.getBoundingClientRect();
const dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
const w = Math.max(1, Math.round(r.width * dpr));
const h = Math.max(1, Math.round(r.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h;
back.width = w; back.height = h;
backDirty = true;
}
regenerateSteps();
}
function regenerateSteps() {
const cw = Math.max(1, canvas.width);
const startCols = Math.min(elRenderColumns, cw);
const total = Math.max(1, elRenderSteps);
const use = Math.max(1, Math.floor(total * 0.9));
const a = [];
const ratio = Math.pow(cw / startCols, 1 / total);
for (let i = 0; i < use; i++) {
a.push(Math.max(1, Math.round(startCols * Math.pow(ratio, i))));
}
for (let i = 1; i < a.length; i++) if (a[i] <= a[i - 1]) a[i] = a[i - 1] + 1;
steps = a.length ? a : [startCols];
}
function drawImageToBack() {
if (!backDirty || !naturalW || !naturalH) return;
const cw = back.width, ch = back.height;
let dw = cw, dh = ch, dx = 0, dy = 0;
if (fitMode !== 'stretch') {
const s = fitMode === 'cover'
? Math.max(cw / naturalW, ch / naturalH)
: Math.min(cw / naturalW, ch / naturalH);
dw = Math.max(1, Math.round(naturalW * s));
dh = Math.max(1, Math.round(naturalH * s));
dx = ((cw - dw) >> 1);
dy = ((ch - dh) >> 1);
}
bctx.clearRect(0, 0, cw, ch);
bctx.imageSmoothingEnabled = true;
bctx.drawImage(img, dx, dy, dw, dh);
backDirty = false;
}
function pixelate(columns) {
const cw = canvas.width, ch = canvas.height;
const cols = Math.max(1, Math.floor(columns));
const rows = Math.max(1, Math.round(cols * (ch / cw)));
if (tiny.width !== cols || tiny.height !== rows) { tiny.width = cols; tiny.height = rows; }
tctx.imageSmoothingEnabled = false;
tctx.clearRect(0, 0, cols, rows);
tctx.drawImage(back, 0, 0, cw, ch, 0, 0, cols, rows);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(tiny, 0, 0, cols, rows, 0, 0, cw, ch);
}
function draw(stepCols) {
if (!canvas.width || !canvas.height) return;
drawImageToBack();
pixelate(stepCols);
}
function animate(t) {
if (!playing) return;
if (!stageStart) stageStart = t;
if (t - stageStart >= elRenderDuration) { stageIndex++; stageStart = t; }
draw(steps[Math.min(stageIndex, steps.length - 1)]);
if (stageIndex >= steps.length - 1) {
canvas.style.opacity = '0';
playing = false;
window.removeEventListener('resize', onWindowResize);
setTimeout(() => { canvas.remove(); }, 250);
return;
}
requestAnimationFrame(animate);
}
function prime() {
fitCanvas();
const run = () => {
naturalW = img.naturalWidth; naturalH = img.naturalHeight;
if (!naturalW || !naturalH) return;
stageIndex = 0;
canvas.style.opacity = '1';
backDirty = true;
draw(steps[0]);
};
if (img.complete && img.naturalWidth) run(); else img.addEventListener('load', run, { once: true });
}
function start() {
if (playing) return;
fitCanvas();
const run = () => {
naturalW = img.naturalWidth; naturalH = img.naturalHeight;
if (!naturalW || !naturalH) return;
stageIndex = 0; stageStart = 0; canvas.style.opacity = '1';
backDirty = true;
playing = true;
requestAnimationFrame(animate);
};
if (img.complete && img.naturalWidth) run(); else img.addEventListener('load', run, { once: true });
}
function onResize() {
fitCanvas();
if (!playing) draw(steps[Math.min(stageIndex, steps.length - 1)] || steps[0]);
}
function onWindowResize() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(onResize, 250);
}
if (trigger === 'load') {
prime();
start();
} else if (trigger === 'inview') {
prime();
const io = new IntersectionObserver(es => {
for (const e of es) if (e.isIntersecting) { start(); io.disconnect(); break; }
}, { rootMargin: '0px 0px -25% 0px', threshold: 0.25 });
io.observe(root);
window.addEventListener('resize', onWindowResize);
} else if (trigger === 'hover') {
prime();
root.addEventListener('mouseenter', start, { once: true });
window.addEventListener('resize', onWindowResize);
} else if (trigger === 'click') {
prime();
root.addEventListener('click', start, { once: true });
window.addEventListener('resize', onWindowResize);
}
}
}
// Initialize Pixelate Image Render Effect
document.addEventListener('DOMContentLoaded', () => {
initPixelateImageRenderEffect();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| [data-pixelate-render] | attribute | — | The container element. Wraps the image and receives the overlay canvas. Controls when the effect runs via the trigger attribute. |
| [data-pixelate-render-trigger] | "load" | "inview" | "hover" | "click" | "load" | Controls when the animation starts. "load" fires immediately after the image loads. "inview" fires when the element is 25% visible in the viewport. "hover" fires on first mouseenter. "click" fires on first click. |
| [data-pixelate-render-img] | attribute | — | The image element inside the container. The script draws this into an offscreen buffer and renders the pixelated overlay canvas on top of it. |
| [data-pixelate-render-duration] | number | 150 | Duration of each animation step in milliseconds. Lower values make the transition snappier; higher values make it slower and more gradual. |
| [data-pixelate-render-steps] | number | 12 | Number of pixelation stages between the starting chunky look and the final sharp image. |
| [data-pixelate-render-columns] | number | 12 | Number of pixel columns at the most pixelated stage. Rows are calculated automatically to maintain square pixels. |
| [data-pixelate-render-fit] | "cover" | "contain" | "stretch" | "cover" | "cover" fills the box and crops if needed. "contain" keeps the full image visible and may letterbox. "stretch" ignores aspect ratio. |
Notes
- •No external dependencies — runs on vanilla JS using the Canvas 2D API and IntersectionObserver.
- •The pixelation technique uses two offscreen canvases: one full-size back buffer and one tiny canvas. The image is downsampled to the tiny canvas then upscaled back with imageSmoothingEnabled: false to produce the blocky pixel look.
- •The overlay canvas is removed from the DOM after the animation completes (after a 250ms fade-out delay) to free up memory.
- •prime() draws the first (most pixelated) frame immediately so the image is never shown un-pixelated before the animation starts — even on slow connections.
- •All resize handlers use a 250ms debounce to avoid layout thrashing during window resize.
- •Multiple [data-pixelate-render] elements on the same page are fully supported — each has its own canvas, offscreen buffers, and state.
Guide
Container
Wrap each image in a container with [data-pixelate-render]. This element controls when the effect runs based on the trigger. The script appends the pixelation canvas as an absolute child of this element.
Image
Place a single image inside the container with [data-pixelate-render-img]. The script draws the fitted image into an offscreen buffer and renders a pixelated overlay canvas on top. The canvas is removed after the animation completes.
Trigger
Control when the animation runs with [data-pixelate-render-trigger]. The four accepted values are: "load" (starts immediately once the image has loaded), "inview" (starts the first time the element is sufficiently in the viewport), "hover" (starts on first mouseenter), and "click" (starts on first click).
Optional overrides
All parameters can be overridden per-element using data attributes. Duration controls the time per step in ms, steps controls how many stages there are, columns sets the starting pixelation level, and fit controls how the image is scaled.
<div
data-pixelate-render
data-pixelate-render-trigger="inview"
data-pixelate-render-duration="120"
data-pixelate-render-steps="10"
data-pixelate-render-columns="16"
data-pixelate-render-fit="cover">
<img data-pixelate-render-img src="image.jpg" alt="">
</div>Viewport behavior
The "inview" trigger uses IntersectionObserver with threshold: 0.25, meaning about 25% of the element must be visible before the animation starts. The rootMargin "0px 0px -25% 0px" shrinks the bottom detection zone by 25% of the viewport height, delaying the trigger slightly until the element is a bit more on screen.