Scroll Animation with Grid: The Ultimate Beautiful Guide [2026]
Scroll Animation with Grid: The Ultimate Beautiful Guide [2026]

Scroll Animation with Grid: The Ultimate Beautiful Guide [2026]18 min read

  Reading time 19 minutes


The Magic of the Zooming Camera

Have you ever visited an ultra-modern website and been blown away by a stunning scroll animation with grid images appearing like magic? It feels incredibly high-tech, but the secret behind it is actually quite simple!

Imagine you are standing with your nose pressed right up against a giant photograph on a wall. As you take steps backward, you realize that the photograph is actually just the center piece of a massive wall of pictures. As you walk further back, more and more pictures reveal themselves from the edges.

That is exactly what we are building today! By tying an animation to your mouse’s scroll wheel, we will make a center image shrink down while surrounding grid layers fade and scale into view.

Understanding Our Scroll Animation with Grid

To make this effect work flawlessly, we need three ingredients. Think of them as the three members of a band working in perfect harmony.

  • HTML: The stage setup. This holds our images in different “layers.”
  • CSS: The lighting and positioning. We use a fantastic modern feature called “Subgrid” to keep all our layers perfectly aligned.
  • JavaScript (Motion): The director. We will use a powerful animation library to tie our visuals directly to the user’s scrollbar.

Before we write the logic, it is always smart to check out foundational grid layout concepts. Feel free to review them here: CSS Grid Layouts.

Scroll Animation with Grid
Scroll Animation with Grid

Setting Up the HTML Skeleton

Our HTML structure is like a layered cake. We have a main container that holds a center image (the one we initially zoom in on). Around it, we create three distinct “layers” of images.

We group these images into layers so we can animate them at different speeds later. The outer edges will animate differently than the inner columns.

The CSS Magic for Our Scroll Animation with Grid

The real hero of our layout is CSS Grid. More specifically, we are using grid-template-columns: subgrid.

If you haven’t used subgrid before, it is wonderfully simple. Normally, a child element inside a grid has no idea what the parent’s grid looks like. Subgrid tells the child, “Hey, just use the exact same grid lines as your parent!” This allows us to stack multiple layers of images directly on top of each other while keeping them perfectly aligned.

Here is a quick breakdown of how we position our elements:

ElementGrid RoleVisual Result
.gridThe Master ParentCreates a 5-column by 3-row layout.
.layerThe Subgrid ChildStacks exactly on top of the .grid using its parent’s columns.
.scalerThe CenterpiecePlaced perfectly in the middle column, expanding to cover the screen initially.

Bringing it to Life with Motion

Writing complex scroll animations from scratch can be a headache. That is why we are using Motion (a fantastic, modern animation library).

Motion gives us two incredible functions: animate() and scroll(). We wrap our animations inside the scroll function, which tells the browser, “Don’t play this animation based on a timer. Play it based on how far down the page the user has scrolled!”

We also included a very important accessibility feature in our JavaScript. We check prefers-reduced-motion. If a user gets motion sickness and has told their operating system they prefer less movement, our code politely skips the animation entirely.

The CSS Magic for Our Scroll Animation with Grid
The CSS Magic for Our Scroll Animation with Grid

The Complete Code Implementation

Here is the exact code you need to bring this to life!

First, we set up our HTML. Note how we divide the images into three distinct .layer containers, plus our .scaler for the center image. This original concept was beautifully crafted by Jhey on CodePen and we are converting it to use Motion!

HTML

