photo of night sky over mountains
Photo by Pawel Nolbert on Unsplash

Scroll-Linked Content Reveal Animation

Learn how to create a scroll-linked animation with Framer Motion.

9 min read

In this article, you'll learn how to create a scroll-linked animation with Framer Motion.

Using the iPhone 14 Pro landing page as inspiration, we'll be building an animation similar to the “chip reveal” effect seen below.

iphone 14 scroll animation example
iphone 14 scroll animation example

Getting Started

Begin by installing the latest version of framer-motion in your React project. The version used while writing this article was v7. Make sure you’re also using at least React 17.

Spinning up a Codesandbox instance can be a quick way for you to get started.

Now let’s see how the animation will work.

For this type of animation, the first thing we need is the users scroll position.
The code we write is contained in a React component, so the scroll position should be in the context of that component, and not the entire page. This allows us to have multiple types of different animations on one page if we wanted to, each contained in their own section.

diagram of element movement
diagram of element movement


After we have the scroll position, we can move some elements across the screen, each at a slightly different speed and starting point.

Fortunately, there’s a hook included with Framer Motion, useScroll, that provides the values we need to get started.

Component Markup

Let’s start writing some code. First, create a new file named ScrollAnimation.js and inside the file, add a React component with the same name.


import { useRef } from 'react';
import { motion, useScroll } from 'framer-motion';

export const ScrollAnimation = () => {
  const containerRef = useRef(null);

  return (
    <section className="scroll-container" ref={containerRef}>

    </section>
  )
}

In the component, import motion and useScroll from framer-motion, then useRef from react.

Next, a container element is needed. This element is what triggers the animation when scrolled through, and it’s also where the scroll values come from. We need a reference to it named containerRef.

Then we have some copy and an image. The copy should be short enough to fit underneath the image, but can otherwise be anything.

The copy will be inside a div with a className of copy, and the image within a div of img-container. More on the actual image element in a second.


const ScrollAnimation = () => {
	...

  return (
    <section className="scroll-container" ref={containerRef}>
      <div className="copy">
        <h2>Lorem ipsum dolor sit amet</h2>
      </div>
      <div className="img-container">

      </div>
    </section>
  )
}

Finally we need to add some motion components for the elements that are animating.

We won’t cover much about the motion component here, but check out the docs, and these past articles to learn more - Getting Started with Framer Motion, Animated Tabs with Framer Motion


<div className="img-container">
  <motion.div className="img-inner">
    <motion.div className="bottom-shadow" />
    <img src={plant1} alt="a green plant" />
    <motion.div className="top-shadow" />
  </motion.div>
</div>

An img-inner div will be moved horizontally to the right based on the scroll position. Within this are two additional divs that are the shadows above and below the image: top-shadow and bottom-shadow. All three of these elements should be referenced as motion.div because they will receive translateX values to move them across the page as you scroll.

The image element itself doesn’t need to be set as a motion component because it moves along with the img-inner component. However, the bottom and top shadows should be motion components because they’ll have different translateX values in order to achieve the parallax-type of animation. This will be more obvious in the next section.

Basic Styling

Before we get any further, let’s add the layout styling. Create a new file named styles.css.

First, some styling for the basic HTML elements.


body {
  background: #000;
  color: #fff;
}

img {
  max-width: 700px;
  width: 100%;
  height: auto;
	position: relative;
  z-index: 1;
}

section {
  min-height: 100vh;
}

Next, we’ll add a basic flexbox layout to have the image beside the heading copy.


.scroll-container {
  display: flex;
  justify-content: space-between;
  max-width: 1200px;
  margin: 100vh auto;
  /* This is needed to prevent the image from "jittering" during
  the transition */
  overflow: hidden;
}

.img-container {
  width: 50%;
  display: flex;
  align-items: center;
  position: relative;
}

