AnimationsIntermediateApril 9, 2026

Pixelate Image

Canvas-based pixelation reveal for images. Renders the image through progressively finer pixel grids until sharp. Supports inview, hover, click, and load triggers. Zero dependencies — pure canvas and requestAnimationFrame.

View Full Demo →
demo.jsx
import PixelateImage from "./index.jsx";
import styles from "./demo.module.css";

export default function PixelateImageDemo() {
  return (
    <div className={styles.page}>

      {/* Hero */}
      <section className={styles.hero}>
        <PixelateImage
          src="/demo-assets/models/model3.png"
          alt=""
          trigger="load"
          duration={110}
          steps={16}
          columns={10}
          className={styles.heroImage}
        />
        <div className={styles.heroOverlay} />
        <div className={styles.heroContent}>
          <p className={styles.heroLabel}>Canvas · No dependencies</p>
          <h1 className={styles.heroTitle}>Pixelate<br />Image</h1>
        </div>
      </section>

      {/* Inview trigger */}
      <section className={styles.section}>
        <p className={styles.sectionLabel}>trigger="inview"</p>
        <p className={styles.sectionTitle}>Resolves pixel-by-pixel as it enters the viewport.</p>
        <PixelateImage
          src="/demo-assets/westend.png"
          alt="Westend"
          trigger="inview"
          duration={120}
          steps={14}
          columns={10}
          className={styles.banner}
        />
      </section>

      {/* Hover trigger */}
      <section className={styles.section}>
        <p className={styles.sectionLabel}>trigger="hover"</p>
        <p className={styles.sectionTitle}>Hover to reveal.</p>
        <div className={styles.grid}>
          {[
            { src: "/demo-assets/models/model0.png", label: "Sarah Chen" },
            { src: "/demo-assets/models/model1.png", label: "Marcus Reed" },
            { src: "/demo-assets/models/model2.png", label: "Aisha Williams" },
          ].map(({ src, label }) => (
            <div key={src} className={styles.cardWrap}>
              <PixelateImage
                src={src}
                alt={label}
                trigger="hover"
                duration={100}
                steps={10}
                columns={8}
                className={styles.card}
              />
              <span className={styles.cardLabel}>{label}</span>
            </div>
          ))}
        </div>
      </section>

      {/* Click trigger */}
      <section className={styles.section}>
        <p className={styles.sectionLabel}>trigger="click"</p>
        <p className={styles.sectionTitle}>Click to reveal.</p>
        <div className={styles.grid}>
          {[
            { src: "/demo-assets/models/model3.png", label: "Jordan Park" },
            { src: "/demo-assets/models/model4.png", label: "Elena Torres" },
            { src: "/demo-assets/kickandbass.png", label: "Kickandbass" },
          ].map(({ src, label }) => (
            <div key={src} className={styles.cardWrap}>
              <PixelateImage
                src={src}
                alt={label}
                trigger="click"
                duration={140}
                steps={16}
                columns={12}
                className={styles.card}
              />
              <span className={styles.cardLabel}>{label}</span>
            </div>
          ))}
        </div>
      </section>

    </div>
  );
}
index.jsx
"use client";

import { useEffect, useRef } from "react";