<body>
  <div class="content-wrap">
    <header>
      <h1 class="fluid">let's<br />scroll.</h1>
      <h2 class="fluid">Origionally from 
        <a href="https://codepen.io/jh3y/pen/VYZwOwd" target="_blank">Jhey →</a>, converted to Motion
      </h2>
    </header>
    <main>
      <section>
        <div class="content">
          <div class="grid">
            <div class="layer">
              <div>
                <img src="https://images.unsplash.com/photo-1463100099107-aa0980c362e6?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjZ8fGZhc2hpb258ZW58MHx8MHx8fDA%3D" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1556304044-0699e31c6a34?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8OTJ8fGZhc2hpb258ZW58MHx8MHx8fDA%3D" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1590330297626-d7aff25a0431?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTA3fHxmYXNoaW9ufGVufDB8fDB8fHww" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTk1fHxmYXNoaW9ufGVufDB8fDB8fHww" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1488161628813-04466f872be2?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NzR8fG1vZGVsJTIwZmFzaGlvbiUyMHN0cmVldHxlbnwwfHwwfHx8MA%3D%3D" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1565321590372-09331b9dd1eb?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTR8fGFpciUyMGpvcmRhbnxlbnwwfHwwfHx8MA%3D%3D" alt="" />
              </div>
            </div>
            <div class="layer">
              <div>
                <img src="https://images.unsplash.com/photo-1531525645387-7f14be1bdbbd?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjM4fHxwcm9kdWN0fGVufDB8fDB8fHww" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1637414165749-9b3cd88b8271?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mjd8fHRlY2glMjBwcm9kdWN0fGVufDB8fDB8fHww" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1699911251220-8e0de3b5ce88?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8N3x8b25ld2hlZWx8ZW58MHx8MHx8fDA%3D" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1667483629944-6414ad0648c5?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTl8fGx1eHVyeSUyMHdhdGNofGVufDB8fDB8fHww" alt="" />
              </div>
              <div>
                <img src="https://plus.unsplash.com/premium_photo-1706078438060-d76ced26d8d5?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjF8fGNhbWVyYSUyMHBvbGFyb2lkfGVufDB8fDB8fHww" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1525385444278-b7968e7e28dc?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NDZ8fGl0ZW18ZW58MHx8MHx8fDA%3D" alt="" />
              </div>
            </div>
            <div class="layer">
              <div>
                <img src="https://images.unsplash.com/reserve/LJIZlzHgQ7WPSh5KVTCB_Typewriter.jpg?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8aXRlbXxlbnwwfHwwfHx8MA%3D%3D" alt="" />
              </div>
              <div>
                <img src="https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8ZmFzaGlvbnxlbnwwfHwwfHx8MA%3D%3D" alt="" />
              </div>
            </div>
            <div class="scaler">
              <img src="https://assets.codepen.io/605876/model-shades.jpg?format=auto&quality=100" alt="" />
            </div>
          </div>
        </div>
      </section>
      <section>
        <h2 class="fluid">fin.</h2>
      </section>
    </main>
  </div>
</body>

Next, we add our CSS. Pay close attention to the grid-template-columns: subgrid; line. That is doing the heavy lifting for our alignment.

CSS

/* Step 1: Basic Grid Setup - No animations yet */

*,
*::before,
*::after {
  box-sizing: border-box;
}

:root {
  --gutter: 2rem;
}

@media (max-width: 600px) {
  :root {
    --gutter: 1rem;
  }
}

body {
  margin: 0;
  font-family: 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif, system-ui;
  background: #000;
  color: #fff;
}

.content-wrap {
  background: #000;
  overflow: clip;
}

/* Header */
header {
  min-height: 100vh;
  display: grid;
  align-content: center;
  max-width: calc(100% - (2 * var(--gutter)));
  padding-left: 48px;
  text-align: left;
}

h2 {
  padding-top: 12px;
}

a {
  color:white;
}

.fluid {
  font-size: clamp(4rem, 12vw, 12rem);
  line-height: 0.6;
  margin: 0;
}

h2.fluid {
  font-size: clamp(0.5rem, 2vw, 1.5rem);
  padding-top: 48px;
}

/* Section setup - Sticky spacer trick */
main section:first-of-type {
  min-height: 240vh; /* Extra height for scroll space */
}

.content {
  min-height: 100vh;
  width: 100vw;
  display: flex;
  place-items: center;
  align-content: center;
  position: sticky;
  top: 0;
  overflow: hidden;
}

main section:last-of-type {
  min-height: 100vh;
  display: grid;
  place-items: center;
}

/* Grid Layout - 5 columns x 3 rows */
.grid {
  --offset: 0;
  --container-width: 1600px;
  --gap: clamp(10px, 7.35vw, 80px);

  width: var(--container-width);
  max-width: calc(100% - (2 * var(--gutter)));
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-template-rows: repeat(3, auto);
  gap: var(--gap);
  margin: 0 auto;
  align-content: center;
  position: absolute;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
}

@media (max-width: 600px) {
  .grid {
    grid-template-columns: repeat(3, 1fr);
    --offset: -1;
  }
  
  .grid > .layer:nth-of-type(1) {
    display: none;
  }
}

/* Layers using subgrid */
.grid > .layer {
  display: grid;
  grid-column: 1 / -1;
  grid-row: 1 / -1;
  grid-template-columns: subgrid;
  grid-template-rows: subgrid;
}

/* Layer 1: Outer edges - odd on left column, even on right column */
.grid > .layer:nth-of-type(1) div:nth-of-type(odd) {
  grid-column: 1;
}

