Original: www.smashingmagazine.com/2021/07/dyn… Translated by wongtsuizhen

preface

Have you ever had a UI requirement that some component on a page needs to scroll to a threshold or go in or out of the viewport? In JavaScript, it is possible to listen for scrollbar events and then handle them in callbacks, but doing so can affect performance and, if not used properly, cause the page to stall. But a better implementation — the Intersection Observer — solves this problem.

The Intersection Observer API is a JavaScript API that lets you listen for an element and detect when it passes through a specified location in a scrollable container (usually, but not always, in the viewport) while firing a callback function.

Intersection Observer can be said to perform better than listening for scroll events on the main thread because it is asynchronous and the callback is only started when the element we are listening to reaches a specified location, rather than updating each scroll. In this article, we’ll take an example of using Intersection Observer to build a fixed header that changes when it intersects different parts of a web page.

The basic use

Intersection Observer With Intersection Observer, we first create an Observer that takes two arguments: the callback is the callback we want to execute when the listening element (called the target element) intersects the root element (the viewport, which must be the ancestor of the target element); Options indicates the configuration object (optional).

const options = {
  root: document.querySelector('[data-scroll-root]'),
  rootMargin: '0px'.threshold: 1.0
}

const callback = (entries, observer) = > {
  entries.forEach((entry) = > console.log(entry))
}

const observer = new IntersectionObserver(callback, options)
Copy the code

After we have created the observer instance, we also need to instruct it to monitor a target element:

const targetEl = document.querySelector('[data-target]')

observer.observe(targetEl)
Copy the code

Options are optional, and they have the following default values:

const options = {
  rootMargin: '0px'.threshold: 1.0
}
Copy the code

rootMargin

rootMarginValue is a bit like adding margin to the root element; like margin, it can accept multiple values, including negative values. The target element will be considered intersecting with respect to the boundary.

Scroll root with positive and negative margin values. Assuming a default threshold of 1, the orange square will be positioned at the “intersecting” point

This means that an element can technically be classified as “intersecting” even if it is not in the view (if the scrollroot is the viewport);

The orange square intersects the root, even if it’s outside the visible region.

RootMargin defaults to 0px, but can accept strings of multiple values, just as you would with the margin property in CSS.

threshold

Threshold can consist of a single value or an array of values between 0 and 1. This indicates that the ratio that must be within the root boundary to be considered intersecting is set to the default value of 1, and the callback will be triggered when 100% of the target elements in the root directory are visible.

A threshold of 1, 0, and 0.5 causes the callback to be triggered when the target element is 100%, 0%, and 50% visible, respectively.

It’s not always easy to categorize elements as visible using these options. So I’ve created a small tool to help you master the Intersection Observer.

Implementing dynamic headers

Now that we know the basics, let’s start implementing a dynamic header. We’ll start with a web page divided into sections. The following image shows the complete layout of the page we will create:

I show a full demo at the end of this article, so if you want to see the code directly, jump todemo. (Making the warehouse)

Each section has a minimum height of 100vh (they may be longer, depending on the content). Our title is fixed at the top of the page and stays in place as the user scrolls (using Position: fixed). Each section has a different colored background, and when they encounter a title, the title’s color changes to match the color of the current section. There is also a tag to show where the user is in the current section, which slides when the next section arrives. If you want to follow along, I’ll walk you through the small demo at the beginning of this article (before starting to use the Intersection Observer API) to get a quick and intuitive look at the code implementation.

Page production

We’ll start with the HEADER’s HTML. This will be a fairly simple page with a title, links, navigation, and nothing special about it, but we’ll mark the header itself with a few data attributes: data-header (we can target element JS), and three anchors that users click to scroll to the corresponding module are marked with data-link:

<header data-header>
  <nav class="header__nav">
    <div class="header__left-content">
      <a href="# 0">Home</a>
    </div>
    <ul class="header__list">
      <li>
        <a href="#about-us" data-link>About us</a>
      </li>
      <li>
        <a href="#flavours" data-link>The flavours</a>
      </li>
      <li>
        <a href="#get-in-touch" data-link>Get in touch</a>
      </li>
    </ul>
  </nav>
</header>
Copy the code

Next comes the HTML for the rest of the page, which we also divide into sections. For brevity, I’ve only included the parts that are relevant to this article, but the demo contains the whole thing. Each section contains a data attribute that specifies the name of the background color, as well as an ID corresponding to the href of the A tag in the title:

<main>
  <section data-section="raspberry" id="home">
    <! --Section content-->
  </section>
  <section data-section="mint" id="about-us">
    <! --Section content-->
  </section>
  <section data-section="vanilla" id="the-flavours">
    <! --Section content-->
  </section>
  <section data-section="chocolate" id="get-in-touch">
    <! --Section content-->
  </section>
</main>
Copy the code

We’ll use CSS to position the header so that it’s fixed at the top of the page when the user scrolls:

header {
  position: fixed;
  width: 100%;
}
Copy the code

We also set a minimum height for each of our secions and center the content. (This code is not required to use Intersection Observer; it is an optimization of the style.)

