Dissolve Reveal
Reveals & Transitions

Dissolve Reveal

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.

Install

npx shadcn@latest add @paragon/dissolve-reveal

dissolve-reveal.tsx

"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;
}