Use framer-motion & IntersectionObserver to build scroll animations

Scroll-based animations can be triggered in two ways - by listening to the scroll event or by using IntersectionObserver. In this article, we will go through step by step process of building the scroll animation using IntersectionObserver. It's no fun to hard-code a React component, hence, we will create a reusable React component. A reusable React component can help us to animate different types of content - images, text or both as shown in the GIF below.

The React component API

A <div> that animates its opacity, top, left, right and bottom CSS properties and consequently all the child elements inside it. The React component API will look as shown below:

The execution plan

  1. Create an Atri project if you don't have an existing one

  2. Write a React component that can take children

  3. Wire up IntersectionObserver on the <div>

  4. Wire up framer motion

  5. Wire custom values in the visual editor

    Repo here - https://github.com/shubhambargal2002/Vancouver-Website

Create Atri app

You can create an Atri app that helps you create beautiful websites like this one. Run the following command to create a new project.

npx @atrilabs/new

Create a Flex React component

Since we are using the same animation in many instances throughout the website, it makes sense to create a reusable component that can take child elements and animate the opacity and top property of the entire container. Hence, we will create a reusable component that can be drag-drop in the visual editor and this component can take children. For this, we need to create a React component that takes props.children. For some basic information on how to create a drag-drop React component, read the docs here.

import React from "react";

const IntersectionAnimationFlex = React.forwardRef<
    HTMLDivElement,
    { children: React.ReactNode[]; styles: React.CSSProperties }
>((props, ref) => {
    return (
        <div ref={ref} style={{ display: "flex", ...props.styles }}>
            {props.children}
        </div>
    );
});

export default IntersectionAnimationFlex;

Next, we need to create a manifest that provides some metadata to the visual editor to understand the behavior of the component. For example, the manifest tells the visual editor what are the props this component can take.

import { createComponentManifest } from "@atrilabs/utils";

export default createComponentManifest({
    name: "IntersectionAnimationFlex",
    acceptsChild: "flex", // layout component like a display: flex
});

The default dimension of <div> element (in most cases) is height=0 and width=100% . The height=0 implies that we won't get enough room to drop another component inside this Flex component. Hence, we need a way to provide some height when it doesn't contain any children.

import IntersectionAnimationFlex from "./IntersectionAnimationFlex";
import React from "react";

const DevIntersectionAnimationFlex: typeof IntersectionAnimationFlex =
    React.forwardRef((props, ref) => {
        const overrideStyles =
            props.children.length === 0 // no children
                ? {
                        height: "100px",
                        border: "1px dashed black",
                  }
                : props.styles;
        return (
            <IntersectionAnimationFlex
                ref={ref}
                {...props}
                styles={overrideStyles}
            />
        );
    });

export default DevIntersectionAnimationFlex;

Wire up IntersectionObserver

const IntersectionAnimationFlex = React.forwardRef<
    HTMLDivElement,
    { children: React.ReactNode[]; styles: React.CSSProperties }
>((props, ref) => {
    const internalRef = useRef<HTMLDivElement>(null);

    const observer = useMemo(() => {
        if(typeof window !== "undefined") { // IntersectionObserver is only available inside browser and throws error in NodeJS
            return new IntersectionObserver(
                (entries) => {
                    entries.forEach((entry) => {
                        if (entry.isIntersecting) {
                            // animation start
                        }
                    });
                },
                { threshold: 0.4 }
            );
        }
    }, []);

    useEffect(() => {
        if (internalRef && internalRef.current && observer) {
            observer.observe(internalRef.current);
        }
    }, []);

    // @ts-ignore
    useImperativeHandle(ref, () => {
        return internalRef.current;
    });

    return (
        <div ref={internalRef} style={{ display: "flex", ...props.styles }}>
            {props.children}
        </div>
    );
});

export default IntersectionAnimationFlex;

Wire up framer motion

import React, { useEffect, useMemo, useImperativeHandle, useRef } from "react";
import { motion, useAnimationControls } from "framer-motion";

const IntersectionAnimationFlex = React.forwardRef<
    HTMLDivElement,
    { children: React.ReactNode[]; styles: React.CSSProperties }
