isPublished: true title: Building a Scroll-Linked Slideshow with Motion for React description: Let's explore how to use scroll-linked animations in combination with position sticky to create a slideshow with Motion for React. date: '2024-12-11' categories:
- react
- motion for react
- framer motion
Building a Scroll-Linked Slideshow with Motion for React
What We're Building
This article walks through a scroll-linked slideshow demo I made with React and Framer Motion, now known as Motion for React.
Our slideshow consists of a left column of slide content in the normal document flow and a right column with a position sticky
image viewer. Images animate in and out based on the currently scrolled slide content.
Our images will animate in and out of the vertical center of the viewport. These animations will be linked to scroll progress – specifically, when the corresponding slide content enters and leaves the center of the viewport.
We'll create an offet during the transition between slides so that one slide gently fades in as the other is still exiting.
We're also going to make an animated counter that shows the active slide number.
Let's jump into it!
Project Setup
This demo is built with NextJS. I'm familiar with Next and like it for small demos and hobby projects.
The project uses Tailwind for styles and includes some basic setup for linting and formatting.
Getting up and running with the demo takes just a couple minutes.
Simply clone the repo and run npm i && npm run dev
if you want to follow along with the finished version.
No worries if you'd like to use the demo code and you're partial to plain ol' React or another framework. The only Next component used in this demo is the <Image>
component. You can simply replace this with an <img>
tag or component of your choosing.
Most of the relevant code is found in app/page.tsx
.
Scaffolding the UI
First, we'll create two elements inside the same position relative
container.
We'll add a position absolute
element. This will contain a position sticky
element and right column to animate our slide images.
Then we'll add an element containing a left column to display our slide content in the normal document flow.
Some shared containerClasses
styles provide a max width container around the respective grid layouts containing each of our columns.
Our initial JSX looks something like this.
Let's set up some data to represent our slides.
For each slide, we'll add a title and some content along with our image. Note we varied the content length of a couple slides to ensure our solution takes this into account.
We'll also define some sensible default slide styles. Later, we'll animate these styles based on scroll progress.
Let's add the slides to our JSX.
You may have noticed we introduced a <SlideImage>
component.
I used Next's Image component and added a little loading blur transition.
I also sized and cropped our images with a 1:1 aspect ratio.
Here's our <SlideImage>
component.
At this point we have a left column displaying our content, but our right column is missing some styles.
For one thing, the position relative
element – the one directly wrapping our slides – needs an appropriate height value.
We also need to account for vertically centering our sticky
image viewer by giving it an appropriate top
value.
For this, we'll need to know our image dimensions.
Getting Dimensions with ResizeObserver
We'll plan to let our image take up the full width of our column.
I've used an image aspect ratio of 1:1 to make our math a bit easier, so our column width will always equal our image height.
This allows us to measure our column width to derive our image height. We'll also need to respond to changes in column width when the window resizes.
The common approach here is a hook relying on ResizeObserver.
You can write your own hook for this or use one from a library like the useResizeObserver hook from usehooks-ts as I've done here.
Using the hook is as simple as assigning a ref
to one of our columns and calling the hook to get the width.
The imageHeight
returned from our hook will be undefined
until our column dimensions can be measured. I'm simply assigning the ref
to our left column and then waiting to render the elements on the right until our image height is defined. This way we won't be doing unnecessary rerenders just to get our initial styles.
We'll define a new <ImageViewer>
component to illustrate this.
We've used viewport units to set the top of our position sticky
container to 50vh
minus half our image height.
This offset puts the center of our image exactly at the vertical center of the viewport.
Then, we can simply assign a height
of imageHeight
to the position relative
wrapper around our images to ensure our images always take up the intended height.
Introducing Motion
Alright. We're building a scroll-linked animation. Let's finally work on the scroll and animation parts.
We're going to use Motion's useScroll
hook.
The useScroll
hook returns motion values. These automatically tracked values allow us to easily trigger events and compose new values. We can also use those values to directly style the DOM.
Tracking Page Scroll
Let's start in our parent component.
The useScroll
hook tracks page scroll by default. We won't use page scroll for tracking the progress of each slide – we'll track specific elements for that – but page scroll will still come in handy later in the demo.
Let's set that up now.
That was easy!
scrollYProgress
gives us a value between 0 and 1 as the page scrolls.
Tracking Scroll Direction
We're going to need scroll direction when we build our animated counter to show the active slide. This will allow us to animate the slide numbers up or down depending on the current direction.
To track scroll direction, we need to get the difference between the current and previous scrollYProgress
every time it changes.
We could also do this using scrollY
from useScroll
, using absolute pixels rather than a value from 0 to 1. Either works.
We'll use Motion's useMotionValueEvent
hook to tell us when scrollYProgress
changes.
We'll use React state to continually track the last scroll value and use it to get the new diff on every scroll change.
You can also check out this interactive example of tracking scroll progress and direction to see the below code in action.
State Management
We're already tracking page scroll, but our image animations need to respond to the scroll progress of each slide.
Specifically, we want our images to enter as we start scrolling the slide content and exit when we finish.
In between enter and exit – that is, while the slide is actively being scrolled – we simply want to show the current image.
We're animating our slides independently based on their slide content, but we also have this notion of the active slide used by our animated counter – so, how are we going to manage this?
First, let's look at setting up some React state for tracking and updating these values.
Recall the slidesData
we set up previously. Let's initialize some state with this data to begin tracking our slides.
Now, let's add some state to track the active slide.
Handling Updates to State
Our <ImageViewer>
component needs our slides
state in order to apply the latest scroll-linked styles to our images.
An individual slide's styles will update only when the relevant slide content is being scrolled.
We'll add a <ContentSection>
component to do just that.
We'll pass our <ContentSection>
a couple handlers from our parent component so it can perform any necessary state updates.
The first handler will take care of updating the slides
state with the slide's latest styles.
Another will simply set the activeSlideID
state using the ID of the new active slide.
Let's take a look at passing these handlers to our new <ContentSection>
component, replacing what we had previously.
Now, in addition to the slide
itself, we're passing the slides
and activeSlideID
state.
This allows <ContentSection>
to access the scroll progress of other slides in order to make logical comparisons to determine whether to set the activeSlideID
.
We also pass the handlers handleSetSlides
and handleSetActiveSlideID
so that <ContentSection>
can actually perform those updates.
Next, we'll build out the new <ContentSection>
component.
Tracking Slide Content Scroll Progress
Earlier, in our parent component, we called useScroll
to track the page scroll.
Now, we'll look at tracking specific elements.
We can pass useScroll
an element's ref
to track its scroll.
Let's see what this looks like by extending our previous markup and using it in the new <ContentSection>
component.
Nice! Now we're tracking scroll progress of the slide content.
But what defines the start and end of scroll?
These are determined by the offset
and container
options of useScroll
.
You can read more about the offset
option to learn what values it supports and how it works with respect to the target
and container
options.
For our purposes, we'll start by describing our offset as starting when the top of our target
element – the slide content – enters the center of the viewport and finishing when the bottom of the slide content leaves.
We can do this by passing ['start 50vh', 'end 50vh']
as our offset
.
Keep in mind there is no right answer here. We're guiding the user through our intended scroll-linked experience. You might choose different values or altogether different strategies depending on what you want to build.
Image Enter and Exit Animations
Right now we're tracking our slide content as discrete sections. Scroll progress of one slide ends right before another starts.
However, we mentioned earlier that we want the entering slide to fade in while the previous slide is still exiting, creating a subtle transition between slides. So, we might need to switch up our approach.
One other consideration is how the current viewport dimensions and slide content height impact our animations.
As the viewport width shrinks, our sections become taller and the relative difference in height between sections of varying content length is exaggerated.
Animations driven by scroll progress are are sensitive to those changes in height – the taller the content section, the taller the scrollable area used for that animation.
This could be desirable in some cases. However, in our case, we don't want the image animation for one slide to start earlier or later than another based on differences in slide content height.
Rather, we want the timing of our our subtle transition between slides to be consistent.
We can do this by tracking only how the top and bottom of our slide content intersect the viewport.
We're going to add two fixed height elements that essentially bracket the top and bottom of the content section.
These will be position absolute
elements we can manipulate relative to the top
and bottom
of our slide content, creating our own offset of sorts to represent one slide bleeding into another as it enters and exits.
We've set up some constants to compose the styles of our new elements. We can use these as levers to dial in the desired overlap and scrollable area used in our animations.
One thing to note is that we're using absolute pixels to compose our image animations. This isn't a concern for demonstration purposes. However, understanding the relationship between our responsive image size and the absolute values used to compose our animations is important when considering different screen sizes and devices, since the overlap between slides during transition will decrease as the viewport width – and therefore our image width – decreases.
Now that we've got a nice mechanism for composing our animations, it's time to start turning our scroll progress into styles.
This is where Motion's useTransorm
comes in handy.
Creating Scroll-Linked Styles
We talked earlier about how we can compose motion values from other motion values and then use them to style the DOM. We'll use useTransform
to do this.
Recall that scrollYProgress
gives us a motion value between 0 and 1.
useTransform
lets us create value mappings to describe mapping one motion value to another.
We'll use this to map both topYProgress
and bottomYProgress
to new respective motion values to describe appropriately animating the opacity of our image.
Opacity
Let's start with topYProgress
.
The value mapping above describes a new motion value that maps from 0 to 1 as the top of the slide content is scrolled, with the exception that the first slide always maps to 1. This just ensures we don't start with a completely blank right column before the user starts scrolling the slideshow.
The logic for bottomOpacity
is the inverse. The new motion value maps from 1 to 0 as the bottom of the slide content is scrolled with the exception that the last slide always maps to 1. This ensures the final image remains visible after the user has finished scrolling past the slideshow.
Next, we can create a unified opacity
motion value by taking the min of topOpacity
and bottomOpacity
– since whichever of topYProgress
or bottomYProgess
is actively scrolling will always be the lowest opacity.
Anytime both topOpacity
and bottomOpacity
are 1 this means the user is between the top and bottom – in other words, simply in the middle of scrolling the slide content. The resulting opacity
value of 1 in this scenario simply indicates that the image should remain fully visible.
Scale
The opacity
motion value gives us a value between 0 and 1. We can transform this value just like we did with scrollYProgress
.
We'll repeat this pattern of composing styles from other styles in order to orchestrate our animations.
This time rather than value mapping we'll simply pass useTransform
a function.
Our scale will always be between 0.75 and 1. Our formula determines how rapidly the scale accelerates between those bounds as opacity
changes. We use opacity.get()
to ensure we always use the current opacity
in the formula.
The current formula results in a relatively slow increase in scale as the image is first scrolled into view with a more rapid increase in scale as the image becomes fully visible.
This effect – assuming a steady scroll velocity – makes the image seem to snap into place as it approaches filling the width of the column.
Position
We're going to use useTransform
to create a new motion value used to animate translateX
to move the slide image left or right on enter and exit. We'll pass in a function just like we did for scale
.
Our function determines the offset distance to the left or right for even and odd slides, respectively – this describes how far and in what direction the image can possibly move away from center during animation.
Then, we add to this offset a value that returns toward the center origin as opacity
approaches 1 – this describes the image starting from our offset and approaching the fully visible state, centered inside the column.
Triggering State Updates
Right now, we're tracking the scroll of the top and bottom of our slide content since those scrollable areas are relevant for our animations.
However, we haven't considered how motion values respond to changes in velocity. A rapidly changing motion value indicates to Motion that less frequent updates to the value itself are required in order to accurately track the movement.
This is important since the user could scroll quickly out of the slide, resulting in a stale motion value. We'll address this by tracking a larger scrollable surface area – the page.
We're already tracking page scroll using scrollYProgress
in our parent component. Let's simply pass this as props to our ContentSection
component and trigger state updates when scrollYProgress
changes.
Updating Parent Component State
We're now automatically tracking scroll-linked styles via the motion values we created. We've also set up scrollYProgress
as a useful trigger for state updates.
Now, let's finally wire this up to perform the updates using the handlers we passed down from our parent component.
We're going to move our state updates into a function that handles our updates to both the slides
and activeSlideID
state and call it when scrollYProgress
changes.
handleSetSlides
simply passes along the latest scroll-linked styles.
We also set the activeSlideID
by comparing the opacity
of this slide with the currently active slide – the most visible slide should always be considered active.
Animating Images with Motion values
Now that we're continuously updating the slides
state on scroll, let's use those values to animate our images inside the <ImageViewer>
component.
We can replace the previous div
wrapping our image with a motion component by importing motion
and using motion.div
.
This lets us animate the element directly via the style
prop. Motion provides some enhanced style
props for transforms, including the x
used for our translateX
transform.
Building the Animated Counter
We'll make our new animated counter sticky
so it always displays alongside our images.
We're going to work within the existing position sticky
container in the <ImageViewer>
component and offset our animated counter to horizontally center it in the viewport – evenly spaced between the content on the left and the images on the right.
Let's look at adding some layout for the counter inside the existing <ImageViewer>
component.
Our new elements work in combination to create an offset based on our grid column gap styles, resulting in a 32 pixel square in the center of the viewport.
We've added bg-blue-500
just as a temporary background color. We're going to animate this color later.
To make the slide numbers animate in and out of the counter, we need a few things first.
Getting the Active Slide and Direction
Recall that we're calling <ImageViewer>
in our parent component and passing it the following props.
Now, let's pass it the direction
and activeSlideID
as well.
Motion Component Props
We'll take advantage of animation props to orchestrate the animations in our counter.
Rather than continuously animating via the style
prop like we did with the images, we'll be animating to and from distinctly defined visual states, or variants.
First, we'll turn the elements we want to animate into motion components with motion.div
.
Next, we'll pass animation props to define our animations. This includes using Motion's custom
prop, which allows us to pass custom data we can leverage to create dynamic visual states.
Let's start by creating the background color animation we mentioned earlier, replacing the current placeholder bg-blue-500
.
The initial
and animate
props take animation targets defined in our variants
. These represent the initial and active visual state of our component.
Let's define the variants activeNumberBackgroundVariants
and transition activeNumberBackgroundTransition
introduced above.
We've defined a list of colors representing each slide.
The activeNumberBackgroundVariants
defines an initial inactive
visual state. This is just a default for when the component first renders.
The active
visual state changes the background color based on the activeSlideID
, which we passed to our component earlier as id
via the custom
prop.
The activeNumberBackgroundTransition
just defines what transition to use between visual states.
Now, the background color fades in on first render and then transitions smoothly from one color to the next as we scroll between slides.
AnimatePresence
We're going to animate our slide numbers in and out of the counter using Motion's <AnimatePresence>
component. This allows us to add and remove components from the React tree while animating them as they enter and exit.
One neat trick with <AnimatePresence>
is that we can take advantage of the role of the key
prop in React's reconciliation process. We can render a single motion component and provide our dynamic activeSlideID
value as the key
so that every time the activeSlideID
changes the previous component will be removed and a new component added.
Just like before, we'll define the visual state of our component with variants, only this time our activeNumberVariants
includes an exit
variant for when the component gets removed from the tree.
We'll define a new constant heightOffset
to define the distance of the translateY
transform we'll perform when animating the slide numbers in or out.
Combined with scroll direction
, which we passed via the custom
prop, we're able to coordinate animating our slide numbers in the same direction.
We've set the active
variant to a y
position of 0 since we want the current slide number to rest at the center of our background color component. The initial y
position for any slide number will be heightOffset
pixels in the direction we're scrolling.
The result is that as we scroll down the new slide number will animate up and into the background color component as the previous number animates out above it. And vice versa when we scroll up.
Wrapping Up
That's it! We've covered quite a bit. Hopefully you liked this demo.
The only part the demo we didn't cover is the staggered animation next to the slide titles. You can check out the full source code for the demo to learn how it was built, and you can check out Motion's transition to learn more about how to use staggerChildren
and delayChildren
to power your animations.
Thanks for reading!