.copy {
  display: flex;
  align-items: center;
  flex-direction: column;
  justify-content: center;
  width: 40%;
  font-size: 24px;
  margin-left: 5%;
  text-align: left;
}

.copy h2 {
  font-size: 70px;
  margin-top: 0;
}

The 100vh margin on the scroll-container is to make sure there’s enough scrollable area above and below the component to see the animation.

Last, we have some styling for the top and bottom shadows. We’re intentionally setting the background color of the bottom shadow to blue and the top shadow to red to better visualize the next few sections.


.bottom-shadow {
  background: red;
  height: 100%;
  width: 100%;
  position: absolute;
  left: 0;
  z-index: 0;
}

.top-shadow {
  background: blue;
  height: 100%;
  width: 140%;
  position: absolute;
  left: 0%;
  top: 0;
  z-index: 2;
}

Don’t forget to import the CSS file we just added into the ScrollAnimation component.


import './styles.css';

const ScrollAnimation = () => { ... }

The useScroll Hook

Now that we have some styles in place, let’s discuss the Framer Motion useScroll hook and how to use it.

From the Framer Motion docs:

useScroll is used to create scroll-linked animations, like progress indicators and parallax effects.

We use this hook to get the scrollYProgress value that it returns.

First, we pass the containerRef we added earlier as the target.


const ScrollAnimation = () => {
  const containerRef = useRef(null);

  const { scrollYProgress } = useScroll({
    target: containerRef,
  });

	return ( ... );
}

Next, we need an offset value.

offset is an array of at least two intersections. From the Framer Motion docs, an intersection describes a point when the target and container meet. So for example, "start end" means when the start of the target meets the end of the container.

The offset value is the same as the demo code in the docs: ["start end", "end end"]. This occurs when the start of the target meets the end of the container.


const { scrollYProgress } = useScroll({
  target: containerRef,
  offset: ["start end", "end end"]
});

There are several different types of values you can pass in the scroll offset array. Strings (like above), numbers (like 0 or 1), or pixel values. Check out the docs for more info.

Now that we have the scrollYProgress value, let’s apply it as a style property to the bottom-shadow element.


<div className="img-container">
	<motion.div className="img-inner">
	  <motion.div
	    className="bottom-shadow"
	    style={{ translateX: scrollYProgress }}
	  />
	  <img src={plant1} alt="a green plant" />
	  <motion.div className="top-shadow" />
	</motion.div>
</div>

If you scroll the page with your browser devtools open, you should see the transform: translateX() value updating between 0px and 1px depending on which direction you’re scrolling. Nothing is actually moving on the page yet though because this is such a small value.

When passing property values to motion components, you should pass it shorthand like translateX instead of transform: translateX(). Framer Motion takes care of the rest for you.

Transforming Scroll Values

So we have a scrollYProgress value between 0px and 1px, but what do we do with that?

Framer Motion includes a hook specifically to transform one set of values into another: useTransform.

useTransform is the primary way to compose chains of motion values. That is, to take the output of one motion value, change it some way, and then output that as a new motion value. - useTransform docs

useTransform is perfect for this scenario because now we can take scrollYProgress and turn it into a percentage. Then we can apply this percentage to the motion component elements.

To use the useTransform hook, pass the scrollYProgress as the first argument. Since we know the range from scrollYProgress is between 0 and 1, the next argument is an array with that range.


import { motion, useScroll, useTransform } from 'framer-motion';

const ScrollAnimation = () => {
  ...
	const bottomShadowValue = useTransform(scrollYProgress, [0, 1], ['-100%', '0%']);

	return ( ... )
}

The last argument we pass is the range that’s output from the hook. In this case, we want a percentage starting at -100% and ending at 0%.

Now apply the transformed bottomShadowValue value to the bottom shadow element.


<div className="img-container">
	<motion.div className="img-inner">
	  <motion.div
	    className="bottom-shadow"
	    style={{ translateX: bottomShadowValue }}
	  />
	  <img src={plant1} alt="a green plant" />
	  <motion.div className="top-shadow" />
	</motion.div>
