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:
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>
If—for some reason—CSS or JavaScript fails, this markup will display as a nested ordered list with clear structure:
Next, let’s add a unique id to each <li>
:
<li id="r1-c1">
<li id="r1-c2">
<!-- and so on -->
We’ll also include a “scroll spy,” which is a set of links pointing to those unique id
s:
<nav>
<a href="#r1-c1"></a>
<a href="#r1-c2"></a>
<!-- and so on -->
</nav>
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;
}
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;
}
To make each <li>
fill the entire screen, we’ll use the following CSS:
li {
flex: 0 0 100vw;
height: 100dvh;
}
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;
}
}
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:
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:
- Syncing Horizontal Scrolling: Ensure all rows scroll together
- Highlighting Active Navigation Dots: Show which cell is currently in view
- 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));
};
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);
});
}
};
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]
}
);
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);
};
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)