Intersection API in React

As a web developer we know how important it is to provide a professional UI for the users of your application. A big part of that is the ability to take different actions based on what the user is currently seeing.

This could be an infinite scroll when the user reaches the bottom of a list of items you load the next ones in advance. Lazy loading images is another great example - no need to load something and risk a performance hit if the user never gets to see it anyway.

Those are the most common practices used today there are many other use cases that depend on the business logic of your application. For example, you may want a simple animation to occur when an element enters the browser's viewport to demo the Intersection API.

The Intersection Observer

The Intersection Observer API allows us to watch a certain element and run a callback when its visibility changes in relation to the view port or another element.

We can specify an intersection ratio to tell the Intersection Observer what percentage of your element needs to be visible in order to trigger the callback. This must be a value between 0 and 1.

Another configuration option that needs to be specified when you initialise the Intersection Observer is the root. This is the element in relevance to which we will be watching for intersections. Most of the time this will be the full view port which means that we need to pass null as a value.

We can initialise an Intersection Observer which will run a callback every time our target is intersecting in the view port as this:

// Create and initialize an observer  
const observer = new IntersectionObserver(([entry]) => {
  // is run when DOMElement match the thresholds
  callback(entry)
}, {
  root: null, // use the browsers viewport
  rootMargin: '0px',
  threshold: thresholds // thresholds for the observer to detect
})

// Start observing the dom element
observer.observe(DOMElement)

To specify the element that we actually want to watch we need to use the observe method and pass it a DOM element.

When an element that meets the threshold requirements and enters the view port it will run the callback provided when the Intersection Observer was initialised and, it will pass the callback an array of entries. In the code above we are only interested in the first element.

Each element in the array passed to the callback has the following shape:

