Two-Dimensional One Page Scroll Navigation with Scroll-Grid

Category: Javascript , Layout | April 24, 2025
Author:stoumann
Views Total:0 views
Official Page:Go to website
Last Update:April 24, 2025
License:MIT

Preview:

Two-Dimensional One Page Scroll Navigation with Scroll-Grid

Description:

Scroll-Grid is a responsive, mobile-friendly one page scrolling system built with vanilla JavaScript and CSS/CSS3.

This system enables bidirectional scrolling (horizontal and vertical) synchronized with a floating position grid, to create spatial navigation through content cells.

It is good for building layouts where content flows both down and across. Think of use cases like interactive storytelling, project portfolios arranged in a grid, or unique single-page applications where navigation isn’t just linear.

Features:

  • Supports standard vertical scrolling between rows and horizontal scrolling within rows.
  • Generates a visual map of content cells; clicking a point scrolls the corresponding cell into view.
  • Leverages CSS scroll-behavior: smooth and JS scrollIntoView({ behavior: 'smooth' }) for transitions.
  • Relies heavily on CSS Flexbox, Grid, and Scroll Snap for the core structure and behavior.
  • Allows users to navigate the grid using arrow keys.
  • Uses IntersectionObserver to efficiently update the navigation grid based on the currently visible cell.
  • Keeps horizontal scroll positions aligned across different rows during navigation.

See it in action:

How to use it:

1. Create a main a main container (ol.ui-scroll-grid) for the one page scrolling system. Inside this, each direct child <li> represents a row. Within each row <li>, place another <ol>. This nested <ol> will contain the actual content cells (<li>) for that row. It’s crucial that each content cell <li> has a unique ID following the pattern rX-cY (e.g., r1-c1, r3-c5), where X is the row number (1-based) and Y is the column number (1-based).

<ol class="ui-scroll-grid">
  <li>
    <ol>
      <li id="r1-c1">You can enter Nowhere from Anywhere.</li>
      <li id="r1-c2">I came from the North.</li>
      <li id="r1-c3">I am now here.<br>In Nowhere.</li>
      <li id="r1-c4">The map, a sheet of paper, with a single sentence.</li>
      <li id="r1-c5">&OpenCurlyDoubleQuote;The only way out is in&CloseCurlyDoubleQuote;</li>
    </ol>
  </li>
  <li>
    <ol>
      <li id="r2-c1">My canister if half full.<br>Or half empty?</li>
      <li id="r2-c2">The path can lead to Anywhere.<br>Or Everywhere.</li>
      <li id="r2-c3">Just keep walking.<br>Aimlessly.<br>Or determined.</li>
      <li id="r2-c4">Did I just spot Yin and Yang?</li>
      <li id="r2-c5">Nothing is permanent.</li>
    </ol>
  </li>
  <li>
    <ol>
      <li id="r3-c1">You can enter Nowhere from Everywhere.</li>
      <li id="r3-c2">I came from the West.</li>
      <li id="r3-c3">I am now in the Middle of Nowhere.</li>
      <li id="r3-c4">I came from the East.</li>
      <li id="r3-c5">You can enter Nowhere from Everywhere.</li>
    </ol>
  </li>
  <li>
    <ol>
      <li id="r4-c1">Nothing is permanent.</li>
      <li id="r4-c2">Did I just spot Yang and Yin?</li>
      <li id="r4-c3">Just keep walking.<br>Determined.<br>Or aimlessly.</li>
      <li id="r4-c4">The path can lead to Everywhere.<br>Or Anywhere.</li>
      <li id="r4-c5">My canister if half empty.<br>Or half full?</li>
    </ol>
  </li>
  <li>
    <ol>
      <li id="r5-c1">&OpenCurlyDoubleQuote;The only way out is in&CloseCurlyDoubleQuote;</li>
      <li id="r5-c2">The map, a sheet of paper, with a single sentence.</li>
      <li id="r5-c3">I am now here.<br>In Nowhere.</li>
      <li id="r5-c4">I came from the South</li>
      <li id="r5-c5">You can enter Nowhere from Anywhere.</li>
    </ol>
  </li>
