DEV Community

Cover image for How to fade in content as it scrolls into view
selbekk
selbekk

Posted on • Edited on • Originally published at selbekk.io

How to fade in content as it scrolls into view

Today, I want to show you a technique for displaying content in a nice and nifty way - by fading it in as it shows up!

The fady slidy part 🎚

Let's start with specifying the CSS required. We create two classes - a fade-in-section base class, and a is-visible modifier class. You can - of course - name them exactly what you want.

The fade-in-section class should hide our component, while the is-visible class should show it. We'll use CSS transitions to translate between them.

The code looks like this:

.fade-in-section {
  opacity: 0;
  transform: translateY(20vh);
  visibility: hidden;
  transition: opacity 0.6s ease-out, transform 1.2s ease-out;
  will-change: opacity, visibility;
}
.fade-in-section.is-visible {
  opacity: 1;
  transform: none;
  visibility: visible;
}
Enter fullscreen mode Exit fullscreen mode

Here, we use the transform property to initially move our container down 1/5th of the viewport (or 20 viewport height units). We also specify an initial opacity of 0.

By transitioning these two properties, we'll get the effect we're after. We're also transitioning the visibility property from hidden to visible.

Here's the effect in action:

Looks cool right? Now, how cool would it be if we had this effect whenever we scroll a new content block into the viewport?

The showy uppy part πŸ‘‹

Wouldn't it be nice if an event was triggered when your content was visible? We're going to use the IntersectionObserver DOM API to implement that behavior.

The IntersectionObserver API is a really powerful tool for tracking whether something is on-screen, either in part or in full. If you want to dig deep, I suggest you read this MDN article on the subject.

Quickly summarized, however, an intersection observer accepts a DOM node, and calls a callback function whenever it enters (or exits) the viewport. It gives us some positional data, as well as nice-to-have properties like isIntersecting, which tell us whether something is visible or not.

We're not digging too deep into the other cool stuff you can do with intersection observers in this article though, we're just implementing a nice "fade in on entry"-feature. And since we're using React, we can write a nice reusable component that we can re-use across our application.

Here's the code for implementing our component:

function FadeInSection(props) {
  const [isVisible, setVisible] = React.useState(true);
  const domRef = React.useRef();
  React.useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => setVisible(entry.isIntersecting));
    });
    observer.observe(domRef.current);
    return () => observer.unobserve(domRef.current);
  }, []);
  return (
    <div
      className={`fade-in-section ${isVisible ? 'is-visible' : ''}`}
      ref={domRef}
    >
      {props.children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And here's a sandbox implementing it:

If you're looking for a copy and paste solution - here you go.

What's happening - step by step

If you want to understand what's happening, I've written a step-by-step guide below, that explains what happens.

First, we call three built in React Hooks - useState, useRef and useEffect. You can read more about each of these hooks in the documentation, but in our code we're doing the following:

  1. Create a state variable indicating whether the section is visible or not with useState. We default it to false
  2. Create a reference to a DOM node with useRef
  3. Create the intersection observer and starting to observe with useEffect

The setup of the intersection observer might look a bit unfamiliar, but it's pretty simple once you understand what's going on.

First, we create a new instance of the IntersectionObserver class. We pass in a callback function, which will be called every time any DOM element registered to this observer changes its "status" (i.e. whenever you scroll, zoom or new stuff comes on screen). Then, we tell the observer instance to observe our DOM node with observer.observe(domRef.current).

Before we're done, however, we need to clean up a bit - we need to remove the intersection listener from our DOM node whenever we unmount it! Luckily, we can return a cleanup function from useEffect, which will do this for us.

That's what we're doing at the end of our useEffect implementation - we return a function that calls the unobserve method of our observer. (Thanks to Sung Kim for pointing this out to me in the comment section!)

The callback we pass into our observer is called with a list of entry objects - one for each time the observer.observe method is called. Since we're only calling it once, we can assume the list will only ever contain a single element.

We update the isVisible state variable by calling its setter - the setVisible function - with the value of entry.isIntersecting. We can further optimize this by only calling it once - so as to not re-hide stuff we've already seen.

We finish off our code by attaching our DOM ref to the actual DOM - by passing it as the ref prop to our <div />.

We can then use our new component like this:

<FadeInSection>
  <h1>This will fade in</h1>
</FadeInSection>

<FadeInSection>
  <p>This will fade in too!</p>
</FadeInSection>

<FadeInSection>
  <img src="yoda.png" alt="fade in, this will" />
</FadeInSection>
Enter fullscreen mode Exit fullscreen mode

And that's how you make content fade in as you scroll into the view!

I'd love to see how you achieve the same effect in different ways - or if there's any way to optimize the code I've written - in the comments.

Thanks for reading!

A final note on accessibility

Although animation might look cool, some people have physical issues with them. In their case, animations is detrimental to the user experience. Luckily, there's a special media query you can implement for those users - namely prefers-reduced-motion. You can (and should!) read more about it in this CSS Tricks article on the subject.

Top comments (25)

Collapse
 
dance2die profile image
Sung M. Kim • Edited

Thank you for the post, @selbekk~

For completeness, one can unobserve the ref in FadeInSection on unmount.

  React.useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        console.log(`entry`, entry, `is = ${entry.isIntersecting}`);
        setVisible(entry.isIntersecting);
      });
    });

    const { current } = domRef;
    observer.observe(current);

    //                      πŸ‘‡ 
    return () => observer.unobserve(current);
  }, []);
