Header

Scroll Into View: Navigating Within Pages

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

Published Date
Wed Jun 212023

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.

This is likely to be the first in a series of posts I'll do on “frontend for non-traditional paths”. Basically: if I’ve found something a bit awkward, and subsequently sent the same advice to multiple people, I should write it up.

First, Just Show Me The Answer

Continue to use <a href="#targetId"… but add an onclick listener approximately like this:

// optionally pass in the anchor
function scrollToId(event) {
  // use document.getElementById if you have the Id instead
  // (basically: the hashTarget without the `#`)
  const targetElement = document.querySelector(hashTarget);

  targetElement?.scrollIntoView({ behavior: "smooth"});

  // otherwise the URL won't change — we want the original plain <a> behaviour:
  history.pushState(null, null, hashTarget); // `#${id}` if you have the Id
  // need to avoid the regular link firing
  event.preventDefault();
}

Quick note: the ? is optional chaining: basically this means “call scrollIntoView on targetElement if it is defined”. This avoids an error in case the element isn’t found.

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

For longer webpages I had 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>
    <title>Your Page Title</title>
    <style>
     .longdiv {
         background-color: gray;
         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 all 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 have two 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.

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>
    <title>Your Page Title</title>
    <style>
     .longdiv {
         background-color: gray;
         height: 120vw;
     }
    </style>
    <script>
     function scrollToId(event, anchor) {
       const hashTarget = anchor.getAttribute("href");
       const targetElement = document.querySelector(hashTarget);
       targetElement?.scrollIntoView({ behavior: "smooth"});
       history.pushState(null, null, hashTarget);
       event.preventDefault();
     }
    </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 a bit annoying if you have a ton of links. It’s possible to run a script that attaches an event listener to all links that start with # — see “Full Code” section of this javascriptkit.com page.

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) {
    const targetElement = document.querySelector(to);
    targetElement?.scrollIntoView({ behavior: "smooth"});
    history.pushState(null, null, to);
    event.preventDefault();
  }

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

In practice, you’d probably either give this component a name that signifies it’s only for in-page links, or maybe you’d only add the event listener if to starts with #.

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 everything, including in-page searching, which is no good.

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.