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; as a single eased progress
* value climbs 0→1, cells below the threshold clear — the blend of gradient and
* per-cell noise is what makes the leading edge ragged rather than a hard line.
*
* Runs once when scrolled into view (or on hover/click). Pauses offscreen,
* cleans up its rAF + observers on unmount, and under `prefers-reduced-motion`
* skips straight to the fully-revealed state (canvas cleared).
*/
export function PixelReveal({
gridSize = 22,
color = "var(--color-muted)",
edgeNoise = 0.5,
direction = "left",
duration = 900,
trigger = "view",
seed,
className,
children,
...props
}: PixelRevealProps) {
const reactId = React.useId();
const resolvedSeed = seed ?? hashString(reactId);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [armed, setArmed] = React.useState(false);
React.useEffect(() => {
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;
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 = (progress: number) => {
ctx.clearRect(0, 0, width, height);
if (progress >= 1) return;
ctx.fillStyle = fillColor;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (thresholds[y * cols + x] > progress) {
ctx.fillRect(x * gridSize, y * gridSize, gridSize + 1, gridSize + 1);
}
}
}
};
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);
// Resolve the CSS color token to a concrete value once we're mounted.
// `canvas.style.color = color` is set below; getComputedStyle resolves it.
const probe = getComputedStyle(canvas).color;
fillColor = probe || color;
buildField();
};
// Use a hidden color probe: set canvas text color to `color` via style.
canvas.style.color = color;
let rafId: number | null = null;
let start = 0;
let done = false;
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const step = (now: number) => {
if (!start) start = now;
const t = Math.min(1, (now - start) / Math.max(1, duration));
paint(easeOutCubic(t));
if (t < 1) {
rafId = requestAnimationFrame(step);
} else {
rafId = null;
done = true;
}
};
const run = () => {
if (done) return;
if (reduceMotion.matches) {
paint(1);
done = true;
return;
}
start = 0;
if (rafId === null) rafId = requestAnimationFrame(step);
};
resize();
paint(0);
const resizeObserver = new ResizeObserver(() => {
const wasDone = done;
resize();
paint(wasDone ? 1 : 0);
});
resizeObserver.observe(wrapper);
// Pause offscreen: cancel rAF when scrolled away, resume progress on return.
const intersectionObserver = new IntersectionObserver(([entry]) => {
const visible = entry?.isIntersecting ?? false;
if (!visible) {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
return;
}
if (trigger === "view" && armed && !done) run();
else if (armed && rafId === null && !done && start) {
// resume a mid-flight animation that was paused offscreen
rafId = requestAnimationFrame(step);
}
});
intersectionObserver.observe(wrapper);
reduceMotion.addEventListener("change", run);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
reduceMotion.removeEventListener("change", run);
};
}, [
gridSize,
color,
edgeNoise,
direction,
duration,
resolvedSeed,
trigger,
armed,
]);
const arm = React.useCallback(() => setArmed(true), []);
return (
<div
ref={wrapperRef}
data-slot="pixel-reveal"
className={cn("relative overflow-hidden", className)}
onMouseEnter={trigger === "hover" ? arm : undefined}
onClick={trigger === "click" ? arm : undefined}
{...props}
>
{/* Real, accessible content lives underneath the covering canvas. */}
{children}
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute inset-0 size-full"
/>
{/* Trigger arming for `view` uses the same IntersectionObserver above. */}
<ViewArmer trigger={trigger} onArm={arm} targetRef={wrapperRef} />
</div>
);
}
/** Arms the reveal when the surface first scrolls into view (trigger="view"). */
function ViewArmer({
trigger,
onArm,
targetRef,
}: {
trigger: PixelRevealProps["trigger"];
onArm: () => void;
targetRef: React.RefObject<HTMLDivElement | null>;
}) {
React.useEffect(() => {
if (trigger !== "view") return;
const el = targetRef.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
onArm();
io.disconnect();
}
},
{ threshold: 0.35 },
);
io.observe(el);
return () => io.disconnect();
}, [trigger, onArm, targetRef]);
return null;
}