loading...

My experience with IntersectionObserver, scroll snap and React

ruben_suet profile image Ruben Suet Originally published at rubensuet.com ・13 min read

It fascinates me how powerful but also how fragile JavaScript is. Until around 2010, no one would ever describe himself as a JS developer. Now-a-days thanks to node, the game changed and JS is one of the most used languages with every new iteration from TC39 making it stronger. To make it even more attractive, I think it's the only language that contains so many libraries/frameworks to work with it. Angular, Vue, React, Svelte, good old Jquery, Ember, Meteor, Stimulus, and so on... It's crazy.

And what do we do? We work with one of these libraries that makes our work easier and more productive, right? We became experts in that framework but forgot the initial abstraction, how Vanilla JS is handling everything behind the scenes. Today it's easier than ever to just download packages that do precisely what we are looking for, but we are not able to understand the non fancy code, the one coming from Vanilla JS.

And here comes the motivation behind my post: I want to talk about the Intersection Observer API. Going first through some pure JS examples, and how I later moved this code into a react environment. My intention is to explain my experience rather than have perfect code, so you might find some code that can be refactored along the way. Feel free to DM me @RubenSuet and I can double-check it 😊.

The use case

To understand why I need to play with some CSS rules or JS API, I need to explain what my issue was and how I started to tackle it. Currently I am a F.E dev in an E-commerce in the EU and like all of the e-shops in the world, the homepage consists mainly of getting the attention of the user, showing some products (maybe personalized, maybe not), and adding hot deals to make even more attractive the idea of buying some product.

And within all these deals and promotions, how can I display a big amount of products without overwhelming the user? With Carousels. They are ugly and are low performant but the user has control. If he wants to see more products he can. We are not forcing him to see all of them, just some of them, and it's up to them to keep clicking and interacting with the carousel to see more. On top of that, we have server-side rendering which means that the only way to make proper responsive pages are with pure CSS or guessing some User-Agent (this last one is pretty dangerous because you can never know which resolution is set).

And to top it all of, on mobile, there's one carousel that doesn't look at all like the one for Desktop. At this point you're guessing right: We are rendering multiple carousels for specific devices ( Sigh, I know. We are working on improving this which is also the reason why I am writing here: to share my progress). So, what can I do to perform better?

Some research

Let's check some E-commerce/marketplaces to see how they handle it. I did some research on several websites, but I would like to highlight 2 specific websites: Amazon and Zalando. Both have the same use case. You enter the website and they have something that looks like a carousel to show the products/categories. But it's not.
Amazon research Intersection Observer

Zaladon research Intersection Observer

Notice how they are working with a simple list and are achieving a perfect "Carousel". The user is not even noticing it and the performance is just great. So how do they do it? Checking a bit the code, in the ul element I found the following:

element.style {
1.  scroll-padding-left: 672px;
2.  scroll-behavior: smooth;
3.  scroll-snap-type: x mandatory;
}

 

AHA! I had heard about this but never needed to work with it. This is what is called snap-scroll. It lets you create the effect of scrolling where you can position the scroll in specific alignment from the item, making the effect of the carousel. Please check as a reference this article from Max Kohler and this other article from Robert Flack and Majid Valipour.

So I presented this articule to my team seeing if we could try to do something like the examples shown above. And then... my dreams were gone. The analytics person from my team explained to me that it's not possible to track which elements are visible at specific moments and to be honest, I hated that idea, but he had a point.

How can I make it possible... Before continuing I decided to procrastinate for a bit on twitter when suddently I saw that @meduzen had posted exactly what I was looking for. He played with the Intersection observer to make sure that when an element is visible, it toggles a class and does a CSS animation. That's perfect. If I can toggle a class, I can for sure trigger a callback and make the analytics work, can't I?

