A Canvas 2D overlay dissolves via a threshold sweep over a seeded, smooth value-noise field, uncovering the content beneath with an organic, feathered front — a film-style dissolve. Runs on scroll-into-view (or hover/click), pauses offscreen, and respects reduced motion.
npx shadcn@latest add @paragon/dissolve-reveal"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------- deterministic */
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;
}
/** Smooth value-noise field on a coarse lattice, bilinearly interpolated. */
function buildNoise(
cols: number,
rows: number,
cell: number,
seed: number,
): Float32Array {
const gx = Math.max(2, Math.ceil(cols / cell) + 2);
const gy = Math.max(2, Math.ceil(rows / cell) + 2);
const rand = mulberry32(seed);
const lattice = new Float32Array(gx * gy);
for (let i = 0; i < lattice.length; i++) lattice[i] = rand();
const out = new Float32Array(cols * rows);
const smooth = (t: number) => t * t * (3 - 2 * t); // smoothstep
for (let y = 0; y < rows; y++) {
const fy = y / cell;
const y0 = Math.floor(fy);
const ty = smooth(fy - y0);
for (let x = 0; x < cols; x++) {
const fx = x / cell;
const x0 = Math.floor(fx);
const tx = smooth(fx - x0);
const a = lattice[y0 * gx + x0];
const b = lattice[y0 * gx + x0 + 1];
const c = lattice[(y0 + 1) * gx + x0];
const d = lattice[(y0 + 1) * gx + x0 + 1];
const top = a + (b - a) * tx;
const bot = c + (d - c) * tx;
out[y * cols + x] = top + (bot - top) * ty;
}
}
return out;
}
export interface DissolveRevealProps extends React.ComponentProps<"div"> {
/** Overlay color that dissolves away to reveal the content. Defaults to card. */
color?: string;
/** 0–1: width of the soft alpha ramp at the dissolve edge. 0 = hard threshold. */
softness?: number;
/** Reveal duration, in ms. */
duration?: number;
/** Lattice scale of the noise field, in px. Larger = broader blotches. */
scale?: number;
/** How the reveal is triggered. */
trigger?: "view" | "hover" | "click";
/** Seed for the deterministic noise field. */
seed?: number;
children: React.ReactNode;
}
/**
* DissolveReveal — a Canvas 2D overlay dissolves via a threshold sweep over a
* seeded, smooth value-noise field, uncovering the content beneath. As a single
* eased progress climbs 0→1, each pixel's overlay alpha is `smoothstep` of
* (noise − progress) over `softness`, so the boundary is an organic, feathered
* front rather than a straight edge — a film-style dissolve.
*
* The noise is generated once from a seeded PRNG (never Math.random at render),
* rasterized into an offscreen buffer, and composited each frame. Runs once on
* scroll-into-view (or hover/click), pauses its rAF while offscreen, cleans up
* rAF + observers on unmount, and under `prefers-reduced-motion` jumps straight
* to the revealed state. Content is real DOM under an `aria-hidden` canvas.
*/
export function DissolveReveal({
color = "var(--color-card)",
softness = 0.18,
duration = 1100,
scale = 26,
trigger = "view",
seed,
className,
children,
...props
}: DissolveRevealProps) {
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)");
// Render at a modest internal resolution for cheap per-pixel compositing.
const MAX = 220;
let cols = 0;
let rows = 0;
let noise: Float32Array = new Float32Array(0);
let buffer: ImageData | null = null;
let rgb: [number, number, number] = [128, 128, 128];
const parseColor = (value: string): [number, number, number] => {
canvas.style.color = value;
const c = getComputedStyle(canvas).color; // rgb(a) form
const m = c.match(/[\d.]+/g);
if (m && m.length >= 3) return [+m[0], +m[1], +m[2]];
return [128, 128, 128];
};
const build = () => {
const rect = wrapper.getBoundingClientRect();
const aspect = rect.height / Math.max(1, rect.width);
cols = Math.max(2, Math.min(MAX, Math.round(rect.width / 2)));
rows = Math.max(2, Math.round(cols * aspect));
canvas.width = cols;
canvas.height = rows;
noise = buildNoise(cols, rows, Math.max(4, scale / 2), resolvedSeed);
buffer = ctx.createImageData(cols, rows);
rgb = parseColor(color);
};
const paint = (progress: number) => {
if (!buffer) return;
const data = buffer.data;
const [r, g, b] = rgb;
const soft = Math.max(0.001, softness);
// A pixel is fully covered where noise > progress + soft, fully clear
// where noise < progress, and feathered in between (smoothstep ramp).
for (let i = 0; i < noise.length; i++) {
const edge = (noise[i] - progress) / soft;
const a = edge <= 0 ? 0 : edge >= 1 ? 1 : edge * edge * (3 - 2 * edge);
const o = i * 4;
data[o] = r;
data[o + 1] = g;
data[o + 2] = b;
data[o + 3] = Math.round(a * 255);
}
ctx.putImageData(buffer, 0, 0);
};
let rafId: number | null = null;
let start = 0;
let done = false;
let paused = false;
const easeInOut = (t: number) =>
t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
const step = (now: number) => {
if (!start) start = now;
const t = Math.min(1, (now - start) / Math.max(1, duration));
// Push progress slightly past 1 so the last speckles finish clearing.
paint(easeInOut(t) * (1 + softness));
if (t < 1) {
rafId = requestAnimationFrame(step);
} else {
rafId = null;
done = true;
}
};
const run = () => {
if (done) return;
if (reduceMotion.matches) {
paint(1 + softness);
done = true;
return;
}
start = 0;
if (rafId === null) rafId = requestAnimationFrame(step);
};
build();
paint(0);
const resizeObserver = new ResizeObserver(() => {
const wasDone = done;
build();
paint(wasDone ? 1 + softness : 0);
});
resizeObserver.observe(wrapper);
const intersectionObserver = new IntersectionObserver(([entry]) => {
const visible = entry?.isIntersecting ?? false;
if (!visible) {
paused = true;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
return;
}
if (paused && armed && !done && start) {
// Resume a run that was paused while offscreen.
paused = false;
rafId = requestAnimationFrame(step);
return;
}
paused = false;
if (trigger === "view" && armed && !done) run();
});
intersectionObserver.observe(wrapper);
reduceMotion.addEventListener("change", run);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
reduceMotion.removeEventListener("change", run);
};
}, [color, softness, duration, scale, resolvedSeed, trigger, armed]);
const arm = React.useCallback(() => setArmed(true), []);
return (
<div
ref={wrapperRef}
data-slot="dissolve-reveal"
className={cn("relative overflow-hidden", className)}
onMouseEnter={trigger === "hover" ? arm : undefined}
onClick={trigger === "click" ? arm : undefined}
{...props}
>
{children}
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute inset-0 size-full"
// Let the low-res buffer scale up smoothly to the element box.
style={{ imageRendering: "auto" }}
/>
<DissolveArmer trigger={trigger} onArm={arm} targetRef={wrapperRef} />
</div>
);
}
/** Arms the reveal when the surface first scrolls into view (trigger="view"). */
function DissolveArmer({
trigger,
onArm,
targetRef,
}: {
trigger: DissolveRevealProps["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;
}