export default function PixelateImage({
  src,
  alt = "",
  trigger = "inview",
  duration = 150,
  steps = 12,
  columns = 12,
  fit = "cover",
  className = "",
}) {
  const rootRef = useRef(null);
  const imgRef = useRef(null);

  useEffect(() => {
    const root = rootRef.current;
    const img = imgRef.current;
    if (!root || !img) return;

    const elRenderDuration = Math.max(16, duration);
    const elRenderSteps = Math.max(1, steps);
    const elRenderColumns = Math.max(1, columns);
    const fitMode = fit.toLowerCase();

    // Canvas setup
    const canvas = document.createElement("canvas");
    canvas.setAttribute("data-pixelate-canvas", "");
    canvas.style.cssText =
      "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;";
    root.appendChild(canvas);

    const ctx = canvas.getContext("2d", { alpha: true });
    ctx.imageSmoothingEnabled = false;

    const back = document.createElement("canvas");
    const tiny = document.createElement("canvas");
    const bctx = back.getContext("2d", { alpha: true });
    const tctx = tiny.getContext("2d", { alpha: true });

    let naturalW = 0,
      naturalH = 0;
    let playing = false,
      stageIndex = 0,
      stageStart = 0;
    let backDirty = true,
      resizeTimeout = 0;
    let rafId = null;
    let stepsArr = [elRenderColumns];
    let io = null;

    function fitCanvas() {
      const r = root.getBoundingClientRect();
      const dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
      const w = Math.max(1, Math.round(r.width * dpr));
      const h = Math.max(1, Math.round(r.height * dpr));
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w;
        canvas.height = h;
        back.width = w;
        back.height = h;
        backDirty = true;
      }
      regenerateSteps();
    }

    function regenerateSteps() {
      const cw = Math.max(1, canvas.width);
      const startCols = Math.min(elRenderColumns, cw);
      const total = Math.max(1, elRenderSteps);
      const use = Math.max(1, Math.floor(total * 0.9));
      const a = [];
      const ratio = Math.pow(cw / startCols, 1 / total);
      for (let i = 0; i < use; i++) {
        a.push(Math.max(1, Math.round(startCols * Math.pow(ratio, i))));
      }
      for (let i = 1; i < a.length; i++) {
        if (a[i] <= a[i - 1]) a[i] = a[i - 1] + 1;
      }
      stepsArr = a.length ? a : [startCols];
    }

    function drawImageToBack() {
      if (!backDirty || !naturalW || !naturalH) return;
      const cw = back.width,
        ch = back.height;
      let dw = cw,
        dh = ch,
        dx = 0,
        dy = 0;
      if (fitMode !== "stretch") {
        const s =
          fitMode === "cover"
            ? Math.max(cw / naturalW, ch / naturalH)
            : Math.min(cw / naturalW, ch / naturalH);
        dw = Math.max(1, Math.round(naturalW * s));
        dh = Math.max(1, Math.round(naturalH * s));
        dx = (cw - dw) >> 1;
        dy = (ch - dh) >> 1;
      }
      bctx.clearRect(0, 0, cw, ch);
      bctx.imageSmoothingEnabled = true;
      bctx.drawImage(img, dx, dy, dw, dh);
      backDirty = false;
    }

    function pixelate(cols) {
      const cw = canvas.width,
        ch = canvas.height;
      cols = Math.max(1, Math.floor(cols));
      const rows = Math.max(1, Math.round(cols * (ch / cw)));
      if (tiny.width !== cols || tiny.height !== rows) {
        tiny.width = cols;
        tiny.height = rows;
      }
      tctx.imageSmoothingEnabled = false;
      tctx.clearRect(0, 0, cols, rows);
      tctx.drawImage(back, 0, 0, cw, ch, 0, 0, cols, rows);
      ctx.imageSmoothingEnabled = false;
      ctx.clearRect(0, 0, cw, ch);
      ctx.drawImage(tiny, 0, 0, cols, rows, 0, 0, cw, ch);
    }

    function draw(stepCols) {
      if (!canvas.width || !canvas.height) return;
      drawImageToBack();
      pixelate(stepCols);
    }

    function animate(t) {
      if (!playing) return;
      if (!stageStart) stageStart = t;
      if (t - stageStart >= elRenderDuration) {
        stageIndex++;
        stageStart = t;
      }
      draw(stepsArr[Math.min(stageIndex, stepsArr.length - 1)]);
      if (stageIndex >= stepsArr.length - 1) {
        canvas.style.opacity = "0";
        playing = false;
        window.removeEventListener("resize", onWindowResize);
        setTimeout(() => canvas.remove(), 250);
        return;
      }
      rafId = requestAnimationFrame(animate);
    }

    function prime() {
      fitCanvas();
      const run = () => {
        naturalW = img.naturalWidth;
        naturalH = img.naturalHeight;
        if (!naturalW || !naturalH) return;
        stageIndex = 0;
        canvas.style.opacity = "1";
        backDirty = true;
        draw(stepsArr[0]);
      };
      if (img.complete && img.naturalWidth) run();
      else img.addEventListener("load", run, { once: true });
    }

    function start() {
      if (playing) return;
      fitCanvas();
      const run = () => {
        naturalW = img.naturalWidth;
        naturalH = img.naturalHeight;
        if (!naturalW || !naturalH) return;
        stageIndex = 0;
        stageStart = 0;
        canvas.style.opacity = "1";
        backDirty = true;
        playing = true;
        rafId = requestAnimationFrame(animate);
      };
      if (img.complete && img.naturalWidth) run();
      else img.addEventListener("load", run, { once: true });
    }

    function onResize() {
      fitCanvas();
      if (!playing) {
        draw(stepsArr[Math.min(stageIndex, stepsArr.length - 1)] || stepsArr[0]);
      }
    }

    function onWindowResize() {
      clearTimeout(resizeTimeout);
      resizeTimeout = setTimeout(onResize, 250);
    }

    if (trigger === "load") {
      prime();
      start();
    } else if (trigger === "inview") {
      prime();
      io = new IntersectionObserver(
        (entries) => {
          for (const e of entries) {
            if (e.isIntersecting) {
              start();
              io.disconnect();
              break;
            }
          }
        },
        { rootMargin: "0px 0px -25% 0px", threshold: 0.25 }
      );
      io.observe(root);
      window.addEventListener("resize", onWindowResize);
    } else if (trigger === "hover") {
      prime();
      root.addEventListener("mouseenter", start, { once: true });
      window.addEventListener("resize", onWindowResize);
    } else if (trigger === "click") {
      prime();
      root.addEventListener("click", start, { once: true });
      window.addEventListener("resize", onWindowResize);
    }

    return () => {
      playing = false;
      if (rafId) cancelAnimationFrame(rafId);
      clearTimeout(resizeTimeout);
      window.removeEventListener("resize", onWindowResize);
      io?.disconnect();
      if (canvas.parentNode) canvas.remove();
    };
  }, [src, trigger, duration, steps, columns, fit]);

  return (
    <div ref={rootRef} className={className} style={{ position: "relative" }}>
      <img
        ref={imgRef}
        src={src}
        alt={alt}
        style={{ display: "block", width: "100%", height: "100%", objectFit: "cover" }}
      />
    </div>
  );
}
demo.module.css
.page {
  background: #111;
  color: #efeeec;
  min-height: 100vh;
}

