A Canvas 2D grid of colored square pixels dissolves along a direction with a noisy leading edge, uncovering the real content beneath.
npx shadcn@latest add @paragon/pixel-reveal"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------- deterministic */
/** Small deterministic PRNG so the pixel dissolve order is stable across renders. */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashString(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) {
h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
}
return h;
}
export type PixelRevealDirection =
| "left"
| "right"
| "up"
| "down"
| "center";
export interface PixelRevealProps extends React.ComponentProps<"div"> {
/** Edge length of each square pixel, in CSS px. Smaller = finer dissolve. */
gridSize?: number;
/** Fill color of the covering pixels. Defaults to the muted token. */
color?: string;
/** 0–1: how ragged the dissolving leading edge is. 0 = a clean line. */
edgeNoise?: number;
/** Direction the dissolve sweeps toward. */
direction?: PixelRevealDirection;
/** Total reveal duration, in ms. */
duration?: number;
/** How the reveal is triggered. */
trigger?: "view" | "hover" | "click";
/** Seed for the deterministic noise field. */
seed?: number;
/** The content revealed beneath the pixels. */
children: React.ReactNode;
}
/**
* PixelReveal — a Canvas 2D grid of colored square "pixels" dissolves along a
* direction with a noisy leading edge, uncovering the real DOM content beneath.
*
* The covering pixels live on an absolutely-positioned canvas over the (always
* real, accessible) `children`. Each cell gets a deterministic threshold from a
* seeded PRNG blended with a directional gradient; a single progress scalar
* runs toward its target and cells below the eased progress clear — the blend
* of gradient and per-cell noise makes the leading edge ragged, and cells
* overpaint by 1px so no sub-pixel seams show. The canvas carries the cover
* color as a CSS background until its first paint, so content never flashes
* before the engine draws (sized + painted in a layout effect, pre-frame).
*
* Progress is resumable: it pauses offscreen (IntersectionObserver) and picks
* up where it left off; prop changes rebuild the field without resetting it;
* with `trigger="hover"` leaving re-covers the content by running the same
* progress backwards (fully interruptible). On touch devices `hover` falls
* back to `view`, `click` is keyboard-operable, and the pixel color re-resolves
* when the theme class flips. Under `prefers-reduced-motion` the content shows
* fully revealed immediately.
*/
export function PixelReveal({
gridSize = 22,
color = "var(--color-muted)",
edgeNoise = 0.5,
direction = "left",
duration = 900,
trigger = "view",
seed,
className,
children,
onClick,
onKeyDown,
onPointerEnter,
onPointerLeave,
...props
}: PixelRevealProps) {
const reactId = React.useId();
const resolvedSeed = seed ?? hashString(reactId);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [fine, setFine] = React.useState(false);
const [clicked, setClicked] = React.useState(false);
// Progress + target live in refs so they survive engine rebuilds (prop
// changes resume mid-flight instead of restarting).
const progressRef = React.useRef(0);
const targetRef = React.useRef(0);
const kickRef = React.useRef<() => void>(() => {});
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
const effectiveTrigger = trigger === "hover" && !fine ? "view" : trigger;
React.useLayoutEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
if (!wrapper || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
let width = 0;
let height = 0;
let cols = 0;
let rows = 0;
let thresholds: Float32Array = new Float32Array(0);
let fillColor = color;
let rafId: number | null = null;
let last = 0;
let visible = true;
let bgCleared = false;
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
// Until the first cells paint, the canvas covers via CSS background so the
// content can never flash. Cleared the moment the animation takes over.
const clearBg = () => {
if (!bgCleared) {
canvas.style.background = "transparent";
bgCleared = true;
}
};
const resolveColor = () => {
// Resolve tokens like var(--color-muted) to a concrete canvas color.
canvas.style.color = color;
fillColor = getComputedStyle(canvas).color || color;
};
const buildField = () => {
cols = Math.max(1, Math.ceil(width / gridSize));
rows = Math.max(1, Math.ceil(height / gridSize));
const rand = mulberry32(resolvedSeed);
thresholds = new Float32Array(cols * rows);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
// Directional base progress-at-which-this-cell-clears, 0..1.
const fx = cols <= 1 ? 0 : x / (cols - 1);
const fy = rows <= 1 ? 0 : y / (rows - 1);
let base: number;
switch (direction) {
case "right":
base = 1 - fx;
break;
case "up":
base = fy;
break;
case "down":
base = 1 - fy;
break;
case "center": {
const dx = fx - 0.5;
const dy = fy - 0.5;
base = Math.min(1, Math.hypot(dx, dy) / 0.7071);
break;
}
case "left":
default:
base = fx;
break;
}
const n = rand();
// Blend the clean gradient with per-cell noise → ragged edge.
thresholds[y * cols + x] = base * (1 - edgeNoise) + n * edgeNoise;
}
}
};
const paint = () => {
const p = easeOutCubic(progressRef.current);
ctx.clearRect(0, 0, width, height);
if (p >= 1) return;
ctx.fillStyle = fillColor;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (thresholds[y * cols + x] > p) {
// +1px overpaint so adjacent cells never show hairline seams.
ctx.fillRect(x * gridSize, y * gridSize, gridSize + 1, gridSize + 1);
}
}
}
};
const tick = (now: number) => {
rafId = null;
const dt = last ? Math.min(64, now - last) : 1000 / 60;
last = now;
const step = dt / Math.max(1, duration);
const target = targetRef.current;
const p = progressRef.current;
const next = p + Math.sign(target - p) * step;
// Clamp once the target is crossed so progress lands exactly.
progressRef.current =
(target - p) * (target - next) <= 0 ? target : next;
paint();
if (progressRef.current !== targetRef.current && visible) {
rafId = requestAnimationFrame(tick);
} else {
last = 0;
}
};
const kick = () => {
if (reduceMotion.matches) {
progressRef.current = 1;
targetRef.current = 1;
clearBg();
paint();
return;
}
if (progressRef.current !== targetRef.current) clearBg();
if (rafId === null && visible && progressRef.current !== targetRef.current) {
last = 0;
rafId = requestAnimationFrame(tick);
}
};
kickRef.current = kick;
const resize = () => {
const rect = wrapper.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
resolveColor();
buildField();
paint();
};
if (reduceMotion.matches) {
progressRef.current = 1;
targetRef.current = 1;
}
if (progressRef.current > 0) clearBg();
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(wrapper);
// One observer both pauses offscreen (any exit) and arms the `view`
// trigger (crossing 35% visible).
const intersectionObserver = new IntersectionObserver(
([entry]) => {
if (!entry) return;
visible = entry.isIntersecting;
if (!visible) {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
last = 0;
}
return;
}
if (effectiveTrigger === "view" && entry.intersectionRatio >= 0.35) {
targetRef.current = 1;
}
kick();
},
{ threshold: [0, 0.35] },
);
intersectionObserver.observe(wrapper);
// Re-resolve token colors when the theme class flips (.dark on <html>).
const themeObserver = new MutationObserver(() => {
resolveColor();
paint();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
reduceMotion.addEventListener("change", kick);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
themeObserver.disconnect();
reduceMotion.removeEventListener("change", kick);
};
}, [
gridSize,
color,
edgeNoise,
direction,
duration,
resolvedSeed,
effectiveTrigger,
]);
const arm = React.useCallback(() => {
targetRef.current = 1;
kickRef.current();
}, []);
const disarm = React.useCallback(() => {
targetRef.current = 0;
kickRef.current();
}, []);
const awaitingClick = effectiveTrigger === "click" && !clicked;
return (
<div
ref={wrapperRef}
data-slot="pixel-reveal"
className={cn("relative overflow-hidden", className)}
role={awaitingClick ? "button" : undefined}
tabIndex={awaitingClick ? 0 : undefined}
aria-label={awaitingClick ? "Reveal content" : undefined}
onPointerEnter={(e) => {
onPointerEnter?.(e);
if (effectiveTrigger === "hover") arm();
}}
onPointerLeave={(e) => {
onPointerLeave?.(e);
if (effectiveTrigger === "hover") disarm();
}}
onClick={(e) => {
onClick?.(e);
if (effectiveTrigger === "click") {
setClicked(true);
arm();
}
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (awaitingClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setClicked(true);
arm();
}
}}
{...props}
>
{/* Real, accessible content lives underneath the covering canvas. */}
{children}
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute inset-0 size-full"
style={{ background: color }}
/>
</div>
);
}