</ol>

2. Create a <nav> element with the class ui-scroll-grid-nav. Inside, add empty <a> tags. Each <a> tag’s href attribute must point to the ID of the corresponding content cell <li>. The title attribute is good practice for accessibility. The order of these links should match the grid order (row by row).

<nav class="ui-scroll-grid-nav">
  <a href="#r1-c1" title="Row 1, Cell 1"></a>
  <a href="#r1-c2" title="Row 1, Cell 2"></a>
  <a href="#r1-c3" title="Row 1, Cell 3"></a>
  <a href="#r1-c4" title="Row 1, Cell 4"></a>
  <a href="#r1-c5" title="Row 1, Cell 5"></a>
  <a href="#r2-c1" title="Row 2, Cell 1"></a>
  <a href="#r2-c2" title="Row 2, Cell 2"></a>
  <a href="#r2-c3" title="Row 2, Cell 3"></a>
  <a href="#r2-c4" title="Row 2, Cell 4"></a>
  <a href="#r2-c5" title="Row 2, Cell 5"></a>
  <a href="#r3-c1" title="Row 3, Cell 1"></a>
  <a href="#r3-c2" title="Row 3, Cell 2"></a>
  <a href="#r3-c3" title="Row 3, Cell 3"></a>
  <a href="#r3-c4" title="Row 3, Cell 4"></a>
  <a href="#r3-c5" title="Row 3, Cell 5"></a>
  <a href="#r4-c1" title="Row 4, Cell 1"></a>
  <a href="#r4-c2" title="Row 4, Cell 2"></a>
  <a href="#r4-c3" title="Row 4, Cell 3"></a>
  <a href="#r4-c4" title="Row 4, Cell 4"></a>
  <a href="#r4-c5" title="Row 4, Cell 5"></a>
  <a href="#r5-c1" title="Row 5, Cell 1"></a>
  <a href="#r5-c2" title="Row 5, Cell 2"></a>
  <a href="#r5-c3" title="Row 5, Cell 3"></a>
  <a href="#r5-c4" title="Row 5, Cell 4"></a>
  <a href="#r5-c5" title="Row 5, Cell 5"></a>
</nav>

3. Insert the necessary CSS styles into your document:

  • .ui-scroll-grid: Sets up vertical scroll snapping (scroll-snap-type: y mandatory) and acts as a container (container-type: inline-size) for potential container query usage.
  • .ui-scroll-grid > li > ol: Sets up horizontal scroll snapping (scroll-snap-type: x mandatory) using display: flex.
  • .ui-scroll-grid > li > ol > li: Defines the dimensions (100vw width adjusted for scrollbar, 100dvh height) and alignment (scroll-snap-align: start) for each cell. The font-size: 7.5cqi makes text size relative to the container’s width.
  • .ui-scroll-grid-nav: Uses position: fixed and display: grid to create the floating navigation map. The grid-template-columns: repeat(5, 1fr) assumes a 5-column layout; adjust this if your grid dimensions differ.
  • .ui-scroll-grid-nav a.active: Styles the currently active navigation dot.
