Reveals & Transitions

Pixel Reveal

A Canvas 2D grid of colored square pixels dissolves along a direction with a noisy leading edge, uncovering the real content beneath.

Install

npx shadcn@latest add @paragon/pixel-reveal

pixel-reveal.tsx

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