</div>

As you scroll, the red bottom shadow should be moving from left to right.

bottom shadow animation
bottom shadow animation


Now that we have this working, transforming the values for the image and the top shadow are very similar.

For the image animation, we want to work in a range of -100% and 0%. The imageValue constant should then be applied to the img-inner motion component.


const ScrollAnimation = () => {
  ...
	const imageValue = useTransform(scrollYProgress, [0, 1], ['-100%', '0%']);

	return (
		<div className="img-container">
			<motion.div className="img-inner" style={{ translateX: imageValue }}>
			  ...
			</motion.div>
		</div>
	);
}

Finally, for the top shadow, the range is -25% and 100%. The movement of the top shadow doesn’t extend as far to the left as the other elements, but it should cover the image completely, and move past the right edge of the screen. This is also why we set the width of the top-shadow element to 140% in our CSS earlier.


const ScrollAnimation = () => {
  ...
	const topShadowValue = useTransform(scrollYProgress, [0, 1], ['-25%', '100%']);

	return (
		<div className="img-container">
			<motion.div className="img-inner" style={{ translateX: imageValue }}>
			  ...
			  <motion.div className="top-shadow" style={{ translateX: topShadowValue }} />
			</motion.div>
		</div>
	);
}

Now all three of the motion components should animate as we scroll up or down through the section.

the bottom and top shadow
the bottom and top shadow


Finishing Touches

We’re almost done! Now that the scroll animation is working, let’s replace the background colors and use gradients for each shadow.


.bottom-shadow {
  background: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%);
  ...
}

.top-shadow {
  background: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 25%);
  ...
}

Considering Accessibility

Scroll animations like these can definitely add a spark of joy for your users and make “flat” content feel a bit more interesting. But we should have alternatives in place for users that would prefer not to see animations.

Apple handles this in their animation effect by suppressing it completely when the Reduced Motion setting is enabled (Windows has a similar setting), and we can do the same thing easily.

To do so, there’s one last hook that Framer Motion provides that we can use - useReducedMotion.

To learn more about the topic of accessibility and animations, be sure to check out Reducing Motion in Animations.

useReducedMotion returns true or false, depending if the user has Reduced Motion settings enabled.

First, import the hook and set the boolean it returns to a constant named shouldUseReducedMotion.


import { motion, useScroll, useTransform, useReducedMotion } from 'framer-motion';

const ScrollAnimation = () => {
  ...
  const shouldUseReducedMotion = useReducedMotion();

	return (...);
}

On the img-inner motion component, we’ll only use imageValue if shouldUseReducedMotion is false, otherwise translateX is set to 0. This prevents any animation.


<motion.div
	className="img-inner"
	style={{ translateX: shouldUseReducedMotion ? 0 : imageValue }}
>
  ...
</motion.div>

Finally, we only need the shadow motion components if we show the animation. If not, then we can make a few code changes to prevent them from rendering.


<motion.div
	className="img-inner"
	style={{ translateX: shouldUseReducedMotion ? 0 : imageValue }}
>
  {!shouldUseReducedMotion && (
    <motion.div className="bottom-shadow" style={{ translateX: bottomShadowValue }} />
  )}
  <img src={plant1} />
  {!shouldUseReducedMotion && (
    <motion.div className="top-shadow" style={{ translateX: topShadowValue }} />
  )}
</motion.div>

Now the animation will only run if the user doesn’t have Reduce Motion enabled, and we don't bother rendering elements that aren't necessary.

Conclusion

You just learned how to build a scroll-linked animation similar to the effect seen on the iPhone 14 Pro landing page.

Framer Motion is the tool of choice in this series, though this same effect could have been achieved in many other ways.

The useScroll, useTransform and useReducedMotion React hooks provided by Framer Motion were especially handy so that we didn’t have to do any manual calculations and could keep our component as accessible as possible.