Pixel Reveal
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; 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>
  );
}