let callback = (entries, observer) => { 
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

In our example below, we are going to use the intersectionRatio property to get the intersection percentage and use it to animate the opacity or width of an element entering the viewport. We implement the observer in the element itself and thus we need only the first member of the array.

Intersection in React

How do we use the Intersection Observer in an application built with React? Let us first creating our boxes this demo is going to use:

import styled from "styled-components";

// Generic box styles
const Box = styled.div`
  position: relative;
  background-color: ${({ backgroundColor }) => backgroundColor || "black"};
  align-items: center;
  color: white;
  display: flex;
  font-size: 1.5rem;
  justify-content: center;
  margin: 1rem 0;
  min-height: 40vh;
`;

// FadeBox
export const FadeBox = styled(Box)`
  opacity: ${({ ratio }) => ratio || 1};
  width: 50%;
`;

// WidthBox
export const WidthBox = styled(Box)`
  width: ${({ ratio }) => `${ratio * 100}%`};
`;

We are using styled-components and define two simple boxes, FadeBox and WidthBox, both of them allows us to dynamically change their styles with a ratio property to create some animation effect.

Now, let's create an intersection box, that will render either the WidthBox or the FadeBox:

import React from 'react'

// Import the two boxes
import { FadeBox, WidthBox } from './Boxes'

// Array containing both box types
const compToRender = [WidthBox, FadeBox]

/**
 * Box using intersection API to alter the styles
 * of the rendered boxes when the boxes enter the viewport
 * @param {number} boxType - number that match the index of the compToRender array
 * @param rest
 * @returns {*}
 * @constructor
 */
function IntersectBox({ boxType, ...rest }) {
  // Which box type to render
  const Component = compToRender[boxType]

  return (
    <Component {...rest} >
      ...
    </Component>
  )
}

export default IntersectBox

In my last article, I wrote about Refs in React.js and now we need that feature in order to tell the Intersection Observer we are going to create to start observe our DOM element.

// Use React's hook useRef to hold a 
// reference to a DOM element
import React, { useRef } from 'react'
// Import the two boxes
import { FadeBox, WidthBox } from './Boxes'

// Array containing both box types
const compToRender = [WidthBox, FadeBox]

/**
 * Box using intersection API to alter the styles
 * of the rendered boxes when the boxes enter the viewport
 * @param boxType
 * @param rest
 * @returns {*}
 * @constructor
 */
function IntersectBox({ boxType, ...rest }) {
  // Ref for DOM element to observe
  const targetRef = useRef(null)  
  // Which box type to render
  const Component = compToRender[boxType]

  return (
    <Component ref={targetRef} {...rest} >      ...
    </Component>
  )
}

export default IntersectBox

Next, we need to add the Intersection Observer and to do that we can use React's useEffect hook. The useEffect hook lets us perform side effects in our function component. The useEffect hook is designed to run after every render of the component and, we can use it to create and initialize our Intersection Observer logic.

It's also important to know the useEffect hook has a dependency array we can pass into the hook itself and if the dependencies are the same as the last time it was executed, it will not run again. it seems perfect to pass a ref to the target DOM Element as a dependency.

It's important to know that React doing a shallow match when checking if the dependencies array has changed or not.

In other words, we want to run the useEffect only after the first render to set up the Intersection Observer.

We also need to store the intersection information in the local state to trigger re-renders and passing in the intersectionRatio value to our boxes and, we use React's hook - useState for this.

To provide the thresholds we create a function that by default builds an array from 0 - 1 and, we can pass in how many steps we want in between.

Below is our Intersection component and, I have tried to document inline what's going on.

// useRef to get a reference to the DOM element being watched
// useEffect to set up the intersection Observer logic
// useState to trigger re-renders - animation of the boxes
import React, { useRef, useEffect, useState } from 'react'

// Import the two boxes
import { FadeBox, WidthBox } from './Boxes'

// Array containing both box types
const compToRender = [WidthBox, FadeBox]

// Build threshold array to be used
function buildThresholdList(numSteps = 10) {
  let thresholds = []

  for (let i = 1.0; i <= numSteps; i++) {
    let ratio = i / numSteps
    thresholds.push(ratio)
  }

  thresholds.push(0)
  return thresholds
}

/**
 * Box using intersection API to alter the styles
 * of the rendered boxes when the boxes enter the viewport
 * @param boxType
 * @param rest
 * @returns {*}
 * @constructor
 */
function IntersectBox({ boxType, ...rest }) {
  // Ref for DOM element to observe
  const targetRef = useRef(null)
  
  // state management for intersection information
  const [entry, setEntry] = useState({})

  // Run this effect only once, we are passing 
  // in targetRef in the dependencies array  
  useEffect(() => {

    // Create a new observer
    const observer = new IntersectionObserver(([entry]) => {
      // our callback - update local state with
      // intersection entry information and triggers
      // a re-render
      setEntry(entry)
    }, {
      root: null,
      rootMargin: '0px',
      threshold: buildThresholdList()
    })

    // Start observe the target
    if (targetRef) {
      observer.observe(targetRef.current)
    }

    // Clean up the observer on un-mounting
    return () => observer.disconnect()
  }, [targetRef])

  // Which box type to render
  const Component = compToRender[boxType]

  return (
    <Component ref={targetRef} ratio={entry.intersectionRatio} {...rest} >
      intersectionRatio: {format(entry.intersectionRatio)}
    </Component>
  )
}

export default IntersectBox

That's it, if you want to run a demo of this, you can create the following component to wrap everything up.

import React from "react";

// Import intersection box
import IntersectBox from './IntersectBox'

/**
 * Build an array with hex colors to apply as background colors in the boxes
 * @param quantity
 * @returns {string[]}
 */
const buildHexArray = quantity =>
  Array.from(Array(quantity).keys(), i =>
    Number((i + 1) * 100).toString(16).padStart(3, "0")
  );

/**
 * Intersection API Demo
 * @returns {*}
 * @constructor
 */
function IntersectionDemo() {
  return (
    <div className="App">
      <h1>Intersection API Example</h1>
      <h2>Start scrolling!</h2>
      {Array.from(Array(50).keys(), i => (
        <br key={i} />
      ))}
      {buildHexArray(8).map((n, i) => (
        <IntersectBox boxType={i % 2} key={n} backgroundColor={`#${n}`} />
      ))}
    </div>
  );
}

export default IntersectionDemo

Published: 2019-12-09
Author: Henrik Grönvall
Henrik Grönvall
Copyright © 2022 Henrik Grönvall Consulting AB