body {
  font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif;
  font-weight: normal;
  height: 100dvh;
  margin: 0;
  scrollbar-color: #0003 #0001;
  scrollbar-width: thin;
  width: 100vw;
}
.ui-scroll-grid {
  container-type: inline-size;
  height: 100dvh;
  list-style-type: none;
  margin: 0;
  overflow: clip auto;
  padding: 0;
  scroll-behavior: smooth;
  scroll-snap-type: y mandatory;
  width: 100vw;
  ol {
    display: flex;
    list-style-type: none;
    overflow: auto clip;
    padding: 0;
    scroll-behavior: smooth;
    scroll-snap-type: x mandatory;
  }
  li {
    background: var(--bg);
    box-sizing: border-box;
    color: #FFFD;
    display: grid;
    flex: 0 0 calc(100vw - 1rem);
    font-size: 7.5cqi;
    height: 100dvh;
    padding-inline: 1rem;
    place-content: center;
    scroll-snap-align: start;
    text-align: center;
    text-wrap: balance;
  }
  & > li {
    &:nth-of-type(1) { --bg: #123; }
    &:nth-of-type(2) { --bg: #187; }
    &:nth-of-type(3) { --bg: #C90; }
    &:nth-of-type(4) { --bg: #D32; }
    &:nth-of-type(5) { --bg: #594; }
  }
}
.ui-scroll-grid-nav {
  bottom: 2rem;
  display: grid;
  gap: .25rem;
  grid-template-columns: repeat(5, 1fr);
  position: fixed;
  right: 2rem;
  a {
    aspect-ratio: 1;
    background-color: #FFFD;
    border-radius: 50%;
    display: block;
    width: .5rem;
    &.active {
      background-color: #0EF;
    }
  }
}

4. Initialize the script after your DOM content has loaded:

function handleNavigation(links, lists, activeClass = 'active') {
  if (!links?.length || !lists?.length) return;
  let linkClicked = false;
  let currentRow = 0;
  let currentCol = 0;
  const syncScroll = target => {
    const parent = target.parentNode;
    lists.forEach(ol => ol !== parent && (ol.scrollLeft = parent.scrollLeft));
  };
  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);
      });
    }
  };
  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);
  };
  const IO = new IntersectionObserver(entries => 
    entries.forEach(({ isIntersecting, intersectionRatio, target }) => {
      if (isIntersecting && intersectionRatio >= 0.5) {
        links.forEach(link => link.classList.remove(activeClass));
        const link = [...links].find(link => link.hash === `#${target.id}`);
        link?.classList.add(activeClass);
        // Update current position when scrolling
        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] 
    }
  );
  links.forEach(link => {
    const target = document.getElementById(link.hash.slice(1));
    if (!target) return;
    IO.observe(target);
    link.addEventListener('click', e => {
      e.preventDefault();
      const [_, row, col] = target.id.match(/r(\d+)-c(\d+)/);
      currentRow = parseInt(row) - 1;
      currentCol = parseInt(col) - 1;
      navigateToCell(currentRow, currentCol);
    });
  });
  // Add keyboard event listener
  document.addEventListener('keydown', handleKeydown);
}
/* Initialize navigation with links and lists from DOM */
handleNavigation(
  document.querySelectorAll('.ui-scroll-grid-nav a'),
  document.querySelectorAll('.ui-scroll-grid ol')
);

FAQs

Q: How responsive is the layout?
It’s designed to be responsive. Cell dimensions use viewport units (vw, dvh), and the example uses container query units (cqi) for font size, making them adapt to the viewport/container size. The CSS uses Flexbox/Grid for the layout, which are inherently flexible. Test on various devices, especially regarding scrollbar handling which can affect 100vw.

Q: Can I customize the navigation map’s appearance?
Yes, completely. The .ui-scroll-grid-nav and its a elements are styled with standard CSS. You can change the shape, size, color, position, and active state appearance. You’ll also need to adjust grid-template-columns in .ui-scroll-grid-nav if your grid isn’t 5 columns wide.

Q: Does it work well on touch devices?
Yes, the core scrolling relies on native browser scroll behavior, which works with touch gestures (swiping). The navigation map clicks also work as expected.

Q: What if my rows have different numbers of columns?
The provided JavaScript assumes a uniform grid (e.g., 5×5 based on the example nav CSS and logic). Handling irregular grids would require significant changes to the handleNavigation function (especially keyboard navigation boundary checks) and the CSS for .ui-scroll-grid-nav. You’d likely need a way (e.g., data attributes) to define the bounds for each row.

Q: Are there performance limits?
Like any scroll-heavy interface, performance depends on the complexity of the content within each cell. Many cells with heavy images, videos, or complex scripts could potentially cause jank during scrolling or transitions. IntersectionObserver is efficient, but the rendering cost of the cells themselves is the main factor. Keep cell content reasonably optimized.

You Might Be Interested In:


Leave a Reply