Header

Scroll Into View: Navigating Within Pages

Letting your users move around within your pages, with “progressive enhancement”

Published Date
Wed Jun 21 2023

This page probably seems trivial to those who have been building sites for a long time, but I suck at it I got by in frontend by learning just enough to build what I needed at a given moment. So I’m writing the post I would have liked when I was trying to figure out a less-jarring way of letting readers navigate within a page.

As a developer from a “non-traditional” path, if I’ve found a concept awkward and have subsequently helped others with the same issue, it’s probably worth a write-up.

First, Just Show Me The Answer

Continue to use <a href="#targetId"…, but add an onclick listener to pass the event and the link itself into a handler function (e.g. <a href="#targetId" onclick="scrollToId(event, this)">, something like:

function scrollToId(event, anchor) {
  event.preventDefault();

  const hashTarget = anchor.getAttribute("href");
  const targetElement = document.querySelector(hashTarget);

  // Check if the user has requested reduced motion
  const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion)');

  if (targetElement) {
    targetElement.scrollIntoView({
      behavior: reducedMotionQuery.matches ? "auto" : "smooth"
    });
    // Update the URL in the address bar
    history.pushState(null, null, hashTarget);
  }
}

Technically you only need the event, since you can use event.currentTarget.getAttribute('href') for the hashTarget, but I find it clearer to pass the anchor.

Quick note: you could use ? for optional chaining, like targetElement?.scrollIntoView({ behavior: "smooth"});: basically this means “call scrollIntoView on targetElement if it is defined”. This avoids an error in case the element isn’t found, but because we also only want to call history.pushState if there’s a targetElement, it’s better to use an if block.

OK I’ve Got Time, Let’s Dive In

For longer webpages I’ve made, I wanted to use anchor elements to let readers navigate to the relevant sections within the page (I did know enough to know that the <a> thing was called an anchor element, and that it could be used for in-page as well as between-page navigation). However, immediate jumps around a page can be confusing/jarring, compared to an actual navigation where the user is moved to a fully new page. I actually often find it confusing for pages I myself have authored, let alone websites I'm not familiar with.

Let's first cover the “normal” way. To create a link on a page to another section in that page, we specify <a href="#some_interesting_element">, where the desired element, typically a header, has a matching ID, e.g. <h2 id="some_interesting_element">. We’ll not use any framework here:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Your Page Title</title>
    <style>
      .longdiv {
        background-color: orange;
        height: 120vw;
      }
    </style>
  </head>
  <body>
    <h1>This is a big page</h1>
    <p>
      Note: this page is an example from
      <a href="/posts/scroll-into-view">the scroll-into-view post</a>.
    </p>
    <p>You can see more information about <a href="#dogs">dogs</a> further down the page.</p>
    <div class="longdiv">
      <p>This div is made huge on purpose for scrolling to be relevant!</p>
    </div>
    <h2 id="dogs">Dogs</h2>
    <p>They’re just like us! Well, not quite.</p>
    <h2>Other Stuff</h2>
    <p>Cats? Sheep?</p>
    <div class="longdiv">
      <p>This div is also made huge on purpose for scrolling to be relevant!</p>
    </div>
  </body>
</html>

This example is live here, exactly as it’s written above , so you can see what I mean about the jump down the page when you click the “dogs” link.

Instead, we’d like the page to scroll down to the target location: this would make it clear how you got there. There’s a nice function built into browsers, scrollIntoView that can do this for us when we set the options to { behavior: "smooth" }. To call this function, I'll add this script into the <head>:

<head>
  <!-- skipping the rest of head here -->
  <script>
     function scrollToDogs() {
       const dogsHeader = document.getElementById("dogs");
       dogsHeader?.scrollIntoView({ behavior: "smooth"});
     }
 </script>
</head>

And we’ll replace the anchor link to #dogs with <button type="button" onclick="scrollToDogs()">dogs</button> in the first paragraph. You can see this version live too (assuming you don’t have Javascript disabled).

We’re close, but we’ve introduced some problems. As noted above, this button will do nothing if the reader has Javascript disabled (this isn’t that common, but we might as well solve for it). But more severely: it’s now semantically wrong , which is very bad for accessibility.

As a frontend noob, it took me a while to realise we can put a very similar onclick handler on the anchor link to begin with, with just a few minor adjustments. First, we need to prevent the browser’s default behaviour of an anchor link, which is to jump straight to the target: we use event.preventDefault() for this, so we’ll need to pass the event to the handler too. Second, one thing the very simple version gave us was that it actually changed the URL: it added #dogs to the end. This meant we could use the back button to get back to where we were, and e.g. we could send our current URL to a friend and know they’d end up at the same spot. We can use history.pushState to achieve exactly this.

