AnimationsIntermediateApril 9, 2026
Pixelate Image
Canvas-based pixelation reveal for images. Renders the image through progressively finer pixel grids until sharp. Supports inview, hover, click, and load triggers. Zero dependencies — pure canvas and requestAnimationFrame.
View Full Demo →Preview
Source
demo.jsx
import PixelateImage from "./index.jsx";
import styles from "./demo.module.css";
export default function PixelateImageDemo() {
return (
<div className={styles.page}>
{/* Hero */}
<section className={styles.hero}>
<PixelateImage
src="/demo-assets/models/model3.png"
alt=""
trigger="load"
duration={110}
steps={16}
columns={10}
className={styles.heroImage}
/>
<div className={styles.heroOverlay} />
<div className={styles.heroContent}>
<p className={styles.heroLabel}>Canvas · No dependencies</p>
<h1 className={styles.heroTitle}>Pixelate<br />Image</h1>
</div>
</section>
{/* Inview trigger */}
<section className={styles.section}>
<p className={styles.sectionLabel}>trigger="inview"</p>
<p className={styles.sectionTitle}>Resolves pixel-by-pixel as it enters the viewport.</p>
<PixelateImage
src="/demo-assets/westend.png"
alt="Westend"
trigger="inview"
duration={120}
steps={14}
columns={10}
className={styles.banner}
/>
</section>
{/* Hover trigger */}
<section className={styles.section}>
<p className={styles.sectionLabel}>trigger="hover"</p>
<p className={styles.sectionTitle}>Hover to reveal.</p>
<div className={styles.grid}>
{[
{ src: "/demo-assets/models/model0.png", label: "Sarah Chen" },
{ src: "/demo-assets/models/model1.png", label: "Marcus Reed" },
{ src: "/demo-assets/models/model2.png", label: "Aisha Williams" },
].map(({ src, label }) => (
<div key={src} className={styles.cardWrap}>
<PixelateImage
src={src}
alt={label}
trigger="hover"
duration={100}
steps={10}
columns={8}
className={styles.card}
/>
<span className={styles.cardLabel}>{label}</span>
</div>
))}
</div>
</section>
{/* Click trigger */}
<section className={styles.section}>
<p className={styles.sectionLabel}>trigger="click"</p>
<p className={styles.sectionTitle}>Click to reveal.</p>
<div className={styles.grid}>
{[
{ src: "/demo-assets/models/model3.png", label: "Jordan Park" },
{ src: "/demo-assets/models/model4.png", label: "Elena Torres" },
{ src: "/demo-assets/kickandbass.png", label: "Kickandbass" },
].map(({ src, label }) => (
<div key={src} className={styles.cardWrap}>
<PixelateImage
src={src}
alt={label}
trigger="click"
duration={140}
steps={16}
columns={12}
className={styles.card}
/>
<span className={styles.cardLabel}>{label}</span>
</div>
))}
</div>
</section>
</div>
);
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
export default function PixelateImage({
src,
alt = "",
trigger = "inview",
duration = 150,
steps = 12,
columns = 12,
fit = "cover",
className = "",
}) {
const rootRef = useRef(null);
const imgRef = useRef(null);
useEffect(() => {
const root = rootRef.current;
const img = imgRef.current;
if (!root || !img) return;
const elRenderDuration = Math.max(16, duration);
const elRenderSteps = Math.max(1, steps);
const elRenderColumns = Math.max(1, columns);
const fitMode = fit.toLowerCase();
// Canvas setup
const canvas = document.createElement("canvas");
canvas.setAttribute("data-pixelate-canvas", "");
canvas.style.cssText =
"position:absolute;inset:0;width:100%;height:100%;pointer-events:none;";
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 rafId = null;
let stepsArr = [elRenderColumns];
let io = null;
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;
}
stepsArr = 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(cols) {
const cw = canvas.width,
ch = canvas.height;
cols = Math.max(1, Math.floor(cols));
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(stepsArr[Math.min(stageIndex, stepsArr.length - 1)]);
if (stageIndex >= stepsArr.length - 1) {
canvas.style.opacity = "0";
playing = false;
window.removeEventListener("resize", onWindowResize);
setTimeout(() => canvas.remove(), 250);
return;
}
rafId = 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(stepsArr[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;
rafId = requestAnimationFrame(animate);
};
if (img.complete && img.naturalWidth) run();
else img.addEventListener("load", run, { once: true });
}
function onResize() {
fitCanvas();
if (!playing) {
draw(stepsArr[Math.min(stageIndex, stepsArr.length - 1)] || stepsArr[0]);
}
}
function onWindowResize() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(onResize, 250);
}
if (trigger === "load") {
prime();
start();
} else if (trigger === "inview") {
prime();
io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
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);
}
return () => {
playing = false;
if (rafId) cancelAnimationFrame(rafId);
clearTimeout(resizeTimeout);
window.removeEventListener("resize", onWindowResize);
io?.disconnect();
if (canvas.parentNode) canvas.remove();
};
}, [src, trigger, duration, steps, columns, fit]);
return (
<div ref={rootRef} className={className} style={{ position: "relative" }}>
<img
ref={imgRef}
src={src}
alt={alt}
style={{ display: "block", width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
);
}
demo.module.css
.page {
background: #111;
color: #efeeec;
min-height: 100vh;
}
/* Hero */
.hero {
height: 100vh;
position: relative;
overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.heroImage {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.heroOverlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.1) 60%);
z-index: 1;
pointer-events: none;
}
.heroContent {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 3rem 5vw;
}
.heroLabel {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
margin: 0 0 1.5rem;
}
.heroTitle {
font-size: clamp(3.5rem, 10vw, 9rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 0.95;
margin: 0;
}
/* Sections */
.section {
padding: 8vh 5vw;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.sectionLabel {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.3);
margin: 0 0 2rem;
}
.sectionTitle {
font-size: clamp(1.25rem, 2.5vw, 2rem);
font-weight: 400;
letter-spacing: -0.01em;
margin: 0 0 3rem;
color: rgba(255, 255, 255, 0.7);
}
/* Inview — full-width banner */
.banner {
width: 100%;
aspect-ratio: 16 / 7;
}
/* Hover / Click — card grid */
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.card {
aspect-ratio: 3 / 4;
cursor: pointer;
}
/* Card label */
.cardWrap {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cardLabel {
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
}
@media (max-width: 767px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}