Pixel reveal
An image that resolves from large square pixels to sharp as it enters view.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/pixel-reveal.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/pixel-reveal
Tied to scroll position, with detail arriving from the middle of the image outward.
On enter
Plays once when it enters view at 48px blocks, then the canvas is removed.
Usage
import { PixelReveal } from "@/components/ui/pixel-reveal"
<PixelReveal trigger="scroll" src="/photo.jpg" alt="What the photo shows" className="rounded-lg" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | The image. Same origin, or served with CORS headers. | |
| alt | string | Alt text for the real image, which stays in the DOM. | |
| trigger | "scroll" | "view" | "view" | "scroll" ties the reveal to scroll position, "view" plays it once on enter. |
| cell | number | 64 | Size of the largest block in CSS pixels. |
Source
pixel-reveal/pixel-reveal.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { ShaderCanvas } from "@/components/ui/shader-canvas";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, ScrollTrigger);
const FRAGMENT = `precision mediump float;
uniform vec2 uResolution;
uniform float uScale;
uniform float uLoaded;
uniform float uProgress;
uniform float uCell;
uniform sampler2D uImage;
uniform vec2 uImageSize;
// Six levels take a block from 64px to 2px, one step short of the sharp image under the canvas.
const float LEVELS = 6.0;
const float SCATTER = 0.9;
const float FOCUS = 1.4;
// Interleaved gradient noise. It uses no sin, so it holds up at real mediump precision on phones.
float noise(vec2 id) {
return fract(52.9829189 * fract(0.06711056 * id.x + 0.00583715 * id.y));
}
// The same crop as object-cover, centred.
vec2 cover(vec2 uv, vec2 box) {
float excess = (box.x / box.y) / (uImageSize.x / uImageSize.y);
vec2 ratio = excess > 1.0 ? vec2(1.0, 1.0 / excess) : vec2(excess, 1.0);
return uv * ratio + (1.0 - ratio) * 0.5;
}
void main() {
gl_FragColor = vec4(0.0);
if (uLoaded < 0.5) return;
vec2 box = uResolution / uScale;
vec2 p = gl_FragCoord.xy / uScale - box * 0.5;
float reach = 0.5 * length(box);
// Halving is geometric, so blocks stop reading as blocks well before the last level. The power holds
// the clock back and gives the chunky levels most of the travel. It peaks just past the latest due time.
float clock = pow(uProgress, 1.5) * (LEVELS - 1.0 + SCATTER + FOCUS + 0.01);
// Walk down a quadtree. A block splits when the clock passes its own threshold, later towards the
// corners, so detail arrives from the middle outward and never as one even step.
float size = uCell;
vec2 block = vec2(0.0);
float settled = 0.0;
for (int i = 0; i < 6; i++) {
vec2 id = floor(p / size);
block = (id + 0.5) * size;
float level = float(i);
float due = level + SCATTER * noise(id + 31.0 * level) + FOCUS * length(block) / reach;
if (clock <= due) {
settled = 1.0;
break;
}
size *= 0.5;
}
// Past the last level the canvas is clear, and the real image underneath is what you see.
if (settled < 0.5) return;
// Four samples inside the block average out fine detail that one sample would turn into sparkle.
float q = size * 0.25;
vec3 color = vec3(0.0);
color += texture2D(uImage, cover((block + vec2(-q, -q) + box * 0.5) / box, box)).rgb;
color += texture2D(uImage, cover((block + vec2(q, -q) + box * 0.5) / box, box)).rgb;
color += texture2D(uImage, cover((block + vec2(-q, q) + box * 0.5) / box, box)).rgb;
color += texture2D(uImage, cover((block + vec2(q, q) + box * 0.5) / box, box)).rgb;
gl_FragColor = vec4(color * 0.25, 1.0);
}`;
type PixelRevealProps = Omit<React.ComponentProps<"div">, "children"> & {
/** The image. Same origin, or served with CORS headers, or it stays the plain image. */
src: string;
/** Alt text for the real image, which stays in the DOM. */
alt: string;
/** "scroll" ties the reveal to scroll position, "view" plays it once on enter. */
trigger?: "scroll" | "view";
/** Size of the largest block in CSS pixels. */
cell?: number;
};
/**
* An image that resolves from large square pixels to sharp as it enters view. The real img sits under
* the canvas and the canvas clears block by block, so reduced motion and no WebGL just show the image.
*/
function PixelReveal({ src, alt, trigger = "view", cell = 64, className, ...props }: PixelRevealProps) {
const root = React.useRef<HTMLDivElement>(null);
const [live, setLive] = React.useState(false);
const [uniforms] = React.useState(() => ({ uProgress: 0, uCell: cell }));
// The shader has no clock: a frame only changes when the tween moves, so it draws on demand.
const paint = React.useRef<(() => void) | null>(null);
const textures = React.useMemo(() => ({ uImage: src }), [src]);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add("(prefers-reduced-motion: no-preference)", () => {
setLive(true);
gsap.set(uniforms, { uProgress: 0 });
if (trigger === "scroll") {
gsap.to(uniforms, {
uProgress: 1,
ease: "none",
onUpdate: () => paint.current?.(),
scrollTrigger: { trigger: root.current, start: "top 95%", end: "center center", scrub: true },
});
} else {
ScrollTrigger.create({
trigger: root.current,
start: "top 75%",
once: true,
onEnter: () => {
// Clear the canvas away at the end, so a page of these does not hold on to WebGL contexts.
gsap.to(uniforms, {
uProgress: 1,
duration: 1.6,
ease: "power2.inOut",
onUpdate: () => paint.current?.(),
onComplete: () => setLive(false),
});
},
});
}
return () => setLive(false);
});
return () => media.revert();
},
{ scope: root, dependencies: [trigger, uniforms] },
);
return (
<div
ref={root}
data-slot="pixel-reveal"
className={cn("relative isolate aspect-3/2 overflow-hidden bg-muted", className)}
{...props}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} decoding="async" className="absolute inset-0 size-full object-cover" />
{live ? (
<ShaderCanvas
fragment={FRAGMENT}
uniforms={uniforms}
textures={textures}
frames="demand"
invalidateRef={paint}
/>
) : null}
</div>
);
}
export { PixelReveal };
components/ui/shader-canvas.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type UniformValue = number | readonly number[];
type ShaderCanvasProps = React.ComponentProps<"div"> & {
/** Fragment shader source. Receives uTime, uResolution (device px), uScale (device px per CSS px), uPointer and uLoaded, plus your uniforms and tokens. */
fragment: string;
/** Extra uniforms. Read every frame, so a tween can mutate this object in place. */
uniforms?: Record<string, UniformValue>;
/** Theme tokens passed as vec3 uniforms, for example { uInk: "--foreground" }. Re-read on theme change. */
tokens?: Record<string, string>;
/** Images passed as sampler2D uniforms, for example { uImage: "/photo.jpg" }. uImageSize-style sizes follow: uImage gets uImageSize. */
textures?: Record<string, string>;
/** Upper limit on device pixels per CSS pixel. */
maxDpr?: number;
/** The moment, in seconds, drawn as the still frame under reduced motion. */
stillAt?: number;
/** "demand" draws only when `invalidateRef` is called, for pieces that rest. The default never stops while visible. */
frames?: "continuous" | "demand";
/** Receives a function that draws one frame now. Pair it with frames="demand". */
invalidateRef?: React.RefObject<(() => void) | null>;
/** "track" feeds a smoothed pointer position to uPointer on devices with a mouse. */
pointer?: "none" | "track";
};
const VERTEX = `attribute vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`;
/** Resolves a token through the cascade, so a scoped .light or .dark wrapper is respected. */
function readToken(host: HTMLElement, token: string): [number, number, number] {
const probe = document.createElement("span");
probe.style.color = `var(${token})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!paint) return [0, 0, 0];
paint.fillStyle = color;
paint.fillRect(0, 0, 1, 1);
const [r, g, b] = paint.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255];
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
/**
* A raw WebGL fragment shader on a full-bleed canvas. It pauses off screen, draws one still frame
* under reduced motion, follows the theme, and leaves its children showing when WebGL is missing.
* Children are the static fallback, so give it something that reads on its own.
*/
function ShaderCanvas({
fragment,
uniforms,
tokens,
textures,
maxDpr = 1.5,
stillAt = 2,
frames = "continuous",
invalidateRef,
pointer = "none",
className,
children,
...props
}: ShaderCanvasProps) {
const host = React.useRef<HTMLDivElement>(null);
const canvas = React.useRef<HTMLCanvasElement>(null);
const latest = React.useRef({ uniforms, tokens });
const textureKey = JSON.stringify(textures ?? {});
const redraw = React.useRef<() => void>(() => {});
const [ready, setReady] = React.useState(false);
// Bumped when a lost context comes back, which rebuilds everything from scratch.
const [generation, setGeneration] = React.useState(0);
React.useEffect(() => {
latest.current = { uniforms, tokens };
redraw.current();
});
React.useEffect(() => {
const root = host.current;
const el = canvas.current;
if (!root || !el) return;
// WebGL clears its buffer once a frame is presented. A canvas that draws rarely has to keep it,
// or the picture is gone the next time the browser re-composites the page.
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
const gl = el.getContext("webgl", {
preserveDrawingBuffer: motion.matches || frames === "demand",
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: true,
powerPreference: "low-power",
});
if (!gl) return;
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
const frag = compile(gl, gl.FRAGMENT_SHADER, fragment);
const program = gl.createProgram();
if (!vertex || !frag || !program) return;
gl.attachShader(program, vertex);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
return;
}
gl.useProgram(program);
// One oversized triangle covers the clip square with no diagonal seam.
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, "aPosition");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
const locations = new Map<string, WebGLUniformLocation | null>();
const at = (name: string) => {
if (!locations.has(name)) locations.set(name, gl.getUniformLocation(program, name));
return locations.get(name) ?? null;
};
const set = (name: string, value: UniformValue) => {
const loc = at(name);
if (!loc) return;
if (typeof value === "number") gl.uniform1f(loc, value);
else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
else if (value.length === 4) gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
};
let colors: Record<string, [number, number, number]> = {};
const readTheme = () => {
colors = Object.fromEntries(
Object.entries(latest.current.tokens ?? {}).map(([name, token]) => [name, readToken(root, token)]),
);
};
readTheme();
// Textures load after the first frame. A sampler with nothing bound reads as black, so shaders that
// use one should fade in on uLoaded, which goes from 0 to 1 once every image has arrived.
const sources = Object.entries(JSON.parse(textureKey) as Record<string, string>);
const images: WebGLTexture[] = [];
let loaded = sources.length === 0 ? 1 : 0;
let disposed = false;
let pending = sources.length;
sources.forEach(([name, src], unit) => {
const texture = gl.createTexture();
if (!texture) return;
images.push(texture);
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
if (disposed) return;
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const sampler = at(name);
if (sampler) gl.uniform1i(sampler, unit);
set(`${name}Size`, [image.naturalWidth, image.naturalHeight]);
pending -= 1;
if (pending === 0) loaded = 1;
draw();
};
image.src = src;
});
const target = { x: 0.5, y: 0.5 };
const eased = { x: 0.5, y: 0.5 };
let time = stillAt;
let visible = false;
let frame = 0;
let last = 0;
const draw = () => {
gl.viewport(0, 0, el.width, el.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
set("uTime", time);
set("uResolution", [el.width, el.height]);
set("uScale", el.width / Math.max(el.clientWidth, 1));
set("uPointer", [eased.x, eased.y]);
set("uLoaded", loaded);
for (const [name, value] of Object.entries(colors)) set(name, value);
for (const [name, value] of Object.entries(latest.current.uniforms ?? {})) set(name, value);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
redraw.current = () => {
if (motion.matches) draw();
};
const loop = (now: number) => {
frame = 0;
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt;
// Frame-rate independent smoothing: 1 - e^(-k dt).
const k = 1 - Math.exp(-6 * dt);
eased.x += (target.x - eased.x) * k;
eased.y += (target.y - eased.y) * k;
draw();
schedule();
};
const schedule = () => {
if (frames === "demand" || frame || !visible || motion.matches || document.hidden || gl.isContextLost()) return;
frame = requestAnimationFrame(loop);
};
const stop = () => {
if (frame) cancelAnimationFrame(frame);
frame = 0;
last = 0;
};
const resize = () => {
const scale = Math.min(window.devicePixelRatio || 1, maxDpr);
const width = Math.max(1, Math.round(el.clientWidth * scale));
const height = Math.max(1, Math.round(el.clientHeight * scale));
if (el.width !== width || el.height !== height) {
el.width = width;
el.height = height;
}
draw();
};
const resizer = new ResizeObserver(resize);
resizer.observe(el);
const viewport = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (!visible) return stop();
draw();
schedule();
});
viewport.observe(el);
// Smooth scrolling libraries flip classes on html while scrolling, so react only to the theme itself.
const themeKey = () => {
const html = document.documentElement;
return `${html.classList.contains("dark")} ${html.classList.contains("light")} ${html.style.colorScheme}`;
};
let lastTheme = themeKey();
const theme = new MutationObserver(() => {
if (themeKey() === lastTheme) return;
lastTheme = themeKey();
readTheme();
draw();
});
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
const onVisibility = () => (document.hidden ? stop() : schedule());
const onMotion = () => {
stop();
if (motion.matches) {
time = stillAt;
draw();
} else schedule();
};
const onPointer = (e: PointerEvent) => {
const box = el.getBoundingClientRect();
target.x = (e.clientX - box.left) / box.width;
target.y = 1 - (e.clientY - box.top) / box.height;
};
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const tracks = pointer === "track" && fine;
document.addEventListener("visibilitychange", onVisibility);
motion.addEventListener("change", onMotion);
if (tracks) window.addEventListener("pointermove", onPointer, { passive: true });
// preventDefault asks the browser to restore the context. Until then the static fallback shows.
const onLost = (e: Event) => {
e.preventDefault();
stop();
setReady(false);
};
const onRestored = () => setGeneration((n) => n + 1);
el.addEventListener("webglcontextlost", onLost);
el.addEventListener("webglcontextrestored", onRestored);
if (invalidateRef) invalidateRef.current = draw;
resize();
setReady(true);
return () => {
disposed = true;
if (invalidateRef) invalidateRef.current = null;
stop();
redraw.current = () => {};
resizer.disconnect();
viewport.disconnect();
theme.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
motion.removeEventListener("change", onMotion);
if (tracks) window.removeEventListener("pointermove", onPointer);
el.removeEventListener("webglcontextlost", onLost);
el.removeEventListener("webglcontextrestored", onRestored);
images.forEach((texture) => gl.deleteTexture(texture));
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(frag);
};
}, [fragment, frames, generation, invalidateRef, maxDpr, pointer, stillAt, textureKey]);
return (
<div
ref={host}
data-slot="shader-canvas"
aria-hidden="true"
className={cn("absolute inset-0", className)}
{...props}
>
{ready ? null : children}
<canvas ref={canvas} className="absolute inset-0 size-full" />
</div>
);
}
export { ShaderCanvas };
For AI agents
Hand your agent this page, the markdown at /components/pixel-reveal/prompt.md, or the Copy AI prompt button above.
When to use
- One large image below the fold on a landing page
- A case study cover that should arrive with some ceremony
- A section about rendering, loading or image work, where the effect makes the point
When not to use
- On the first screen: the sharp image paints first, then the blocks would pop in over it
- On a grid of images, where many canvases compete and the effect wears out
- On images people need right away, such as product photos in a checkout
Agent notes
- It defaults to a 3:2 box. Pass aspect-auto and a height to fill something else, and the shader crops the same way as object-cover
- A real img with its alt is always in the DOM under the canvas. Under reduced motion no canvas mounts, and without WebGL the img simply shows
- With trigger="view" the canvas is removed when the reveal ends. With trigger="scroll" it stays mounted so the reveal can run backward
- The image must be same origin or send CORS headers. It depends on GSAP and ScrollTrigger, installed from npm under GSAP's own licence
The maths
- Blocks halve from 64px to 2px, which is log2(64 / 2) + 1 = 6 levels, and a loop of 6 stays well under the 32 step limit.
- A block splits when clock passes level + 0.9 × noise + 1.4 × distance from the centre, so the latest possible split is 5 + 0.9 + 1.4 = 7.3.
- The clock is progress^1.5 × 7.31, which puts level 3, the 8px blocks, at about 55 percent of the travel instead of 41.