Image Effects
PixelReveal
The picture starts as big square blocks and sharpens in steps when it comes on screen, then a plain picture is left. Stays hidden if JavaScript is off.
Download
- React
- Tailwind CSS v4
- motion
components/ui/pixel-reveal.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
type PixelRevealProps = {
src: string;
alt: string;
/** Size of the first blocks in pixels. */
block?: number;
/** Seconds from the first blocks to the sharp picture. */
duration?: number;
/** false plays it again each time it comes back on screen. */
once?: boolean;
/** Set the size here. */
className?: string;
};
/* PixelReveal: the picture starts as big square blocks and sharpens in steps when it comes on screen. */
export function PixelReveal({ src, alt, block = 64, duration = 0.9, once = true, className = "aspect-video" }: PixelRevealProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [done, setDone] = useState(false);
const sharp = done || reduce === true;
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx || sharp) return;
const image = new Image();
const small = document.createElement("canvas");
const smallCtx = small.getContext("2d");
let timer = 0;
let inView = false;
let loaded = false;
const paint = (size: number) => {
const { width, height } = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const scale = Math.max(width / image.naturalWidth, height / image.naturalHeight);
const sw = width / scale;
const sh = height / scale;
small.width = Math.max(1, Math.ceil(width / size));
small.height = Math.max(1, Math.ceil(height / size));
smallCtx?.drawImage(image, (image.naturalWidth - sw) / 2, (image.naturalHeight - sh) / 2, sw, sh, 0, 0, small.width, small.height);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
};
const sizes: number[] = [];
for (let size = block; size >= 2; size /= 2) sizes.push(size);
const start = () => {
if (!loaded) return;
window.clearTimeout(timer);
paint(block);
if (!inView) return;
let step = 0;
const next = () => {
if (step === sizes.length) {
setDone(true);
return;
}
paint(sizes[step]);
step += 1;
timer = window.setTimeout(next, (duration * 1000) / sizes.length);
};
next();
};
image.onload = () => {
loaded = true;
start();
};
image.src = src;
const watch = new IntersectionObserver(
([entry]) => {
inView = entry.isIntersecting;
if (inView) start();
},
{ threshold: 0.35 },
);
watch.observe(canvas);
return () => {
image.onload = null;
window.clearTimeout(timer);
watch.disconnect();
};
}, [src, block, duration, sharp]);
useEffect(() => {
const box = boxRef.current;
if (!box || once || !done) return;
const watch = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) setDone(false);
});
watch.observe(box);
return () => watch.disconnect();
}, [once, done]);
return (
<div ref={boxRef} className={`relative overflow-hidden bg-neutral-800 ${className}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} className={`absolute inset-0 size-full object-cover ${sharp ? "" : "opacity-0"}`} />
{!sharp && <canvas ref={canvasRef} aria-hidden className="absolute inset-0 size-full" />}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/pixel-reveal.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/pixel-reveal-demo.tsx
"use client"; import { useState } from "react"; import { PixelReveal } from "@/components/ui/pixel-reveal"; export default function PixelRevealExample() { const [run, setRun] = useState(0); return ( <div className="flex h-full flex-col items-center justify-center gap-3 bg-[#0b0a14] p-4"> <PixelReveal key={run} src="/demo/dusk.jpg" alt="Hills at dusk under a pale sun" className="aspect-video w-full max-w-[460px] rounded-2xl" /> <button type="button" onClick={() => setRun(run + 1)} className="rounded-full bg-white/10 px-4 py-1.5 text-sm text-white hover:bg-white/20"> Play again </button> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | — | Picture address. |
| alt | string | — | Text for screen readers. |
| block | number | 64 | Size of the first blocks in pixels. |
| duration | number | 0.9 | Seconds from the first blocks to the sharp picture. |
| once | boolean | true | false plays it again each time it comes back on screen. |
| className | string | "aspect-video" | Set the size here. |