/* Hero */
.hero {
  height: 100vh;
  position: relative;
  overflow: hidden;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.heroImage {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.heroOverlay {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.1) 60%);
  z-index: 1;
  pointer-events: none;
}

.heroContent {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: 3rem 5vw;
}

.heroLabel {
  font-size: 0.75rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.5);
  margin: 0 0 1.5rem;
}

.heroTitle {
  font-size: clamp(3.5rem, 10vw, 9rem);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 0.95;
  margin: 0;
}

/* Sections */
.section {
  padding: 8vh 5vw;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.sectionLabel {
  font-size: 0.7rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.3);
  margin: 0 0 2rem;
}

.sectionTitle {
  font-size: clamp(1.25rem, 2.5vw, 2rem);
  font-weight: 400;
  letter-spacing: -0.01em;
  margin: 0 0 3rem;
  color: rgba(255, 255, 255, 0.7);
}

/* Inview — full-width banner */
.banner {
  width: 100%;
  aspect-ratio: 16 / 7;
}

/* Hover / Click — card grid */
.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
}

.card {
  aspect-ratio: 3 / 4;
  cursor: pointer;
}

/* Card label */
.cardWrap {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.cardLabel {
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.35);
}

@media (max-width: 767px) {
  .grid {
    grid-template-columns: 1fr 1fr;
  }
}

Apr 27, 2026

ANIMATIONS

Cursor Hover Label

A custom cursor label that follows the mouse and fades in when hovering trigger elements. Uses GSAP quickTo for smooth tracking. Trigger elements use data-cursor-label attributes to set label text. Inspired by portfolio/agency sites like Studio PIC.

Apr 27, 2026

ANIMATIONS

Section Transition 01

GSAP ScrollTrigger-based section transition system. Sibling sections opt into parallax, pin, or reveal modes via data attributes. Supports y offset, overlay opacity, and overlay color per section. Mobile strategy simplifies motion on smaller screens.

Apr 27, 2026

ANIMATIONS

Text Reveal 01

GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.