>((props, ref) => {
    const internalRef = useRef<HTMLDivElement>(null);

    // animation
    const controls = useAnimationControls();
    const variations = {
        initial: { opacity: 0, top: 32 },
        inView: { opacity: 1, top: 0 },
    };

    const observer = useMemo(() => {
        return new IntersectionObserver(
            (entries) => {
                console.log(entries);
                entries.forEach((entry) => {
                    if (entry.isIntersecting) {
                        // animation start
                        controls.start("inView");
                    }
                });
            },
            { threshold: 0.4 }
        );
    }, [controls]);

    useEffect(() => {
        if (internalRef && internalRef.current) {
            observer.observe(internalRef.current);
        }
    }, []);

    // @ts-ignore
    useImperativeHandle(ref, () => {
        return internalRef.current;
    });

    return (
        <motion.div
            ref={internalRef}
            style={{ ...props.styles, display: "flex", position: "relative" }}
            variants={variations}
            initial="initial"
            animate={controls}
        >
            {props.children}
        </motion.div>
    );
});

export default IntersectionAnimationFlex;

Wire custom values

import { createComponentManifest } from "@atrilabs/utils";

export default createComponentManifest({
    name: "IntersectionAnimationFlex",
    acceptsChild: "flex",
    custom: {
        initialTop: { type: "number" },
        initialLeft: { type: "number" },
        initialRight: { type: "number" },
        initialBottom: { type: "number" },
        finalTop: { type: "number" },
        finalLeft: { type: "number" },
        finalRight: { type: "number" },
        finalBottom: { type: "number" },
        initialOpacity: { type: "number" },
        finalOpacity: { type: "number" },
        threshold: { type: "number" },
    },
    initalCustomValues: {
        initialTop: 32,
        initialOpacity: 0,
        finalTop: 0,
        finalOpacity: 1,
        threshold: 0.5,
    },
});
import React, { useEffect, useMemo, useImperativeHandle, useRef } from "react";
import { motion, useAnimationControls } from "framer-motion";

const IntersectionAnimationFlex = React.forwardRef<
    HTMLDivElement,
    {
        children: React.ReactNode[];
        styles: React.CSSProperties;
        custom: {
            initialTop?: number;
            initialLeft?: number;
            initialRight?: number;
            initialBottom?: number;
            finalTop?: number;
            finalLeft?: number;
            finalRight?: number;
            finalBottom?: number;
            initialOpacity?: number;
            finalOpacity?: number;
            threshold?: number;
        };
    }
>((props, ref) => {
    const internalRef = useRef<HTMLDivElement>(null);

    // animation
    const controls = useAnimationControls();
    const variations = {
        initial: {
            opacity: props.custom.initialOpacity ?? 0,
            top: props.custom.initialTop || 32,
            left: props.custom.initialLeft,
            right: props.custom.initialRight,
            bottom: props.custom.initialBottom,
        },
        inView: {
            opacity: props.custom.finalOpacity ?? 1,
            top: props.custom.finalTop || 0,
            left: props.custom.finalLeft,
            right: props.custom.finalRight,
            bottom: props.custom.finalBottom,
        },
    };

    const observer = useMemo(() => {
        return new IntersectionObserver(
            (entries) => {
                console.log(entries);
                entries.forEach((entry) => {
                    if (entry.isIntersecting) {
                        // animation start
                        controls.start("inView");
                    }
                });
            },
            { threshold: props.custom.threshold ?? 0.4 }
        );
    }, [controls, props.custom.threshold]);

    useEffect(() => {
        if (internalRef && internalRef.current) {
            observer.observe(internalRef.current);
        }
    }, []);

    // @ts-ignore
    useImperativeHandle(ref, () => {
        return internalRef.current;
    });

    return (
        <motion.div
            ref={internalRef}
            style={{ ...props.styles, display: "flex", position: "relative" }}
            variants={variations}
            initial="initial"
            animate={controls}
        >
            {props.children}
        </motion.div>
    );
});

export default IntersectionAnimationFlex;