.grid > .layer:nth-of-type(1) div:nth-of-type(even) {
  grid-column: -2;
}

/* Layer 2: Inner columns */
.grid > .layer:nth-of-type(2) div:nth-of-type(odd) {
  grid-column: calc(2 + var(--offset));
}

.grid > .layer:nth-of-type(2) div:nth-of-type(even) {
  grid-column: calc(-3 - var(--offset));
}

/* Layer 3: Center column top and bottom */
.grid > .layer:nth-of-type(3) div:first-of-type {
  grid-column: calc(3 + var(--offset));
  grid-row: 1;
}

.grid > .layer:nth-of-type(3) div:last-of-type {
  grid-column: calc(3 + var(--offset));
  grid-row: -1;
}

.grid img {
  width: 100%;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  border-radius: 1rem;
}

.grid .scaler {
  position: relative;
  grid-area: 2 / calc(3 + var(--offset));
}

.scaler {
  z-index: 2;
  width: 100%;
  height: 100%;
  position: relative;

  img {
    position: absolute;
    top: 50%;
    left: 50%;
    translate: -50% -50%;
    object-fit: cover;
    border-radius: 1rem;
    width: 100%;
    height: 100%;
  }
}

Finally, the JavaScript. We are importing tools directly from the Motion CDN. We calculate the viewport size, shrink the center image, and then stagger the appearance of our .layer elements so they don’t all pop in at the exact same time!

JavaScript

import { animate, scroll, stagger, cubicBezier } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';

// Check for reduced motion preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (prefersReducedMotion) {
  // Skip animations if user prefers reduced motion
  console.log('Reduced motion preference detected - skipping animations');
} else {
  let image = document.querySelector('.scaler img');
  let firstSection = document.querySelector('main section:first-of-type');
  let layers = document.querySelectorAll('.grid > .layer');

  // Measure the natural size before animating
  const naturalWidth = image.offsetWidth;
  const naturalHeight = image.offsetHeight;
  
  // Get viewport dimensions in pixels
  const viewportWidth = window.innerWidth;
  const viewportHeight = window.innerHeight;

  // Animate image on scroll - shrink from full screen to natural size
  scroll(
    animate(image, {
      width: [viewportWidth, naturalWidth],
      height: [viewportHeight, naturalHeight]
    }, {
      width: { easing: cubicBezier(0.65, 0, 0.35, 1) },   // GSAP power2.inOut
      height: { easing: cubicBezier(0.42, 0, 0.58, 1) }   // GSAP power1.inOut
    }),
    {
      target: firstSection,
      offset: ['start start', '80% end end'] 
    }
  );

  // Animate each layer with staggered timing
  // Different easing per layer: power1, power3, power4
  const scaleEasings = [
    cubicBezier(0.42, 0, 0.58, 1),  // Layer 1: GSAP power1.inOut
    cubicBezier(0.76, 0, 0.24, 1),  // Layer 2: GSAP power3.inOut
    cubicBezier(0.87, 0, 0.13, 1)   // Layer 3: GSAP power4.inOut
  ];
  
  layers.forEach((layer, index) => {
    // Calculate different end points for each layer
    const endOffset = `${1 - (index * 0.05)} end`;
    
    // fade: opacity stays 0 until 55% of scroll progress, then fades to 1
    scroll(
      animate(layer, {
        opacity: [0, 0, 1]
      }, {
        offset: [0, 0.55, 1],  // Hold at 0 until 55%, then animate to 1
        easing: cubicBezier(0.61, 1, 0.88, 1)  // GSAP sine.out
      }),
      {
        target: firstSection,
        offset: ['start start', endOffset]
      }
    );
    
    // reveal: scale stays 0 until 30% of scroll progress, then scales to 1
    scroll(
      animate(layer, {
        scale: [0, 0, 1]
      }, {
        offset: [0, 0.3, 1],   // Hold at 0 until 30%, then animate to 1
        easing: scaleEasings[index]  // Different power curve per layer
      }),
      {
        target: firstSection,
        offset: ['start start', endOffset]
      }
    );
  });
}

Your Turn to Build

You just learned how to combine CSS subgrid with JavaScript scroll tracking to create an incredibly professional layout effect. By breaking your images into distinct HTML layers, aligning them with CSS, and orchestrating their movement with Motion, you can build experiences that feel completely magical to your users.

I would absolutely love to see what you create with this technique. What kind of images are you going to use for your grid? Drop a comment below, share this tutorial with a fellow developer, and let’s get building! 😊

5222
0
Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *