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
Create an Atri project if you don't have an existing one
Write a React component that can take children
Wire up IntersectionObserver on the
<div>
Wire up framer motion
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;