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.

<div className='relative'>
  <div className='absolute inset-0'>
    <div className='sticky'>
      <div className={containerClasses}>
        <div className='grid grid-cols-2 gap-16 md:gap-32'>
          <div className='col-start-2'>{/* Slideshow images go here */}</div>
        </div>
      </div>
    </div>
  </div>
  <div className={containerClasses}>
    <div className='grid grid-cols-2 gap-16 md:gap-32'>
      {/* Content sections go here */}
    </div>
  </div>
</div>

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.

const loremSnippet =
  'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
 
const slidesData = [
  {
    id: 0,
    title: 'Flowers',
    content: loremSnippet,
    image: {
      src: FlowersPic,
      alt: 'flowers'
    },
    opacity: 1,
    scale: 1,
    x: 0
  },
  {
    id: 1,
    title: 'Bike',
    content: `${loremSnippet} ${loremSnippet}`,
    image: {
      src: BikePic,
      alt: 'bike'
    },
    opacity: 0,
    scale: 0,
    x: 0
  },
  {
    id: 2,
    title: 'Coffee',
    content: loremSnippet,
    image: {
      src: CoffeePic,
      alt: 'coffee'
    },
    opacity: 0,
    scale: 0,
    x: 0
  },
  {
    id: 3,
    title: 'Ferris Wheel',
    content: `${loremSnippet} ${loremSnippet}`,
    image: {
      src: FerrisWheelPic,
      alt: 'ferris wheel'
    },
    opacity: 0,
    scale: 0,
    x: 0
  },
  {
    id: 4,
    title: 'Bridge',
    content: loremSnippet,
    image: {
      src: BridgePic,
      alt: 'bridge'
    },
    opacity: 0,
    scale: 0,
    x: 0
  }
]

Let's add the slides to our JSX.