section {
  padding: 5rem 0;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

Copy the code

Alarms in IFRAME

While building this Codepen demo, I ran into a confusing problem: my Intersection Observer code, which should have worked perfectly, didn’t fire properly at the Intersection. Instead, it fired when the target element intersected the edge of the viewport. After some reflection, I realized that this is because in Codepen, the content is loaded in an iframe, which is handled differently. (See the section on clipping and intersection rectangles in the MDN documentation.)

As a solution, in the demo, we can wrap the tag in another element that acts as the root element in the scroll container (Intersection Observeroptions), rather than the browser viewport we expect:

<div class="scroller" data-scroller>
  <header data-header>
    <! --Header content-->
  </header>
  <main>
    <! --Sections-->
  </main>
</div>
Copy the code

If you want to learn how to use viewports as roots instead of the ones in demo, visit the Github repository.

CSS styles

In our CSS, we will define some custom properties for the colors we use. We will also define two additional custom properties for the title text and background color and set some initial values. (We will update these two custom properties later)

:root {
  --mint: #5ae8d5;
  --chocolate: #573e31;
  --raspberry: #f2308e;
  --vanilla: #faf2c8;
  
  --headerText: var(--vanilla);
  --headerBg: var(--raspberry);
}
Copy the code

We’ll use these custom attributes in the header:

header {
  background-color: var(--headerBg);
  color: var(--headerText);
}
Copy the code

We will also set the colors for the different parts. I used data attributes as selectors, or you can simply use classes.

[data-section="raspberry"] {
  background-color: var(--raspberry);
  color: var(--vanilla);
}

[data-section="mint"]  {
  background-color: var(--mint);
  color: var(--chocolate);
}

[data-section="vanilla"] {
  background-color: var(--vanilla);
  color: var(--chocolate);
}

[data-section="chocolate"] {
  background-color: var(--chocolate);
  color: var(--vanilla);
}

Copy the code

When each section is in the view, we can also set some styles for the headers:

/* Header */
[data-theme="raspberry"]  {
  --headerText: var(--raspberry);
  --headerBg: var(--vanilla);
}

[data-theme="mint"] {
  --headerText: var(--mint);
  --headerBg: var(--chocolate);
}

[data-theme="chocolate"]  {
  --headerText: var(--chocolate);
  --headerBg: var(--vanilla);
}

Copy the code

The data attribute is even more important here, because we’re toggling the header’s data-theme property every time we intersect.

Create the Observer

Now that we have the basic HTML and CSS for the page, we can create an observer to monitor each part of the view as it enters. We trigger a callback when we scroll down the page and a section touches the bottom of the header, which means we need to set a negative rootMargin corresponding to the height of the header.

const header = document.querySelector('[data-header]')
const sections = [...document.querySelectorAll('[data-section]')]
const scrollRoot = document.querySelector('[data-scroller]')

const options = {
  root: scrollRoot,
  rootMargin: `${header.offsetHeight * -1}px`.threshold: 0
}
Copy the code

We set a threshold of 0 because we want it to fire whenever any part of the section intersects the root boundary.

First, we’ll create a callback to change the header’s data-theme value (this is easier than adding and removing classes, especially if the header element may have applied other classes).

/* The callback that will fire on intersection */
const onIntersect = (entries) = > {
  entries.forEach((entry) = > {
    const theme = entry.target.dataset.section
    header.setAttribute('data-theme', theme)
  })
}
Copy the code

Then we’ll create an observer to listen for the intersecting parts:

/* Create the observer */
const observer = new IntersectionObserver(onIntersect, options)

/* Set our observer to observe each section */
sections.forEach((section) = > {
  observer.observe(section)
})
Copy the code

Now we should see our title color updated when each part meets the title.

Click to view the demo

However, you may notice that the color doesn’t update the color correctly as we scroll down. In fact, the title updates the color of the previous section each time. But when you scroll up, it works perfectly. So we need to determine the direction of the roll and change its behavior accordingly.

Find the direction of roll

We will set a variable in JS for the direction of the scroll, with an initial value of up, and another variable for the last known scroll position. Then, in the callback function, if the scroll position is greater than the previous value, we can set the orientation value to Down and vice versa.

let direction = 'up'
let prevYPosition = 0

const setScrollDirection = () = > {
  if (scrollRoot.scrollTop > prevYPosition) {
    direction = 'down'
  } else {
    direction = 'up'
  }

  prevYPosition = scrollRoot.scrollTop
}

const onIntersect = (entries, observer) = > {
  entries.forEach((entry) = > {
    setScrollDirection()
          
    / *... * /})}Copy the code

We’ll also create a new function to update the header color, passing the target part as an argument:

const updateColors = (target) = > {
  const theme = target.dataset.section
  header.setAttribute('data-theme', theme)
}

const onIntersect = (entries) = > {
  entries.forEach((entry) = > {
    setScrollDirection()
    updateColors(entry.target)
  })
}

Copy the code

So far, we should not see any change in header behavior. But now that we know the scrolling direction, we can pass in a different target for the updateColors() function. If the scrolling direction is up, we will use the entry target. If it’s downward, we’ll use the next section (if any).

const getTargetSection = (target) = > {
  if (direction === 'up') return target
  
  if (target.nextElementSibling) {
    return target.nextElementSibling
  } else {
    return target
  }
}

const onIntersect = (entries) = > {
  entries.forEach((entry) = > {
    setScrollDirection()
    
    const target = getTargetSection(entry.target)
    updateColors(target)
  })
}
Copy the code

However, there is a problem: The header is updated not only when the section reaches the header, but also when the next element at the bottom of the view enters the view. This is because our observer triggers two callbacks: one when the element enters and one when the element leaves.

To determine whether the header should be updated, we can use the isIntersecting key in the Entry object. Let’s create another function that returns a Boolean to determine whether the header color should be updated:

const shouldUpdate = (entry) = > {
  if (direction === 'down' && !entry.isIntersecting) {
    return true
  }
  
  if (direction === 'up' && entry.isIntersecting) {
    return true
  }
  
  return false
}

Copy the code

We’ll update the onIntersect() function accordingly:

const onIntersect = (entries) = > {
  entries.forEach((entry) = > {
    setScrollDirection()
    
    /* Do nothing if no need to update */
    if(! shouldUpdate(entry))return
    
    const target = getTargetSection(entry.target)
    updateColors(target)
  })
}
Copy the code

Our colors should now be updated correctly. We can set up a CSS transition animation to look better:

header {
  transition: background-color 200ms, color 200ms;
}
Copy the code

(Click to view demo)

Add dynamic markup

Next, we’ll add a tag to the title that will update its position as we scroll to different sections. We can do this using pseudo-elements, so we don’t need to add anything to the HTML. We’ll give it some simple CSS styles, position it in the top left corner of the header, and give it a background color. We use currentColor here because it will accept the value of the title text color:

header::after {
  content: ' ';
  position: absolute;
  top: 0;
  left: 0;
  height: 0.4 rem;
  background-color: currentColor;
}
Copy the code

We can use a custom attribute for the width, which defaults to 0. We will also use custom attributes for the Transform X value. We set these values in the callback function when the user scrolls.

header::after {
  content: ' ';
  position: absolute;
  top: 0;
  left: 0;
  height: 0.4 rem;
  width: var(--markerWidth, 0);
  background-color: currentColor;
  transform: translate3d(var(--markerLeft, 0), 0.0);
}
Copy the code

Now we can write a function to update the width and position of the marker at the intersection:

const updateMarker = (target) = > {
  const id = target.id
  
  /* Do nothing if no target ID */
  if(! id)return
  
  /* Find the corresponding nav link, or use the first one */
  let link = headerLinks.find((el) = > {
    return el.getAttribute('href') = = =` #${id}`
  })
  
  link = link || headerLinks[0]
  
  /* Get the values and set the custom properties */
  const distanceFromLeft = link.getBoundingClientRect().left
  
  header.style.setProperty('--markerWidth'.`${link.clientWidth}px`)
  header.style.setProperty('--markerLeft'.`${distanceFromLeft}px`)}Copy the code

We can call this function while updating the color:

const onIntersect = (entries) = > {
  entries.forEach((entry) = > {
    setScrollDirection()
    
    if(! shouldUpdate(entry))return
    
    const target = getTargetSection(entry.target)
    updateColors(target)
    updateMarker(target)
  })
}
Copy the code

We also need to set up an initial position for the tag so it doesn’t pop up. When the document is loaded, we’ll call the updateMarker() function, using the first part as the target:

document.addEventListener('readystatechange'.e= > {
  if (e.target.readyState === 'complete') {
    updateMarker(sections[0])}})Copy the code

Finally, let’s add a CSS transition so that the tag slides over the title from one link to the next. When we convert the width property, we can use will-change to make the browser perform optimization.

header::after {
  transition: transform 250ms, width 200ms, background-color 200ms;
  will-change: width;
}
Copy the code

Smooth scrolling

Finally, it would be nice if when users click on links, they smoothly scroll down the page instead of jumping to that section. For now we do it directly with CSS, no JS required! For a better experience, it is best to respect the user’s preferences and use smooth scrolling only if the user does not have animated fade enabled in the system Settings:

@media (prefers-reduced-motion: no-preference) {
  .scroller{ scroll-behavior: smooth; }}Copy the code

Complete demo

Put all of the above steps together and you have a complete demo. View the full Demo

Browser support

Intersection Observer is widely supported in modern browsers. It fills in for older browsers where needed — but I prefer to take a progressive enhancement approach. In our header example, providing a simple, unchanging version for unsupported browsers won’t do much harm to the user experience.

To check whether the Intersection Observer is supported, use the following method:

if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
  /* Code to execute if IO is supported */
} else {
  /* Code to execute if not supported */
}
Copy the code

resources

Read more about the Intersection Observer API:

  • MDN API
  • Intersection ObserverVisualization tool
  • Timing Element Visibility with the Intersection Observer API MDN How to use IO to track the Visibility of ads
  • Now You See Me: How To Defer, lazy-load And Act With IntersectionObsort-Denys Mishunov’s article covers some other uses of IO, including resource Lazy loading. It’s not as necessary now (thanks to the loading property), but there’s still a lot to learn.