Pixelated Image Reveal

A card that transitions between two images using a randomized pixel grid — squares blink on in a staggered scatter, briefly expose the underlying image, then scatter off to reveal the new state. On touch devices the toggle fires on tap instead of hover.

gsaphoverpixelimagerevealgridtransition

Setup — External Scripts

CDN — GSAP (add before </body>)
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>

Code

HTML
html
<section class="cloneable">
  <div data-hover="" data-pixelated-image-reveal="" class="pixelated-image-card">
    <div class="before__100"></div>
    <div class="pixelated-image-card__default">
      <img src="https://cdn.prod.website-files.com/6712ad33825977f9d2f1ba2c/6714d43a777a77da89a9b5ec_osmo-pixelated-image-1.jpg" width="400" alt="" class="pixelated-image-card__img">
    </div>
    <div data-pixelated-image-reveal-active="" class="pixelated-image-card__active">
      <img src="https://cdn.prod.website-files.com/6712ad33825977f9d2f1ba2c/6714d43a4d1abab1b3c81caf_osmo-pixelated-image-2.jpg" width="400" alt="" class="pixelated-image-card__img">
    </div>
    <div data-pixelated-image-reveal-grid="" class="pixelated-image-card__pixels">
      <div class="pixelated-image-card__pixel"></div>
    </div>
  </div>
</section>
CSS
css
.cloneable {
  padding: var(--container-padding);
  justify-content: center;
  align-items: center;
  min-height: 100svh;
  display: flex;
  position: relative;
}

.pixelated-image-card {
  background-color: var(--color-neutral-800);
  color: var(--color-primary);
  border-radius: .5em;
  width: 30vw;
  max-width: 100%;
  position: relative;
  overflow: hidden;
}

.before__100 {
  padding-top: 100%;
}

.pixelated-image-card__default,
.pixelated-image-card__img,
.pixelated-image-card__active,
.pixelated-image-card__pixels {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.pixelated-image-card__active {
  display: none;
}

.pixelated-image-card__pixel {
  background-color: currentColor;
  width: 100%;
  height: 100%;
  display: none;
  position: absolute;
}
JavaScript
javascript
function initPixelatedImageReveal() {
  const animationStepDuration = 0.3;
  const gridSize = 7;
  const pixelSize = 100 / gridSize;
  const cards = document.querySelectorAll('[data-pixelated-image-reveal]');
  const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || window.matchMedia("(pointer: coarse)").matches;

  cards.forEach((card) => {
    const pixelGrid   = card.querySelector('[data-pixelated-image-reveal-grid]');
    const activeCard  = card.querySelector('[data-pixelated-image-reveal-active]');
    const existingPixels = pixelGrid.querySelectorAll('.pixelated-image-card__pixel');
    existingPixels.forEach(pixel => pixel.remove());

    for (let row = 0; row < gridSize; row++) {
      for (let col = 0; col < gridSize; col++) {
        const pixel = document.createElement('div');
        pixel.classList.add('pixelated-image-card__pixel');
        pixel.style.width  = `${pixelSize}%`;
        pixel.style.height = `${pixelSize}%`;
        pixel.style.left   = `${col * pixelSize}%`;
        pixel.style.top    = `${row * pixelSize}%`;
        pixelGrid.appendChild(pixel);
      }
    }

    const pixels       = pixelGrid.querySelectorAll('.pixelated-image-card__pixel');
    const totalPixels  = pixels.length;
    const staggerDuration = animationStepDuration / totalPixels;
    let isActive = false;
    let delayedCall;

    const animatePixels = (activate) => {
      isActive = activate;
      gsap.killTweensOf(pixels);
      if (delayedCall) delayedCall.kill();
      gsap.set(pixels, { display: 'none' });

      gsap.to(pixels, {
        display: 'block',
        duration: 0,
        stagger: { each: staggerDuration, from: 'random' }
      });

      delayedCall = gsap.delayedCall(animationStepDuration, () => {
        activeCard.style.display       = activate ? 'block' : 'none';
        activeCard.style.pointerEvents = activate ? 'none' : '';
      });

      gsap.to(pixels, {
        display: 'none',
        duration: 0,
        delay: animationStepDuration,
        stagger: { each: staggerDuration, from: 'random' }
      });
    };

    if (isTouchDevice) {
      card.addEventListener('click', () => animatePixels(!isActive));
    } else {
      card.addEventListener('mouseenter', () => {
        if (!isActive) animatePixels(true);
      });
      card.addEventListener('mouseleave', () => {
        if (isActive) animatePixels(false);
      });
    }
  });
}

// Initialize Pixelated Image Reveal
document.addEventListener('DOMContentLoaded', () => {
  initPixelatedImageReveal();
});

Attributes

NameTypeDefaultDescription
[data-pixelated-image-reveal]attributeThe card container. The script attaches mouseenter/mouseleave (or click on touch) events here and reads the grid and active child elements from within it.
[data-pixelated-image-reveal-active]attributeThe alternate state element shown after the pixel transition completes. Hidden by default (display: none) and toggled by the script at the midpoint of the animation.
[data-pixelated-image-reveal-grid]attributeThe pixel overlay container. Any existing .pixelated-image-card__pixel children are cleared on init and replaced with a fresh gridSize × gridSize set of absolutely-positioned pixel divs.
animationStepDurationnumber0.3Total duration in seconds for the pixel-on or pixel-off phase. Adjust in the script to make the reveal faster or slower.
gridSizenumber7Number of rows and columns in the pixel grid (gridSize × gridSize total pixels). Increase for smaller, more numerous pixels; decrease for larger, blockier ones.

Notes

  • Requires GSAP loaded via CDN before the script runs.
  • On touch devices (detected via ontouchstart, maxTouchPoints, and pointer: coarse), the animation toggles on each tap instead of hover enter/leave.
  • The pixel grid is rebuilt from scratch on every initPixelatedImageReveal() call — existing .pixelated-image-card__pixel elements inside the grid are removed first.
  • gsap.killTweensOf(pixels) and delayedCall.kill() ensure rapid re-triggers (fast hover in/out) never leave the card in an inconsistent state.
  • The active image is shown/hidden at exactly the animationStepDuration midpoint using gsap.delayedCall, so the swap is always hidden behind the pixel overlay.
  • Multiple [data-pixelated-image-reveal] cards on the same page are fully supported — each gets its own pixel grid, isActive flag, and event listeners.

Guide

Duration

Adjust animationStepDuration to control how fast the pixel scatter plays. Lower values create a snappier flash; higher values give a slower, more deliberate reveal.

const animationStepDuration = 0.3; // seconds

Pixel size

Change gridSize to increase or decrease the number of pixels in the grid. The pixel size is derived automatically: pixelSize = 100 / gridSize (as a percentage of the card).

const gridSize = 7; // 7×7 = 49 pixels