<div className='relative'>
  <div className='absolute inset-0'>
    <div className='sticky'>
      <div className={containerClasses}>
        <div className='grid grid-cols-2 gap-16 md:gap-32'>
          <div className='col-start-2'>
            <div className='relative'>
              {slides.map((slide) => (
                <div
                  key={slide.id}
                  className={cn('absolute inset-0 origin-center rounded-md')}
                >
                  <SlideImage id={slide.id} image={slide.image} />
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <div className={containerClasses}>
    <div className='grid grid-cols-2 gap-16 md:gap-32'>
      <div className='grid gap-y-6'>
        {slides.map((slide) => (
          <div key={slide.id} className='grid gap-y-6'>
            <h2 className='pl-6 text-4xl font-bold md:text-7xl'>
              {slide.title}
            </h2>
            <p className='pl-6 text-base font-extralight md:text-2xl'>
              {slide.content}
            </p>
          </div>
        ))}
      </div>
    </div>
  </div>
</div>

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.

type Slide = {
  id: number
  title: string
  content: string
  image: {
    src: StaticImageData
    alt: string
  }
  opacity: number
  scale: number
  x: number
}
 
type SlideImageProps = Pick<Slide, 'id' | 'image'>
 
const SlideImage = ({ id, image }: SlideImageProps) => {
  const [isLoaded, setIsLoaded] = useState<boolean>(false)
 
  return (
    <Image
      className={cn(
        isLoaded ? 'blur-none' : 'blur-lg',
        'z-50 h-full w-full rounded-md object-cover grayscale-[50%] transition-all duration-200'
      )}
      priority={id === 0}
      width={580}
      height={580}
      sizes='(max-width: 767px) 360px, 580px'
      alt={image.alt}
      src={image.src}
      placeholder={'blur'}
      onLoad={() => setIsLoaded(true)}
    />
  )
}

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.

const columnRef = useRef<HTMLDivElement>(null)
 
const { width: imageHeight } = useResizeObserver({
  ref: columnRef
})
We define the ref and call the hook.
<div className={containerClasses}>
  <div className='grid grid-cols-2 gap-16 md:gap-32'>
    <div ref={columnRef} className='grid gap-y-6'>
      {slides.map((slide) => (
        <div key={slide.id} className='grid gap-y-6'>
          <h2 className='pl-6 text-4xl font-bold md:text-7xl'>{slide.title}</h2>
          <p className='pl-6 text-base font-extralight md:text-2xl'>
            {slide.content}
          </p>
        </div>
      ))}
    </div>
  </div>
</div>
Then, we assign the ref to our left column.
<div className='relative'>
  {imageHeight && (
    <ImageViewer
      imageHeight={imageHeight}
      slides={slides}
    />
  )}
  <div className={containerClasses}>
    <div className='grid grid-cols-2 gap-16 md:gap-32'>
      <div ref={columnRef} className='grid gap-y-6'>
        {slides.map((slide) => (
          <div key={slide.id} className='grid gap-y-6'>
            <h2 className='pl-6 text-4xl font-bold md:text-7xl'>{slide.title}</h2>
            <p className='pl-6 text-base font-extralight md:text-2xl'>
              {slide.content}
            </p>
          </div>
        ))}
      </div>
    </div>
  </div>
>
We conditionally render our slide images only once our image height is defined.
type ImageViewerProps = {
  imageHeight: number
  slides: Slide[]
}
 
const ImageViewer = ({ imageHeight, slides }: ImageViewerProps) => {
  const imageOffset = (imageHeight / 2).toFixed(0)
 
  return (
    <div className='absolute inset-0'>
      <div
        style={{
          top: `calc(50vh - ${imageOffset}px)`
        }}
        className='sticky'
      >
        <div className={containerClasses}>
          <div className='grid grid-cols-2 gap-16 md:gap-32'>
            <div className='col-start-2'>
              <div className='relative' style={{ height: `${imageHeight}px` }}>
                {slides.map((slide) => (
                  <div
                    key={slide.id}
                    className={cn('absolute inset-0 origin-center rounded-md')}
                  >
                    <SlideImage slide={slide} />
                  </div>
                ))}
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}
Finally, we define a new component that uses the defined image height to calculate necessary styles.

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.

const { scrollYProgress } = useScroll()

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.

const [[direction, lastScrollY], setDirection] = useState([1, 0])
 
useMotionValueEvent(scrollYProgress, 'change', (currentScrollY) => {
  setDirection([currentScrollY - lastScrollY > 0 ? 1 : -1, currentScrollY])
})

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.

const [slides, setSlides] = useState<Slide[]>(slidesData)

Now, let's add some state to track the active slide.

const [activeSlideID, setActiveSlideID] = useState(0)

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.

// Handler to update a slide's opacity, scale, and x position.
const handleSetSlides = useCallback(
  ({
    id,
    opacity,
    scale,
    x
  }: Pick<Slide, 'id' | 'opacity' | 'scale' | 'x'>) => {
    setSlides((prevSlides: any) => {
      return prevSlides.map((slide: any) => {
        return slide.id === id
          ? {
              ...slide,
              opacity,
              scale,
              x
            }
          : slide
      })
    })
  },
  []
)

Another will simply set the activeSlideID state using the ID of the new active slide.

// Handler to set the active slide ID.
const handleSetActiveSlideID = (id: number) => setActiveSlideID(id)

Let's take a look at passing these handlers to our new <ContentSection> component, replacing what we had previously.

{
  slides.map((slide) => (
    <div key={slide.id} className='grid gap-y-6'>
      <h2 className='pl-6 text-4xl font-bold md:text-7xl'>{slide.title}</h2>
      <p className='pl-6 text-base font-extralight md:text-2xl'>
        {slide.content}
      </p>
    </div>
  ))
}
{
  slides.map((slide) => (
    <ContentSection
      key={slide.id}
      slide={slide}
      slides={slides}
      activeSlideID={activeSlideID}
      handleSetSlides={handleSetSlides}
      handleSetActiveSlideID={handleSetActiveSlideID}
    />
  ))
}

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.

type ContentSectionProps = {
  slide: Slide
  slides: Slide[]
  activeSlideID: number
  handleSetSlides: ({
    id,
    opacity,
    scale,
    x
  }: Pick<Slide, 'id' | 'opacity' | 'scale' | 'x'>) => void
  handleSetActiveSlideID: (id: number) => void
}
 
const ContentSection = ({
  slide,
  slides,
  activeSlideID,
  handleSetSlides
  handleSetActiveSlideID,
}: ContentSectionProps) => {
  const contentSectionRef = useRef(null)
 
  const { scrollProgress } = useScroll({
    target: contentSectionRef
  })
 
  return (
    <div ref={contentSectionRef} className='grid gap-y-6'>
      <h2 className='pl-6 text-4xl font-bold md:text-7xl'>{slide.title}</h2>
      <p className='pl-6 text-base font-extralight md:text-2xl'>
        {slide.content}
      </p>
    </div>
  )
}

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.

const { scrollProgress } = useScroll({
  target: contentSectionRef,
  offset: ['start 50vh', 'end 50vh']
})

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.

// In module scope...
 
// The scrollable area in pixels the animation will take up from start to finish.
const animateHeight = 150
// How many pixels beyond the section top and bottom our animation is offset.
const animatePositionOffset = 50
// Where relative to the viewport our animation is triggered.
const animateScrollOffset = '50vh'
// In <ContentSection> component...
 
const topRef = useRef(null)
const bottomRef = useRef(null)
 
const { scrollYProgress: topYProgress } = useScroll({
  target: topRef,
  offset: [`start ${animateScrollOffset}`, `end ${animateScrollOffset}`]
})
 
const { scrollYProgress: bottomYProgress } = useScroll({
  target: bottomRef,
  offset: [`start ${animateScrollOffset}`, `end ${animateScrollOffset}`]
})
 
return (
  <div className='relative grid gap-y-6'>
    <div
      className='absolute'
      style={{
        height: animateHeight,
        top: 0,
        marginTop: `-${animatePositionOffset}px`
      }}
      ref={topRef}
    />
    <div
      className='absolute'
      style={{
        height: animateHeight,
        bottom: 0,
        marginBottom: `-${animatePositionOffset}px`
      }}
      ref={bottomRef}
    />
    <h2 className='pl-6 text-4xl font-bold md:text-7xl'>{slide.title}</h2>
    <p className='pl-6 text-base font-extralight md:text-2xl'>
      {slide.content}
    </p>
  </div>
)

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.

const topOpacity = useTransform(
  topYProgress, // the starting motion value we want to transform
  [0, 1], // input range of `topYProgress`
  slide.id === 0 ? [1, 1] : [0, 1] // output range of `topOpacity`
)

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.

const bottomOpacity = useTransform(
  bottomYProgress,
  [0, 1],
  slide.id === slides.length - 1 ? [1, 1] : [1, 0]
)

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.

const opacity = useTransform(() =>
  Math.min(topOpacity.get(), bottomOpacity.get())
)

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.

const scale = useTransform(() => Math.min(1, 0.75 + opacity.get() ** 2 * 0.25))

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.

// In module scope...
 
// The distance images should enter and exit from left or right.
const xOffset = 80
// In <ContentSection> component...
 
const x = useTransform(() => {
  // Alternate the direction a slide enters/exits for odd/even slides.
  const isEven = slide.id % 2 === 0
  return (
    // The `xOffset` distance off center.
    xOffset * (isEven ? -1 : 1) +
    // Approach center as opacity approaches 1.
    opacity.get() * xOffset * (isEven ? 1 : -1)
  )
})

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.

const ContentSection = ({
  scrollYProgress,
  direction,
  slide,
  slides,
  activeSlideID,
  handleSetSlides,
  handleSetActiveSlideID
}: ContentSectionProps) => {
useMotionValueEvent(scrollYProgress, 'change', () => {
  // Handle state updates...
})

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.

useMotionValueEvent(scrollYProgress, 'change', () => {
  handleSlideUpdates(slide)
})
 
const handleSlideUpdates = useCallback(
  (slide: Slide) => {
    handleSetSlides({
      id: slide.id,
      opacity: opacity.get(),
      scale: scale.get(),
      x: x.get()
    })
 
    if (opacity.get() > slides[activeSlideID].opacity) {
      handleSetActiveSlideID(slide.id)
    }
  },
  [
    handleSetSlides,
    handleSetActiveSlideID,
    opacity,
    scale,
    x,
    slides,
    activeSlideID
  ]
)

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.

{
  slides.map((slide) => (
    <motion.div
      key={slide.id}
      className={cn('absolute inset-0 origin-center rounded-md')}
      style={{
        opacity: slide.opacity,
        x: slide.x,
        scale: slide.scale
      }}
    >
      <SlideImage id={slide.id} image={slide.image} />
    </motion.div>
  ))
}

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.

<div
  style={{
    top: `calc(50vh - ${imageOffset}px)`
  }}
  className='sticky'
>
  <div className={containerClasses}>
    <div className='grid grid-cols-2 gap-16 md:gap-32'>
      <div className='col-start-2'>
        <div className='relative' style={{ height: `${imageHeight}px` }}>
          {slides.map((slide) => (
            <motion.div
              key={slide.id}
              className={cn('absolute inset-0 origin-center rounded-md')}
              style={{
                opacity: slide.opacity,
                x: slide.x,
                scale: slide.scale
              }}
            >
              <SlideImage id={slide.id} image={slide.image} />
            </motion.div>
          ))}
          <div className='absolute -left-12 bottom-0 top-0 flex items-center md:-left-20'>
            <div className='flex h-8 w-8 items-center justify-center overflow-hidden rounded-[4px] bg-blue-500'>
              {/* Add animated counter showing the active slide number */}
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

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.

<ImageViewer
  imageHeight={imageHeight}
  slides={slides} />
/>

Now, let's pass it the direction and activeSlideID as well.

<ImageViewer
  imageHeight={imageHeight}
  slides={slides}
  direction={direction}
  activeSlideID={activeSlideID}
/>

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.

<div className='absolute -left-12 bottom-0 top-0 flex items-center md:-left-20'>
  <div className='flex h-8 w-8 items-center justify-center overflow-hidden rounded-[4px] bg-blue-500'>
    {/* Add animated counter showing the active slide number */}
  </div>
  <motion.div
    custom={{
      id: activeSlideID
    }}
    initial='inactive'
    animate='active'
    transition={activeNumberBackgroundTransition}
    variants={activeNumberBackgroundVariants}
    className='flex h-8 w-8 items-center justify-center overflow-hidden rounded-[4px]'
  >
    {/* Add animated counter showing the active slide number */}
  </motion.div>
</div>

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.

// In module scope...
 
const colors = ['#6282a6', '#7e6e84', '#72665e', '#b87a5e', '#ebbd74']
 
const activeNumberBackgroundVariants = {
  inactive: {
    backgroundColor: '#ffffff',
    opacity: 0
  },
  active: ({ id }: { id: number }) => ({
    backgroundColor: colors[id],
    opacity: 0.7
  })
}
 
const activeNumberBackgroundTransition = {
  duration: 0.7,
  ease: [0.72, 0.32, 0, 1]
}

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.

<div className='absolute -left-12 bottom-0 top-0 flex items-center md:-left-20'>
  <motion.div
    custom={{
      id: activeSlideID
    }}
    initial='inactive'
    animate='active'
    transition={activeNumberBackgroundTransition}
    variants={activeNumberBackgroundVariants}
    className='flex h-8 w-8 items-center justify-center overflow-hidden rounded-[4px]'
  >
    {/* Add animated counter showing the active slide number */}
    <AnimatePresence>
      <motion.div
        key={activeSlideID}
        custom={{ direction }}
        initial='inactive'
        animate='active'
        exit='exit'
        variants={activeNumberVariants}
        transition={activeNumberTransition}
        className='absolute text-white'
      >
        {activeSlideID + 1}
      </motion.div>
    </AnimatePresence>
  </motion.div>
</div>

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.

// In module scope...
 
const heightOffset = 40
 
const activeNumberVariants = {
  inactive: ({ direction }: { direction: number }) => ({
    opacity: 0,
    y: direction * heightOffset
  }),
  active: {
    opacity: 0.9,
    y: 0
  },
  exit: ({ direction }: { direction: number }) => ({
    opacity: 0,
    y: direction * heightOffset * -1
  })
}

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!