More research on the internet showed me how to use the Intersection Observer (I'll post all my references at the end of this post), but none of them were tackling it in React. The only thing I found was an npm package, but this was exactly the intro of my post and before I use a solution that is already built, I wanted to understand the real abstraction and how it works. So I wanted to do it by myself, without dependencies to other libraries that do all the magic without you knowing what is going on.

Building my component with scroll-snap and Intersection Observer

First I'll show what I built and then I'll break it into smaller pieces. Here's a picture showing what I want to accomplish:

Goal for my Post

So, I have some sections, and when I am scrolling, I want to console log in which section I am at that specific moment. Here is my pure react component to achieve it:

//CarouselScroller.tsx
import  *  as  React  from  "react";
import  {  Section,  LightSection,  Container  }  from  "./CarouselScroller.styled";
const  CarouselScroller:  React.FC<{}>  =  ()  =>  {
    return  (
    <Container>
        <Section  color="#134611">
            Section 1
        </Section>
        <Section color="#3E8914">
            Section 2
        </Section>
        <Section color="#3DA35D">
            Section 3
        </Section>
        <LightSection color="#96E072">
            Section 4
        </LightSection>
        <LightSection color="#E8FCCF">
            Section 5
        </LightSection>
    </Container>
    <button  onClick={() =>  setCount(count + 1)}> Re-render</button>
    );
};

CarouselScroller.displayName  =  "CarouselScroller";
export  default  CarouselScroller;

 

I used styled components and made the <Container> the <Section> and the <LightSection>

// CarouselScrollect.tyled.ts
import  styled  from  "styled-components";

const  Container  =  styled.div`
`;

const  Section  =  styled.div<{ color:  string  }>`
    background:  ${props  =>  props.color};
    min-width:  70vw;
    height:  30vh;
    color:  white;
    display:  flex;
    align-items:  center;
    justify-content:  center;
    font-size:  28px;
`;

const  LightSection  =  styled(Section)`
    color:  #1f2d3d;
`;

export  {  Container,  Section,  LightSection  };

 

With these 2 files. I got exactly what you saw in the previous gif. However, it is still lacking the scroll snap. Let's add it

// CarouselScrollect.tyled.ts
import  styled  from  "styled-components";

const  Container  =  styled.div`
    scroll-snap-type:  x  proximity;
    display:  flex;
    overflow-x:  scroll;
`;

const  Section  =  styled.div<{ color:  string  }>`
    scroll-snap-align:  center;
// Other properties non related with scroll-snap
`;

 

  • scroll-snap-type: You need to specify how it locks into the viewport when it scrolls. That usually is the parent component that wraps the children to make the effect and is the first rule you need to specify to use the snap module. Usually, you can specify the axis x or y and choose as a second option which kind of 'lock' you want to use. There are 3 properties:
    • none: You scroll normally, it doesn't force the scroll to anchor specifically at some point of the element
    • proximity: When scrolling, between 2 elements it can force to anchor into one specific element.
    • mandatory: The most strict option. It forces always to anchor the element where you align (is the option that the children have. We'll discuss it in a moment. I like this option if you want to do the effect of parallax or close it as a carousel.
  • Scroll-snap-align: Where do you want to align the item when it locks the scroller. The image from Robert Flack and Majid Valipour explains well this concept. I upload it here, but please remember this picture belongs to them ( and therefore they deserve to be referenced)

Alignment between elements with scroll snap

I will provide a sandbox link with the working project, feel free to do some playgrounds with the options. In the end, a picture is worth a thousand words.

Time to play with Intersection observer. Some Vanilla JS to make it work before we go. This is how it looks:

let observer = new IntersectionObserver(callback, options)

 

Check out we need a callback and options. Let's start with the last one since it's the easier one:

let options = {
    root: null // relative to document viewport
    rootMargin: '0px'// margin around root. Values are similar to CSS property. Unitless values not allowed
    threshold: 1.0 // visible amount of item shown concerning root
}

 

I tried to explain in the code itself what every option does but mainly you can specify another root ( like #gallery), the rootMargin if it needs a margin to start with and last (and I would say the most important one) but not least the threshold. How much of the item needs to be shown to trigger your callback with values from 0 (hidden) to 1 (fully shown). In my case it needs to be fully visible.

Let's check the callback

let callback = (entries, observer) {
    for(let entry of entries) {
        if (entry.intersectionRatio  >=  1) {
            console.log('I am visible!')
        }
    }
}

Please, note this is a simplified version just for learning purposes. You can fulfill it with any logic you want.

  • entries are going to be an array of IntersectionObserverEntry(thanks TS to help to put a name on this). And that's when I iterate it, you have the property intersectionRatio which is the one that determines if it's visible or not ( again, the value goes from 0 to 1).

Connecting the options and the callback you can notice that we specified a threshold of 1 and we check in the callback if this true, and if it is, then we do log it.

If you want to know more about IntersectionObserver check out the MDN docs. Apart from triggers for when elements are visible, IntersectionObserver lets you do lazy loading, infinite scrolling fetching new data, between others. Mainly it reacts when an element is in focus of the user. Cool API to be honest and I am not sure how I went so far without having the necessity to work with it.

At the end you can observer elements like

const images = document.querySelector("img")
for(let image of images) {
    observer.observe(image)
}

 

In this example, the observer reacts for all images in your document and does whatever you need to do.

Going back into the react component. Let's make it step by step:

const  CarouselScroller:  React.FC<{}>  =  ()  =>  {
    const  refs  =  React.useRef<HTMLDivElement[]>([]);
    const  observer  =  React.useRef<IntersectionObserver>(null);
    const  addNode  = (node:  HTMLDivElement)  =>  refs.current.push(node);

    React.useEffect(()  =>  {
        if  (observer.current)  observer.current.disconnect();

        observer.current  =  new  IntersectionObserver(handler,  options);
        for  (const  node  of  refs.current)  {
            observer.current.observe(node);
        }
        return  ()  =>  observer.current.disconnect();
    },  []);

    return  (
        <Container>
            <Section  ref={addNode} color="#134611">
                Section 1
            </Section>
            <Section  ref={addNode} color="#3E8914">
                Section 2
            </Section>
            <Section  ref={addNode} color="#3DA35D">
                Section 3
            </Section>
            <LightSection  ref={addNode} color="#96E072">
                Section 4
            </LightSection>
            <LightSection  ref={addNode} color="#E8FCCF">
                Section 5
            </LightSection>
            </Container>
    );
};

 

Step by step:

    const  refs  =  React.useRef<HTMLDivElement[]>([]);
    const  observer  =  React.useRef<IntersectionObserver>(null);
    const  addNode  = (node:  HTMLDivElement)  =>  refs.current.push(node);

 

Notice that I made it in TS (If you feel uncomfortable, just remove the <>). So first I create an array of refs. Here I want to store the html elements to observe afterwards. Then I create another ref for the observer. Without refs, a new IntersectionObserver would be created for every re-render, and I don't want that. At the end a quick method to push the refs into the array I declared before. And this is how I store them in the return method:

<Section  ref={addNode} color="#134611">
    Section 1
</Section>

 

So with that, I can have all my refs stored. Now let's check my useEffect.

React.useEffect(()  =>  {
    if  (observer.current)  observer.current.disconnect();

    observer.current  =  new  IntersectionObserver(handler,  options);
    for  (const  node  of  refs.current)  {
        observer.current.observe(node);
    }
    return  ()  =>  observer.current.disconnect();
},  []);

 

It's important to wrap it in a useEffect to make sure it will only be rendered JUST after the component is mounted. Otherwise, you won't have the refs. The first thing I do inside is to check if I have already an observer. In the case of true, then I use the disconnect method. This method lets me 'unobserve' all elements we were observing. So this is a kind of 'reset' to start again and observe again, in case we already had an observer.

Afterward, we create the observer with a handler and options, and we iterate all those refs to be observed. Notice that I return a method to make sure that I disconnect as well when this component is unmounted. If you are confused returning a method inside a use effect, check this article from React docs.

Let me show you my handler and options:

const  handler  =  (
    entries:  IntersectionObserverEntry[],
    observer:  IntersectionObserver
)  =>  {

    for  (const  entry  of  entries)  {
        if  (entry.intersectionRatio  >=  1)  {
            console.log("i Am visible",  entry.target.textContent);
        }
    }
};

const  options  =  {
    root:  null,
    rootMargin:  "0px",
    threshold:  1.0
};

 

And with that.... MAGIC, we got it! Together, dear reader, we achieved the goal I set at the beginning of this section!

But wait... there's a couple of gotchas and refactoring to do. Checking the react docs we find the following FAQ. It seems our intersection observer is creating it every time we re-render, and we don't want that. So we can refactor it to create the ref as a lazy load. The following snippet is just with the necessary changes to achieve it:

const  getObserver  =  (ref:  React.MutableRefObject<IntersectionObserver  |  null>)  =>  {
    let  observer  =  ref.current;
    if  (observer  !==  null)  {
        return  observer;
    }
    let  newObserver  =  new  IntersectionObserver(handler,  options);
    ref.current  =  newObserver;
    return  newObserver;
};

const  CarouselScroller:  React.FC<{}>  =  ()  =>  {
    const  observer  =  React.useRef<IntersectionObserver>(null);
    React.useEffect(()  =>  {
        if  (observer.current)  observer.current.disconnect();
        const  newObserver  =  getObserver(observer);

        for  (const  node  of  refs.current)  {
            newObserver.observe(node);
        }
        return  ()  =>  newObserver.disconnect();
    },  []);
    return (...)
}

 

I presented const observer = React.useRef<IntersectionObserver>(null); but when I was doing some playgrounds, I did const observer = React.useRef<IntersectionObserver>(new IntersectionObserver(handler,options));. That it's causing a new object every render, and therefore some performance errors.

Another gotcha well pointed coming from @aytee17 is that, for every render, we'll call the ref callback in the return method, and it will start to increase dramatically my array of references. The ref callback is being triggered twice: once when it's mounted into the DOM, and another when it's being removed from the DOM( it calls the callback, but the ref holds as null value instead of the HTML element). In short words: My first render my array will have 5 elements ( 5 sections that I add in this example), if I force a re-render, I will have 15 elements:

  • 5 HTML elements from my first render and added into the DOM
  • 5 nulls from when the elements were removed from the DOM
  • 5 elements from the new re-render that are added into the DOM

So, my proposal, it's to wrap the addNode method into a useCallback. If you are wondering what it does, here I tried to explain it with my own words.

My final result:

import  *  as  React  from  "react";
import  {  Section,  LightSection,  Container  }  from  "./App.styled";

const  handler  =  (
    entries:  IntersectionObserverEntry[],
    observer:  IntersectionObserver
)  =>  {
    for  (const  entry  of  entries)  {
        if  (entry.intersectionRatio  >=  1)  {
            console.log("i Am visible",  entry.target.textContent);
        }
    }
};

const  options  =  {
    root:  null,
    rootMargin:  "0px",
    threshold:  1.0
};

const  getObserver  =  (ref:  React.MutableRefObject<IntersectionObserver  |  null>)  =>  {
    let  observer  =  ref.current;
    if  (observer  !==  null)  {
        return  observer;
    }
    let  newObserver  =  new  IntersectionObserver(handler,  options);
    ref.current  =  newObserver;
    return  newObserver;
};

const  CarouselScroller:  React.FC<{}>  =  ()  =>  {
    const  [count, setCount] =  React.useState(0);
    const  refs  =  React.useRef<HTMLDivElement[]>([]);
    const  observer  =  React.useRef<IntersectionObserver>(null);
    const  addNode  =  React.useCallback(
        (node:  HTMLDivElement)  =>  refs.current.push(node)
    ,[]);
    // ref callback is called twice: once when the DOM
    //node is created, and once (with null) when the DOM
    //node is removed.
    // TRY IT OUT => Comment the other addNode and uncomment this one
    //const addNode = (node: HTMLDivElement) => refs.current.push(node);

    React.useEffect(()  =>  {
        if  (observer.current)  observer.current.disconnect();
        const  newObserver  =  getObserver(observer);

        for  (const  node  of  refs.current)  {
            newObserver.observe(node);
        }
        return  ()  =>  newObserver.disconnect();
    },  []);

    console.log("render",  refs);
    return  (
        <React.Fragment>
            <Container>
                <Section  ref={addNode} color="#134611">
                    Section 1
                </Section>
                <Section  ref={addNode} color="#3E8914">
                    Section 2
                </Section>
                <Section  ref={addNode} color="#3DA35D">
                    Section 3
                </Section>
                <LightSection  ref={addNode} color="#96E072">
                    Section 4
                </LightSection>
                <LightSection  ref={addNode} color="#E8FCCF">
                    Section 5
                </LightSection>
            </Container>
            <button  onClick={() =>  setCount(count + 1)}> Re-render</button>
        </React.Fragment>
    );
};



CarouselScroller.displayName  =  "CarouselScroller";

export  default  CarouselScroller;

 

I added a useState to force a re-render. I added as well comment and a proposal, so you can see by yourself the problem to not wrap the method with useCallback

Check the code working in Sandbox

It's been an exciting journey to feel more confident with IntersectionObserver, and to document all my steps and how it helped me be more confident with it. My next steps are to generate these effects with real products and start and apply them to my job.

Code is just code, you won't harm anyone. So don't be afraid to go and understand the abstraction. If something is not clear, don't be afraid to ask the internet how to tackle it, and I encourage you to document it and explain it as well.

References for this post

Practical CSS SCroll Snapping

Well-Controlled Scrolling with CSS Scroll Snap

How to do scroll-linked animations the right way

Creating a RevealJS clone with CSS Scroll Snap Points

Intersection Observer: Track elements Scrolling Into View

How To Use an IntersectionObserver in a React Hook

See the original post at my blog suetBabySuet

Posted on by:

ruben_suet profile

Ruben Suet

@ruben_suet

Digital builder ⚒️. React developer . How would you explain JS to your grandma? 🦸‍♂️Attempting to make code accessible.

Discussion

markdown guide