DEV Community

Cover image for Creating a Scroll Grid
Mads Stoumann
Mads Stoumann

Posted on

Creating a Scroll Grid

The other day I noticed the similarities between Tailscale and MUBI — not the companies, of course, but their logos! It also reminded me of a position control I once created. Subconsciously, this must have triggered something, because I woke up today wanting to code a “Scroll Grid.”

So, what is a scroll grid? It’s a scrollable grid in multiple directions, aided with a “scroll spy,” which looks a bit like MUBI’s and Tailscale’s logos:

Scroll Grid

Enough talking. Let’s code!

Progressive Enhancement

Progressive enhancement is a design philosophy that ensures basic functionality works for everyone while adding advanced features for those with modern browsers or JavaScript enabled.

HTML

We’ll start with five ordered lists (<ol>), each containing five <li> items, creating a grid of 5×5 cells.

<ol>
  <li><ol>...</ol></li>
  <li><ol>...</ol></li>
  <li><ol>...</ol></li>
  <li><ol>...</ol></li>
  <li><ol>...</ol></li>
</ol>
Enter fullscreen mode Exit fullscreen mode

If—for some reason—CSS or JavaScript fails, this markup will display as a nested ordered list with clear structure:

Text Only

Next, let’s add a unique id to each <li>:

<li id="r1-c1">
<li id="r1-c2">
<!-- and so on -->
Enter fullscreen mode Exit fullscreen mode

We’ll also include a “scroll spy,” which is a set of links pointing to those unique ids:

<nav>
  <a href="#r1-c1"></a>
  <a href="#r1-c2"></a>
  <!-- and so on -->
</nav>
Enter fullscreen mode Exit fullscreen mode

For accessibility, add a description or an aria-label for each link.

CSS

The next step in our progressive journey is adding scroll support without JavaScript.

For the main element (the outermost <ol>), we’ll apply the following styles:

.outer {
  overflow: clip auto;
  scroll-behavior: smooth;
  scroll-snap-type: y mandatory;
}
Enter fullscreen mode Exit fullscreen mode

This ensures vertical “snapping” for the inner <ol> elements. For those inner lists, we’ll add horizontal snapping:

.inner {
  display: flex;
  overflow: auto clip;
  scroll-snap-type: x mandatory;
}
Enter fullscreen mode Exit fullscreen mode

To make each <li> fill the entire screen, we’ll use the following CSS:

li {
  flex: 0 0 100vw;
  height: 100dvh;
}
Enter fullscreen mode Exit fullscreen mode

For the scroll spy, we simply create a fixed grid of 5×5 items:

.spy {
  display: grid;
  gap: .25rem;
  grid-template-columns: repeat(5, 1fr);
  inset-block-end: 2rem;
  inset-inline-end: 2rem;
  position: fixed;
  a {
    aspect-ratio: 1;
    background-color: #FFFD;
    border-radius: 50%;
    display: block;
    width: .5rem;
  }
}
Enter fullscreen mode Exit fullscreen mode

Additional minor styles (available in the final CodePen below) will complete the design. For now, we have all the basics, and we can navigate the grid by scrolling or clicking the dots:

Basic

If you scroll horizontally and then vertically, you might notice how you don’t land directly below the current item but rather at the first item of the next row. This happens because each row’s scrollLeft position needs to stay in sync. For that—and for highlighting the active dot—we need JavaScript.

NOTE: If you only need to highlight dots in a single row (like a carousel), you don’t need JavaScript. CSS scroll-driven animations can handle this. Check out this example by Bramus.

JavaScript

Now we get to the fun part — adding interactivity that transforms our basic scroll grid into a seamless, intuitive experience.

Our JavaScript will handle three key interactions:

  1. Syncing Horizontal Scrolling: Ensure all rows scroll together
  2. Highlighting Active Navigation Dots: Show which cell is currently in view
  3. Keyboard Navigation: Allow users to move around the grid using arrow keys

Let’s break down the handleNavigation function and highlight its most crucial bits:

Scroll Synchronization

The syncScroll function is our scroll synchronization maestro. When a user scrolls one row, it ensures all other rows match that horizontal scroll position:

const syncScroll = target => {
  const parent = target.parentNode;
  lists.forEach(ol => ol !== parent && (ol.scrollLeft = parent.scrollLeft));
};
Enter fullscreen mode Exit fullscreen mode

It takes the scrolled row’s parent and applies its scrollLeft to all other rows.

Navigation and Active State

The navigateToCell function manages navigation when a dot is clicked or a key is pressed:

const navigateToCell = (row, col) => {
  const targetId = `r${row + 1}-c${col + 1}`;
  const target = document.getElementById(targetId);
  const link = [...links].find(link => link.hash === `#${targetId}`);

  if (target && link) {
    linkClicked = true;
    target.scrollIntoView({ behavior: 'smooth' });
    links.forEach(l => l.classList.remove(activeClass));
    link.classList.add(activeClass);

    requestAnimationFrame(() => {
      setTimeout(() => {
        syncScroll(target);
        linkClicked = false;
      }, 1000);
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

Key highlights:

  • Smoothly scrolls to the target cell
  • Updates the active navigation dot
  • Syncs scrolling after a short delay to prevent immediate re-scrolling

Intersection Observer

The real magic happens with the Intersection Observer, which tracks which cells are currently visible:

const IO = new IntersectionObserver(entries => 
  entries.forEach(({ isIntersecting, intersectionRatio, target }) => {
    if (isIntersecting && intersectionRatio >= 0.5) {
      // Update active dot and current position
      links.forEach(link => link.classList.remove(activeClass));
      const link = [...links].find(link => link.hash === `#${target.id}`);
      link?.classList.add(activeClass);

      const [_, row, col] = target.id.match(/r(\d+)-c(\d+)/);
      currentRow = parseInt(row) - 1;
      currentCol = parseInt(col) - 1;

      if (!linkClicked) syncScroll(target);
    }
  }), { 
    threshold: [0, 0.5, 1.0] 
  }
);
Enter fullscreen mode Exit fullscreen mode

This observer:

  • Tracks when a cell is at least 50% in view
  • Updates the active navigation dot
  • Tracks the current grid position
  • Syncs scrolling when manually scrolling (not clicking)

Keyboard Navigation

And finally, keyboard support is added through the handleKeydown event listener:

const handleKeydown = (e) => {
  switch (e.key) {
    case 'ArrowLeft':   currentCol = Math.max(0, currentCol - 1); break;
    case 'ArrowRight':  currentCol = Math.min(4, currentCol + 1); break;
    case 'ArrowUp':     currentRow = Math.max(0, currentRow - 1); break;
    case 'ArrowDown':   currentRow = Math.min(4, currentRow + 1); break;
    default: return;
  }
  e.preventDefault();
  navigateToCell(currentRow, currentCol);
};
Enter fullscreen mode Exit fullscreen mode

This little snippet allows users to navigate using the arrow keys — within the 5×5 grid.

And that concludes this tutorial! We’ve built a progressive, scrollable grid that works with or without JavaScript. It supports multiple interaction methods—try scrolling with (or without) touch, navigating with arrow keys, or clicking on the dots.

Here's a Codepen demo:

Top comments (0)