Enter fullscreen mode Exit fullscreen mode

I wasn't aware of this unobserve until running into the issue recently when I implemented my sticky components using IntersectionObserver, which had a memory leak.

error

Here is the fork with unobserve & "unmount" button.

Collapse
 
selbekk profile image
selbekk • Edited

Ah that’s true - forgot about that one! I’ll update the example later today to include it (with credit given, of course)

Edit: Updated the post.

Collapse
 
dance2die profile image
Sung M. Kim

Thank you 🀜

Collapse
 
_kushagra profile image
Kushagra 🎈

To make it run only once the following works, thanks op for the amazing tutorial

function FadeInSection(props) {
        const [isVisible, setVisible] = React.useState(false);
        const domRef = React.useRef();
        React.useEffect(() => {
            const observer = new IntersectionObserver(entries => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        setVisible(entry.isIntersecting);
                    }
                });
            });
            observer.observe(domRef.current);
            return () => observer.unobserve(domRef.current);
        }, []);
        return (
            <div
                className=className={`fade-in-section ${isVisible ? 'is-visible' : ''}`}
                ref={domRef}
            >
                {props.children}
            </div>
        );
    }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
amiangie profile image
am i angie?

This is a great post, but gods I hate this effect, it makes page unsearchable when very often Ctrl + F is the quickest method to find something.

Collapse
 
selbekk profile image
selbekk

That’s true - but there are ways around that. Semantically, the content is there - so just skipping the visibility setting would enable search

Collapse
 
amiangie profile image
am i angie?

I've yet to see this implemented properly in the wild :)

Collapse
 
victoruvarov profile image
Victor Uvarov

How can you get this to work with TypeScript? React.useRef() needs a type. Not sure what type the dom ref is. Any ideas?

Collapse
 
selbekk profile image
selbekk

HTMLDivElement?

Collapse
 
victoruvarov profile image
Victor Uvarov

Thanks I figured it out already. There were 3 problems.

  • useRef() should be initialized with null and typed.
const domRef = useRef<HTMLDivElement>(null);
  • the ref will be null for a second, so I just needed to have a local var in the first lines of the effect
const current = domRef.current;
if (!current) return;
Collapse
 
eerk profile image
eerk

Great example, especially since the so-called "simple example" on MDN is actually really complicated!

I'm still wondering why you are creating a new IntersectionObserver() for each component? In most online examples the observable elements are added to one single IntersectionObserver:

let observer = new IntersectionObserver(...)
let mythings = document.querySelectorAll('thing')
mythings.forEach(thing => {
    observer.observe(thing)
})
Collapse
 
selbekk profile image
selbekk

Hi!

I could've created a single intersection observer too, but to be honest it doesn't matter too much. If you have hundreds of things you want to fade in - sure, optimize it. I think the way I wrote it is a bit easier to understand from a beginner's point of view.

Collapse
 
rickgove profile image
Rick Gove • Edited

How would I reverse this effect once it's about to leave the viewport?

I have it disappearing, but without any effects this way:

 .fade-out-observe {
    width: 100%;
    position: absolute;
  }



function vh() {
    var h = Math.max(
      document.documentElement.clientHeight,
      window.innerHeight || 0
    );
    return h;
  }

useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => setVisible(entry.isIntersecting));
    });
    observer.observe(fadeOutRef.current);
    return () => observer.unobserve(fadeOutRef.current);
  }, []);


 <div
        ref={fadeOutRef}
        className="fade-out-observe"
        style={{ top: vh() * 0.4 }}
      />

Enter fullscreen mode Exit fullscreen mode
Collapse
 
ginniecodes profile image
Jhinel Arcaya

Thank you for this post!
Very useful, I didn't know about IntersectionObserver, more awesome features to take a look at.

Collapse
 
chrissavoie profile image
Chris Savoie

I'd encourage testing this with heatmapping tools like Hotjar. I had to eliminate something similar because it presented Hotjar from being able to screenshot a full page to lay the heatmap data over top.

Collapse
 
selbekk profile image
selbekk

That’s true - I think you could add some workarounds to make it work regardless though. If I ever end up using hoyjar, I’ll update the article with my findings

Collapse
 
cheerupemodev profile image
Jess Rezac

Thanks for including the accessibility note!

Collapse
 
chrisachard profile image
Chris Achard

Huh, neat. Thanks for the post! I like the use of hooks too :)