Shaders & Backgroundsv0.2.0
Pixel Reveal Image
An image resolves from pixelated to sharp.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/pixel-reveal-image.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/webgl/use-shader-canvas.tsimport { useEffect, useRef, useState } from "react"; /** Numbers, vectors (arrays) and, for sampler2D uniforms, an image, canvas or video element. */ export type UniformValue = number | boolean | readonly number[] | Float32Array | TexImageSource; const isTextureSource = (value: UniformValue): value is TexImageSource => typeof value === "object" && value !== null && !Array.isArray(value) && !ArrayBuffer.isView(value); /** * - `idle`: not started yet (always the value on the server and the first client render) * - `running`: animating * - `static`: a single frame is on screen (reduced motion, paused, offscreen or tab hidden) * - `fallback`: WebGL2 is unavailable, the shader failed to compile or the context was lost */ export type ShaderStatus = "idle" | "running" | "static" | "fallback"; export interface UseShaderCanvasOptions { /** * Fragment shader source. Unless it starts with `#version`, the hook prepends a header with * `precision highp float`, the built-in uniforms `u_time` (seconds), `u_resolution` (pixels), * `u_mouse` (0–1, y up) and `u_pointer` (0–1, eases to 1 while the pointer is inside), * `in vec2 v_uv` and `out vec4 fragColor`. Output premultiplied alpha. */ fragmentShader: string; /** * Custom uniforms by name. Read every frame, so updating them never restarts the shader. Pass a * function to compute them per frame from refs or other values that change without re-rendering. */ uniforms?: Record<string, UniformValue> | (() => Record<string, UniformValue>); /** Clock multiplier. @default 1 */ speed?: number; /** Freezes the clock and keeps the current frame on screen. @default false */ paused?: boolean; /** Fraction of the display resolution to render at, clamped to 0.5–1. @default 1 */ renderScale?: number; /** Highest device pixel ratio used. @default 1.5 */ maxDpr?: number; /** Track the pointer over the container into `u_mouse` and `u_pointer`. @default false */ pointer?: boolean; /** Clock value, in seconds, of the single frame drawn under reduced motion. @default 6 */ staticTime?: number; /** * Render even on software WebGL (SwiftShader, llvmpipe), where compiling and every frame run on * the CPU and block the main thread. By default those renderers get the fallback. @default false */ allowSoftware?: boolean; } const VERTEX_SHADER = /* glsl */ `#version 300 es out vec2 v_uv; void main() { vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); v_uv = p; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }`; const FRAGMENT_HEADER = /* glsl */ `#version 300 es precision highp float; uniform float u_time; uniform vec2 u_resolution; uniform vec2 u_mouse; uniform float u_pointer; in vec2 v_uv; out vec4 fragColor; `; interface UniformSlot { location: WebGLUniformLocation; type: number; } const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); function compile(gl: WebGL2RenderingContext, 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; if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: shader failed to compile\n${gl.getShaderInfoLog(shader)}`); gl.deleteShader(shader); return null; } function link(gl: WebGL2RenderingContext, fragmentSource: string) { const source = fragmentSource.trimStart().startsWith("#version") ? fragmentSource.trimStart() : FRAGMENT_HEADER + fragmentSource; const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, source); const program = vertex && fragment ? gl.createProgram() : null; if (program && vertex && fragment) { gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); gl.detachShader(program, vertex); gl.detachShader(program, fragment); } if (vertex) gl.deleteShader(vertex); if (fragment) gl.deleteShader(fragment); if (!program) return null; if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program; if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: program failed to link\n${gl.getProgramInfoLog(program)}`); gl.deleteProgram(program); return null; } function collectUniforms(gl: WebGL2RenderingContext, program: WebGLProgram) { const slots = new Map<string, UniformSlot>(); const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number; for (let i = 0; i < count; i++) { const info = gl.getActiveUniform(program, i); const location = info && gl.getUniformLocation(program, info.name); if (info && location) slots.set(info.name.replace(/\[0\]$/, ""), { location, type: info.type }); } return slots; } function setUniform(gl: WebGL2RenderingContext, { location, type }: UniformSlot, value: Exclude<UniformValue, TexImageSource>) { const isInt = type === gl.INT || type === gl.BOOL; if (typeof value === "boolean" || typeof value === "number") { const n = Number(value); if (isInt) gl.uniform1i(location, n); else gl.uniform1f(location, n); return; } const list = value as number[] | Float32Array; if (isInt) gl.uniform1iv(location, Int32Array.from(list)); else if (type === gl.FLOAT_VEC2) gl.uniform2fv(location, list); else if (type === gl.FLOAT_VEC3) gl.uniform3fv(location, list); else if (type === gl.FLOAT_VEC4) gl.uniform4fv(location, list); else gl.uniform1fv(location, list); } /** * Renders a fragment shader with WebGL2 on a canvas the hook creates inside the element you * attach `ref` to (give that element a size and `position: relative`, `absolute` or `fixed`). * * One fullscreen triangle, no three.js. The canvas follows the element's size, caps the pixel * ratio, pauses offscreen and in hidden tabs, draws a single static frame under reduced motion * (software WebGL such as SwiftShader gets the fallback unless allowSoftware is set), * recovers from context loss and releases the GPU context on unmount. Uniform values can be * numbers, arrays (vec2–vec4 or float arrays) or, for sampler2D uniforms, an image, canvas or * video element (uploaded once it has pixels; pass a new element or re-render to refresh). When `status` is * `fallback` (or before the first frame), whatever you render behind the canvas shows through. */ export function useShaderCanvas<T extends HTMLElement = HTMLDivElement>(options: UseShaderCanvasOptions) { const ref = useRef<T>(null); const [status, setStatus] = useState<ShaderStatus>("idle"); const latest = useRef(options); const wake = useRef<() => void>(() => {}); const time = useRef(0); // Keep the render loop reading fresh props, and let it react to paused/uniform changes. useEffect(() => { latest.current = options; wake.current(); }); const { fragmentShader } = options; useEffect(() => { const host = ref.current; if (!host) return; const canvas = document.createElement("canvas"); canvas.setAttribute("aria-hidden", "true"); Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block", pointerEvents: "none", opacity: "0", transition: "opacity 500ms ease-out", }); host.appendChild(canvas); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const mouse = [0.5, 0.5]; const mouseTarget = [0.5, 0.5]; let gl: WebGL2RenderingContext | null = null; let program: WebGLProgram | null = null; let uniforms = new Map<string, UniformSlot>(); const textures = new Map<string, { texture: WebGLTexture; unit: number; source: TexImageSource | null; ready: boolean }>(); let reported: ShaderStatus = "idle"; let raf = 0; let lastFrame = 0; let clock = 0; let pointer = 0; let pointerTarget = 0; let inView = false; let pageVisible = document.visibilityState === "visible"; let needsResize = true; let drawn = false; let lost = false; let software = false; const report = (next: ShaderStatus) => { if (next === reported) return; reported = next; setStatus(next); }; const build = () => { gl ??= canvas.getContext("webgl2", { alpha: true, antialias: false, depth: false, stencil: false, premultipliedAlpha: true, powerPreference: "low-power", }); if (gl) { const info = gl.getExtension("WEBGL_debug_renderer_info"); const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER)); software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(renderer); } // On software WebGL even compiling blocks the main thread, so use the CSS fallback instead. program = gl && !(software && !latest.current.allowSoftware) ? link(gl, fragmentShader) : null; uniforms = gl && program ? collectUniforms(gl, program) : new Map(); textures.clear(); needsResize = true; return program !== null; }; const resize = () => { if (!gl) return; const { renderScale = 1, maxDpr = 1.5 } = latest.current; const scale = Math.min(window.devicePixelRatio || 1, maxDpr) * clamp(renderScale, 0.5, 1); const width = Math.max(1, Math.round(host.clientWidth * scale)); const height = Math.max(1, Math.round(host.clientHeight * scale)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } gl.viewport(0, 0, width, height); needsResize = false; }; const draw = (time: number) => { if (!gl || !program || lost) return; if (needsResize) resize(); gl.useProgram(program); const set = (name: string, value: UniformValue) => { const slot = uniforms.get(name); if (!slot || !gl) return; if (isTextureSource(value)) bindTexture(name, slot, value); else setUniform(gl, slot, value); }; set("u_time", time); set("u_resolution", [canvas.width, canvas.height]); set("u_mouse", mouse); set("u_pointer", pointer); const source = latest.current.uniforms; const custom = typeof source === "function" ? source() : source; if (custom) for (const name in custom) set(name, custom[name]); gl.drawArrays(gl.TRIANGLES, 0, 3); if (!drawn) { drawn = true; canvas.style.opacity = "1"; } }; // Textures upload once per source (and every frame for video), as soon as the source has pixels. const bindTexture = (name: string, slot: UniformSlot, source: TexImageSource) => { if (!gl || slot.type !== gl.SAMPLER_2D) return; let entry = textures.get(name); if (!entry) { const texture = gl.createTexture(); if (!texture) return; entry = { texture, unit: textures.size, source: null, ready: false }; textures.set(name, entry); } gl.activeTexture(gl.TEXTURE0 + entry.unit); gl.bindTexture(gl.TEXTURE_2D, entry.texture); const hasPixels = typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement ? source.complete && source.naturalWidth > 0 : typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement ? source.readyState >= 2 : true; const isVideo = typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement; if (hasPixels && (entry.source !== source || !entry.ready || isVideo)) { gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); 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); entry.source = source; entry.ready = true; } gl.uniform1i(slot.location, entry.unit); }; const still = () => reducedMotion.matches; const shouldRun = () => !lost && program !== null && inView && pageVisible && !latest.current.paused && !still(); const frame = (now: number) => { raf = 0; if (!shouldRun()) return settle(); const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.1) : 0; lastFrame = now; clock += dt * (latest.current.speed ?? 1); const ease = 1 - Math.exp(-dt * 6); mouse[0] += (mouseTarget[0] - mouse[0]) * ease; mouse[1] += (mouseTarget[1] - mouse[1]) * ease; pointer += (pointerTarget - pointer) * ease; draw(clock); time.current = clock; report("running"); raf = requestAnimationFrame(frame); }; // Not animating: keep one up-to-date frame on screen. const settle = () => { lastFrame = 0; if (lost || !program) return; time.current = still() ? (latest.current.staticTime ?? 6) : clock; draw(time.current); report("static"); }; const update = () => { if (shouldRun()) { if (!raf) raf = requestAnimationFrame(frame); return; } if (raf) cancelAnimationFrame(raf); raf = 0; settle(); }; const intersection = new IntersectionObserver( ([entry]) => { inView = entry.isIntersecting; update(); }, { rootMargin: "120px" }, ); const resizeObserver = new ResizeObserver(() => { needsResize = true; if (!raf) settle(); }); const onVisibility = () => { pageVisible = document.visibilityState === "visible"; update(); }; const onPointerMove = (event: PointerEvent) => { const rect = host.getBoundingClientRect(); const x = (event.clientX - rect.left) / rect.width; const y = 1 - (event.clientY - rect.top) / rect.height; const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1; if (inside) { mouseTarget[0] = x; mouseTarget[1] = y; } pointerTarget = inside ? 1 : 0; }; const onPointerLeave = () => { pointerTarget = 0; }; const onContextLost = (event: Event) => { event.preventDefault(); lost = true; if (raf) cancelAnimationFrame(raf); raf = 0; report("fallback"); }; const onContextRestored = () => { lost = false; if (build()) update(); else report("fallback"); }; const trackPointer = latest.current.pointer === true; // Start on the next frame: keeps setup out of the effect body and off the hydration task. const start = requestAnimationFrame(() => { if (!build()) return report("fallback"); canvas.addEventListener("webglcontextlost", onContextLost); canvas.addEventListener("webglcontextrestored", onContextRestored); document.addEventListener("visibilitychange", onVisibility); reducedMotion.addEventListener("change", update); if (trackPointer) { window.addEventListener("pointermove", onPointerMove, { passive: true }); document.documentElement.addEventListener("pointerleave", onPointerLeave); } intersection.observe(host); resizeObserver.observe(host); wake.current = update; }); return () => { cancelAnimationFrame(start); if (raf) cancelAnimationFrame(raf); wake.current = () => {}; intersection.disconnect(); resizeObserver.disconnect(); canvas.removeEventListener("webglcontextlost", onContextLost); canvas.removeEventListener("webglcontextrestored", onContextRestored); document.removeEventListener("visibilitychange", onVisibility); reducedMotion.removeEventListener("change", update); window.removeEventListener("pointermove", onPointerMove); document.documentElement.removeEventListener("pointerleave", onPointerLeave); if (gl) { textures.forEach((entry) => gl?.deleteTexture(entry.texture)); if (program) gl.deleteProgram(program); gl.getExtension("WEBGL_lose_context")?.loseContext(); } canvas.remove(); }; }, [fragmentShader]); /** `time` holds the shader clock (seconds, as passed to u_time), e.g. to timestamp clicks. */ return { ref, status, time }; }Copy the source
components/ui/pixel-reveal-image/pixel-reveal-image.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps } from "react"; import { animate, useMotionValue } from "motion/react"; import { cn } from "@/lib/utils"; import { useShaderCanvas } from "@/lib/webgl/use-shader-canvas"; export interface PixelRevealImageProps extends Omit<ComponentProps<"div">, "children"> { /** Image URL. Use a same-origin or CORS-enabled image so WebGL can read it. */ src: string; /** Alternative text, as for any <img>. */ alt: string; /** What starts the reveal. "inView" plays once when scrolled into view; "hover" sharpens on hover and pixelates again on leave. @default "inView" */ trigger?: "inView" | "hover"; /** Pixel size at the start, in canvas pixels. @default 36 */ cellSize?: number; /** Reveal duration in seconds. @default 1.2 */ duration?: number; } const FRAGMENT = /* glsl */ ` uniform sampler2D u_tex; uniform float u_cell; uniform float u_ready; uniform float u_img_aspect; void main() { if (u_ready < 0.5) { fragColor = vec4(0.0); return; } vec2 coord = gl_FragCoord.xy; if (u_cell > 1.0) coord = (floor(coord / u_cell) + 0.5) * u_cell; vec2 uv = coord / u_resolution; // object-fit: cover float canvasAspect = u_resolution.x / u_resolution.y; vec2 scale = canvasAspect > u_img_aspect ? vec2(1.0, u_img_aspect / canvasAspect) : vec2(canvasAspect / u_img_aspect, 1.0); fragColor = texture(u_tex, (uv - 0.5) * scale + 0.5); }`; export function PixelRevealImage({ src, alt, trigger = "inView", cellSize = 36, duration = 1.2, className, ...props }: PixelRevealImageProps) { const root = useRef<HTMLDivElement>(null); const [image, setImage] = useState<HTMLImageElement | null>(null); const [aspect, setAspect] = useState(1); const [ready, setReady] = useState(false); const [animating, setAnimating] = useState(false); const progress = useMotionValue(0); // A stable uniforms object that the animation writes into; the shader reads it every frame. const uniforms = useRef({ u_tex: null as HTMLImageElement | null, u_cell: cellSize, u_ready: 0, u_img_aspect: 1 }); const inView = useRef(false); const loaded = useRef(false); const { ref } = useShaderCanvas<HTMLDivElement>({ fragmentShader: FRAGMENT, paused: !animating, uniforms: () => uniforms.current as unknown as Record<string, number | HTMLImageElement>, }); useEffect(() => { uniforms.current.u_tex = image; uniforms.current.u_ready = ready && image ? 1 : 0; uniforms.current.u_img_aspect = aspect; }); useEffect(() => { return progress.on("change", (p) => { // Ease the cell size down to one pixel; sharpen fully at the end. uniforms.current.u_cell = p >= 0.999 ? 1 : Math.max(1, Math.round(cellSize * (1 - p) * (1 - p))); }); }, [progress, cellSize]); const play = (to: 0 | 1) => { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { progress.set(1); setAnimating(true); requestAnimationFrame(() => setAnimating(false)); return; } setAnimating(true); // Linear time with a squared cell curve: big blocks resolve quickly, fine detail sharpens last. animate(progress, to, { duration: to === 1 ? duration : duration * 0.6, ease: "linear", onComplete: () => setAnimating(false) }); }; // "inView": play once the image is both loaded and at least 40% visible, whichever happens last. useEffect(() => { const el = root.current; if (trigger !== "inView" || !el) return; const observer = new IntersectionObserver( ([entry]) => { if (!entry.isIntersecting) return; inView.current = true; observer.disconnect(); if (loaded.current) play(1); }, { threshold: 0.4 }, ); observer.observe(el); return () => observer.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps -- play only touches motion values and state setters }, [trigger]); // Images can finish loading before hydration (cached or data URLs), so check on mount too. const handleLoad = (img: HTMLImageElement) => { if (loaded.current) return; loaded.current = true; setAspect(img.naturalWidth / Math.max(1, img.naturalHeight)); setReady(true); if (trigger === "inView" && inView.current) play(1); }; const attach = (node: HTMLImageElement | null) => { setImage(node); if (node?.complete && node.naturalWidth > 0) queueMicrotask(() => handleLoad(node)); }; return ( <div ref={root} onPointerEnter={() => trigger === "hover" && ready && play(1)} onPointerLeave={() => trigger === "hover" && ready && play(0)} className={cn("relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-muted", className)} {...props} > {/* The real image: accessible, and the fallback when WebGL isn't available. */} {/* eslint-disable-next-line @next/next/no-img-element -- the element doubles as the WebGL texture source */} <img ref={attach} src={src} alt={alt} crossOrigin="anonymous" onLoad={(event) => handleLoad(event.currentTarget)} className="absolute inset-0 -z-20 size-full object-cover" /> {/* No fade-in here: the pixelated first frame must cover the sharp <img> right away. */} <div ref={ref} aria-hidden="true" className="absolute inset-0 -z-10 [&>canvas]:!opacity-100 [&>canvas]:!transition-none" /> </div> ); }
Usage
import { PixelRevealImage } from "@/components/ui/pixel-reveal-image/pixel-reveal-image";
<PixelRevealImage src="/images/cover.jpg" alt="Product screenshot" trigger="inView" />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
src* | string | – | Image URL. Use a same-origin or CORS-enabled image so WebGL can read it. |
alt* | string | – | Alternative text, as for any <img>. |
trigger | "inView" | "hover" | "inView" | What starts the reveal. "inView" plays once when scrolled into view; "hover" sharpens on hover and pixelates again on leave. |
cellSize | number | 36 | Pixel size at the start, in canvas pixels. |
duration | number | 1.2 | Reveal duration in seconds. |
Also accepts every native <div> attribute, including ref and className.
- The image is uploaded once as a WebGL texture and sampled at the center of each cell; the cell size eases from cellSize down to one pixel. The underlying <img> stays in place for accessibility and as the fallback.
- Built on useShaderCanvas: it pauses offscreen and in hidden tabs, caps the pixel ratio at 1.5, recovers from context loss and releases the GPU on unmount.
Accessibility
- The real <img> with its alt text stays in the DOM, so assistive tech reads the image normally; the canvas on top is aria-hidden.
- With reduced motion the shader draws a single still frame (the image shows sharp right away). Without WebGL2, or on software rendering with no GPU, a CSS fallback in the same colors is shown.