Some users require or just prefer no motion, so we can check for their preference with window.matchMedia('(prefers-reduced-motion)');. If this matches, just use auto, the scrolling default.

We also want this functionality to be re-usable, not tied so directly to our dogs example. For that, we’ll extract the target location (the href) from the anchor, using this to pass the anchor itself to the function, and therefore switch to document.querySelector instead of document.getElementById. In total, we get:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Your Page Title</title>
    <style>
      .longdiv {
        background-color: orange;
        height: 120vw;
      }
    </style>
    <script>
      function scrollToId(event, anchor) {
        event.preventDefault()
        const hashTarget = anchor.getAttribute("href")
        const targetElement = document.querySelector(hashTarget)
        const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion)")
        if (targetElement) {
          targetElement.scrollIntoView({
            behavior: reducedMotionQuery.matches ? "auto" : "smooth",
          })
          history.pushState(null, null, hashTarget)
        }
      }
    </script>
  </head>
  <body>
    <h1>This is a big page</h1>
    <p>
      Note: this page is an example from
      <a href="/posts/scroll-into-view">the scroll-into-view post</a>.
    </p>
    <p>
      You can see more information about
      <a href="#dogs" onclick="scrollToId(event, this)">dogs</a> further down the page.
    </p>
    <div class="longdiv">
      <p>This div is made huge on purpose for scrolling to be relevant!</p>
    </div>
    <h2 id="dogs">Dogs</h2>
    <p>They’re just like us! Well, not quite.</p>
    <h2>Other Stuff</h2>
    <p>Cats? Sheep?</p>
    <div class="longdiv">
      <p>This div is also made huge on purpose for scrolling to be relevant!</p>
    </div>
  </body>
</html>

This version is also live : you can play around and see that we get everything we expect, with and without Javascript.

Adding onclick="scrollToId(event, this)" to every in-page link isn’t dreadful, but it’s annoying and error-prone if you have a ton of links. A better approach is to use a single script that automatically finds all in-page links and attaches the smooth-scrolling behavior. This keeps your HTML clean/simple.

Here is an example script that does that. You can place it just before your closing </body> tag or in the <head> with a defer attribute.

<script>
  // using DOMContentLoaded event to ensure all links are loaded before attaching listeners
  document.addEventListener('DOMContentLoaded', () => {
    // Find all links that start with '#' BUT ARE NOT EXACTLY '#'
    // (because `href="#"` usually is a placeholder, not pointing to a real target)
    const localLinks = document.querySelectorAll('a[href^="#"]:not([href="#"])');
    // Check the user's motion preference ONCE on page load for efficiency
    const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion)');
    const scrollBehavior = reducedMotionQuery.matches ? 'auto' : 'smooth';

    localLinks.forEach(anchor => {
      anchor.addEventListener('click', function (event) {
        event.preventDefault();

        const hashTarget = this.getAttribute('href');
        const targetElement = document.querySelector(hashTarget);

        if (targetElement) {
          targetElement.scrollIntoView({ behavior: scrollBehavior });
          history.pushState(null, null, hashTarget);
        }
      });
    });
  });
</script>

With this script on your page, your links can be simple again: <a href="#dogs">dogs</a>, and the scrolling will just work.

One of the advantages of a framework like React is that you can skip repetitive work by defining a new Link component and using that everywhere (also very useful for styling). In fact before I knew how regular JS worked (with the <script> tag etc), this was the only way I knew how to add event listeners. So we could do something like:

// within a React framework, in e.g. src/components/Link.jsx
function Link({ to, children }) {
  function handleClick(event) {
    event.preventDefault();
    const targetElement = document.querySelector(to);
    const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion)")
    if (targetElement) {
      targetElement.scrollIntoView({
        behavior: reducedMotionQuery.matches ? "auto" : "smooth",
      })
      history.pushState(null, null, to)
    }
  }

  return (
    <a href={to} onClick={handleClick}>
      {children}
    </a>
  );
}

This is a simplified example to illustrate the concept in React. In a real-world application using e.g. React Router or Next.js, you would typically use their built-in components and not manually call history.pushState, or maybe you’d give this component a name that signifies it’s only for in-page links, or only add the event listener if to starts with # etc.

We could potentially avoid any JS and instead add scroll-behavior: smooth; to the root CSS somewhere. This would be very nice if it could target only anchor clicks, but unfortunately it targets all programmatic scrolling, including in-page searching (like Ctrl/Cmd + F), which can be really irritating.

A bit overwrought, but if you’re anything like former Colman, this is the type of tutorial works best. Hopefully now your own readers can enjoy the benefits of default in-page behaviour, along with some nice scrolling.