Giles Perry
Giles Perry

Giles Perry

Advanced Scroll Effects in Framer

Parallax, dynamic headers, and scroll driven video. Learn how to leverage code to take your scroll interactions to the next level in Framer.

Giles Perry's photo
Giles Perry
·Jul 26, 2022·

17 min read

Featured on Hashnode
Featured on daily.dev
Advanced Scroll Effects in Framer

Three years ago I wrote an article for Framer on how to create scroll interaction effects. Much has happened since then, in particular the ability to publish fully functioning websites.

So how do the techniques I explored fit this new world?

We now have two contexts where you might want to apply scrolling effects: scrolling of the whole page relative to the viewport, and scrolling of content that overflows its container. I will concentrate on page scrolling, but the techniques for element scrolling are the same, so look out for a pointer on this.

My focus will be on code, but many of the effects I first wrote about are now more easily applied through native features. I will speak about these options too.

In this article you will learn how to...


First let’s talk theory

Behind the scenes, the things you create in Framer are powered by HTML, CSS and JavaScript. Framer is based on React, a JavaScript library for building user interfaces. Everything you make in Framer is a React component.

Components have properties such as height, width, or scale. We call them props. When you add a frame to your project and pick a background colour in the Properties panel, you are setting the frame’s props.

Framer lets you apply code to a layer that will override the layer’s props when it is displayed in the Preview window, and on your published site. Code Overrides are incredibly powerful, so it's worth getting to know them. Through overrides you have access to pretty much anything the web is capable of.

We'll start with something simple

Let's say you want to override a layer's height.

Height is a CSS style attribute, so to make the layer 100 pixels high we need to update its style prop using the value { height: 100 }.

Here’s the Code Override to do exactly that:

import type { ComponentType } from "react"

export function withSpecificHeight(Component): ComponentType {
    return (props: any) => {
        return <Component {...props} style={{ ...props.style, height: 100 }} />
    }
}

If you are new to coding, being able to read and understand even a simple example like this means taking on JavaScript, TypeScript and React all at once.

Let's pick it apart

A React component is simply a function that returns a block of HTML.

Overrides are a little bit more complicated. In React, an override is what is known as a higher-order component. It’s a function that takes another React component as an input and returns a new component. The override uses this technique to modify the original component’s props.

So, when you add an override to a layer, you are replacing the underlying React component with one that has been modified. By convention, override functions have “with” as a prefix because they return a component with a modification.

Let's replay our example one step at a time:

import type { ComponentType } from "react"

Import a type definition, ComponentType, provided by React. We'll use this to let the code know that the override function returns a component.

export function withSpecificHeight(Component): ComponentType

Export a function component called withSpecificHeight. We need to export it so Framer can access it. The function has a single argument: Component. This is the component that Framer will pass into the function.

How about the : ComponentType tacked on the end? That’s TypeScript. It’s a type annotation specifying that this function returns a component.

Everything in the first set of curly braces is the code block that runs when you call the function. It's pretty simple. It just returns the following React function component:

(props: any) => {
    return <Component {...props} style={{ ...props.style, height: 100 }} />
}

This function defines what will actually be rendered:

<Component /> is our original component. Everything else describes the props we want to apply this time round.

{...props} is a shorthand syntax for listing out and applying all of the original props intact. Because style is set after this, its value gets overridden:

style={{ ...props.style, height: 100 }}

Similarly, we want to override height without affecting any other styles: ...props.style lists out and applies any existing styles, then we override the height.

Boom! Now you can code. Let's get going!

All my examples use Overrides. If you are new to Framer you can read more about Overrides in the Framer Developers Guides. If you need an introduction to React, you might enjoy reading the Framer Guide to React:


Parallax

Parallax is an effect applied to content as it scrolls that creates the illusion of depth by moving background layers more slowly than layers in the foreground. Parallax is all about relative movement.

The easy route to parallax is using Framer’s native scroll effects. Speed Effects allow you to define the scrolling speed of any given layer. Read Framer’s documentation to find out how.

This is great. If vertical parallax is all you need you are good to go. For anything more bespoke you'll need an override.

In a lot of cases, the code is surprisingly simple. We want to establish a relationship between the distance scrolled and another value. We have already seen how an override can modify component properties, so we need two more things.

  1. A way to track the scroll amount.
  2. A way to the transform this valve into the value we want to apply to the layer.

Framer’s animation library, Motion, provides utility functions for each of these.

Keeping track of the scroll distance

The first is useScroll.

This function returns a value that stays synced to the distance the page is scrolled. Before we can call it in our override function, we need to import it from the library:

import { useScroll } from "framer-motion"

Now we can get scrollY, the vertical scroll distance in pixels:

const { scrollY } = useScroll()

scrollY is special kind of variable called a MotionValue that Framer uses to track the state and velocity of a value that animates or changes rapidly. As you scroll, scrollY will magically update any property we connect to it.

Transforming MotionValues

We could apply the value in scrollY directly, but in most cases we need to transform it into another value. For that we need useTransform.

useTransform creates a new MotionValue by transforming the output of a MotionValue we pass to it.

Say we want a layer to drift horizontally as we scroll down the page, translating it 1px left along the x-axis for every 2px we scroll down. i.e. half the scroll speed in a negative x direction.

We can transform scrollY into the x value we need like this:

const x = useTransform(scrollY, (value) => -value / 2)

All that's left to do is to use x to translate the layer along the x-axis using the CSS transform property.

Motion gives us easy access to these properties via enhanced style props and x, y, and z Translate shortcuts.

Here’s the override in its entirety:

import type { ComponentType } from "react"
import { useScroll, useTransform } from "framer-motion"

export function withParallax(Component): ComponentType {
    const speed = 1 / 2
    return (props: any) => {
        const { scrollY } = useScroll()
        const x = useTransform(scrollY, (value) => -value * speed) // scrolling down translates left
        return <Component {...props} style={{ ...props.style, x: x }} />
    }
}

More scroll driven effects

The same technique can be used to make scrolling drive all kinds of properties. Here’s another override to add to your collection. Use it to drive a Lottie animation or scrub through a video (both components have a progress prop and can be added from the Insert panel).

// Scrub through a video or drive a Lottie animation by scrolling
export function withScrolledProgress(Component): ComponentType {
    const startY = 0 // scroll position when animation starts
    const distance = 1000 // scroll distance after which animation ends
    const endY = startY + distance

    return (props) => {
        const { scrollY } = useScroll()
        const progress = useTransform(scrollY, [startY, endY], [0, 1])

        return <Component {...props} progress={progress} />
    }
}

You will notice we are passing arguments into useTransform in a different format. Instead of a function for transforming the input value, we use arrays to specify input and output ranges. These determine the output required at specific points. Values in between are interpolated.


Now is a good time to talk about element scrolling

The main difference, in the case of element scrolling, is we need a ref to identify the scrolling element.

Refs provide a way for React to access nodes in the DOM.

We also need two overrides:

  • One to identify the scroll container.
  • One to apply the transformed property to the element we want affect.

Let's give it a try... First import a function from React for creating refs:

import { createRef } from "react"

The ref has to be created outside of the overrides so that both overrides can use the same ref:

const ref = createRef<HTMLDivElement>()

Here’s the override to attach the ref to a component. Apply this to your scroll container:

export function withScrollRef(Component): ComponentType {
    return (props) => {
        return <Component {...props} ref={ref} />
    }
}

To track the scroll position of a scrollable element, instead of the page, we pass the ref to useScroll's container option:

const { scrollY } = useScroll({
    container: ref
})

In all other respects, our parallax override is identical to the one above:

export function withElementParallax(Component): ComponentType {
    const speed = 1 / 2
    return (props: any) => {
        const { scrollY } = useScroll({
            container: ref
        })
        const x = useTransform(scrollY, (value) => -value * speed) // scrolling down translates left
        return <Component {...props} style={{ ...props.style, x: x }} />
    }
}

Here’s everything we need in one file:

import type { ComponentType } from "react"
import { createRef } from "react"
import { useTransform, useScroll } from "framer-motion"

// create a ref so we can attach it to the scroll container
const ref = createRef<HTMLDivElement>()

// apply this to the element being scrolled
export function withScrollRef(Component): ComponentType {
    return (props) => {
        return <Component {...props} ref={ref} />
    }
}

// apply this to the element with the scroll effect
export function withElementParallax(Component): ComponentType {
    const speed = 1 / 2
    return (props: any) => {
        const { scrollY } = useScroll({
            container: ref
        })
        const x = useTransform(scrollY, (value) => -value * speed) // scrolling down translates left
        return <Component {...props} style={{ ...props.style, x: x }} />
    }
}

The remaining examples will stick to viewport scrolling, but you can adapt them to element scrolling using this framework.


Dynamic nav bar

A nav bar that collapses when you scroll is another great use case for scroll driven transformations. The override we need is very similar to withScrolledProgress.

By default, output from useTransform is clamped to stay within the given range. Using it this way, as we have so far, would allow us to scroll between a maximum and minimum height.

However, apps often have a bar that shrinks to a minimum size when the user scrolls up, but stretches as far as you are able to pull it in the other direction. If that's the case, we only want to clamp the value in one direction. The trick to making that work is unclamping the transform while repeating the final input and output values.

Here’s what that looks like:

export function withDynamicNavBar(Component): ComponentType {
    // Value being driven by scrolling (e.g. height)
    const initialValue = 140
    const finalValue = 88

    const speed = 1
    const scrollDistance = (initialValue - finalValue) / speed

    const startY = 0 // scroll position when transition starts
    const endY = startY + scrollDistance

    return (props: any) => {
        const { scrollY } = useScroll()
        const scrollOutput = useTransform(
            scrollY,
            [startY, endY, endY],
            [initialValue, finalValue, finalValue],
            {
                clamp: false,
            }
        )
        return <Component {...props} style={{ ...props.style, height: scrollOutput }} />
    }
}

Triggering animations at set scroll positions

Another feature we see in dynamic navigation bars is a title, which fades in when the bar reaches its minimum size. How can we animate the opacity of the title so it appears only when scrollY passes a threshold?

It's time for a bit more theory…

When you imaging coding an interaction, it feels like you need code telling the application exactly what to do:

Change the opacity of my title to a new value when I scroll past a threshold.

Code like this is imperative.

React requires a new way of thinking. Instead of specifying what to do, the code is declarative; it tells the application how to be:

Be transparent below the threshold, and be opaque above the threshold.

React takes care of everything else, making sure the opacity changes when the condition changes.

To make this happen our code needs three parts:

  • A variable to keep track of the state of our application: is scrollY past a limit, true or false?
  • Something to listen for changes to scrollY and update the application state if the limit is passed.
  • A function telling the title to animate to either 1 or 0 opacity depending the current state.

We are going to need a couple of React Hooks to get this done. Let’s import them:

import { useState, useEffect } from "react"

Adding a state variable

We need a Boolean (true or false) state variable where we will keep a record of whether the scroll distance is past the threshold or not. The useState Hook gives us a way to create and update a state variable:

const [isPastThreshold, setIsPastThreshold] = useState(false)

isPastThreshold is the state variable and setIsPastThreshold is the setter function that allows us to update it. The initial state is false.

Listening for changes

Listeners can be added to MotionValues with the onChange method. This method passes the latest value into a function we provide every time the MotionValue changes.

scrollY.onChange((latest) => setIsPastThreshold(latest > thresholdY))

We are required by React to add the listener after the component is rendered. Long story short to run some code after rendering we need to add an Effect. That’s where useEffect comes in:

useEffect(
    () => scrollY.onChange((latest) => setIsPastThreshold(latest > thresholdY)),
    []
)

Telling the component how to be

This is as simple as setting opacity on the component's animate prop using a conditional value.

animate={{ opacity: isPastThreshold ? 1 : 0 }}

When isPastThreshold is true the opacity value will animate to 1, otherwise it will animate to 0.

export function withScrollToggledState(Component): ComponentType {
    const thresholdY = 100 // set the scroll position where you want the state change
    return (props) => {
        const { scrollY } = useScroll()
        const [isPastThreshold, setIsPastThreshold] = useState(false)
        useEffect(
            () =>
                scrollY.onChange((latest) =>
                    setIsPastThreshold(latest > thresholdY)
                ),
            []
        )
        return (
            <Component
                {...props}
                animate={{ opacity: isPastThreshold ? 1 : 0 }}
            />
        )
    }
}

Get used to seeing this:

const property = condition ? valueWhenTrue : valueWhenFalse

In React, this is your best friend…

Triggering a variant switch

Now we know how to trigger state changes, we can use the same trick on the variant prop:

variant={isPastThreshold ? "Second" : "First"}

When you try this for the first time, you see the power of declarative code in sharp focus. Set one simple condition and Magic Motion will handle everything.

This is another scenario that can be handled natively. You’ll find the Scroll Variant option when you add an Effect to any component with more than one variant. Read Framer’s documentation to learn about native Scroll Effects.


Viewport awareness

So far we have controlled when effects are applied by hard-coding the scroll position. This approach can be limiting. If we want the same effect on a number of layers, we need a bespoke override for each. If we change the element's position, we need to update our code. Even worse, if our layout is responsive, we may not know element positions upfront.

Luckily, Motion has our back.

Animate while in view

Triggering animations as an element enters the viewport is a simple as setting Motion's whileInView prop.

Add the viewport prop if you need more control over how the viewport is detected.

Here's an override that animates an element's opacity the first time the element appears in the viewport:

export function withAnimateWhileInView(Component): ComponentType {
    return (props) => {
        return (
            <Component
                {...props}
                initial={{ opacity: 0 }}
                whileInView={{ opacity: 1 }}
                viewport={{ once: true }}
            />
        )
    }
}

You can also use whileInView for animating between variants. Take a look at the docs to learn more about whileInView and its corresponding viewport options.

How about scroll-driven animations?

You can track an element's progress within the viewport by passing its ref to useScroll's target option.

Let's see if we can reproduce the scaling effect shown in the demo above.

If we define the start and end of the viewport from top to bottom, and apply the same logic to the circles, then each circle should:

  • start animating when its end intersects with the viewport's end,
  • and finish when its start intersects with the viewport's start.

In other words we want make each circle a scroll target, and measure its progress as it scrolls from "end end" to "start start".

Here is an override you can attach to each circle to make that happen:

import type { ComponentType } from "react"
import { useRef } from "react"
import { useScroll, useTransform } from "framer-motion"

export function withScrollLinkedScale(Component): ComponentType {
    return (props: any) => {
        const ref = useRef(null)

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

        const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.5, 1, 0.5])

        return (
            <Component {...props} ref={ref} style={{ ...props.style, scale }} />
        )
    }
}

There are a few things to call out:

We met refs earlier. This time we don't need to share the ref so we create it inside the override with React's useRef hook.

const ref = useRef(null)

Instead of the absolute scroll position, in pixels, we tell useScroll to return scrollYProgress: a MotionValue between 0 and 1. Also, we pass in target and offset options.

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

As scrollYprogress progresses from 0 when the circle enters the viewport, to 0.5 in the centre of the viewport, to 1 at the end, we transform its scale from 0.5 to 1 and back to 0.5.

const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.5, 1, 0.5])

Scroll-away nav bar

I'm going to step up the pace a little and look at a more complex interaction: a commonly seen pattern where a nav bar slides off screen when you scroll down the page and slides back into view when you scroll back.

Thinking declaratively… we need a condition to tell us this: is the nav bar in view or not?

This will depend on two things:

  • is the user scrolling back or not?
  • is the page at the top? (because we always want to show the nav bar when we are at the top of the page)

Which gives us three state variables: isInView, isScrollingBack and isScrollingBack.

Scroll direction can be calculated by checking if the velocity of scrollY is positive or negative.

const { scrollY } = useScroll()
const scrollVelocity = useVelocity(scrollY)

We need to listen for changes in scroll velocity in one Effect.

useEffect(
    () =>
        scrollVelocity.onChange((latest) => {
            if (latest > 0) {
                setIsScrollingBack(false)
                return
            }
            if (latest < -threshold) {
                setIsScrollingBack(true)
                return
            }
        }),
    []
)

In another we set isAtTop

useEffect(
    () => scrollY.onChange((latest) => setIsAtTop(latest <= 0)),
    []
)

And if either isScrollingBack or isAtTop changes we have a final Effect to update: isInView.

useEffect(
    () => setIsInView(isScrollingBack || isAtTop),
    [isScrollingBack, isAtTop]
)

Here's what that all comes to in full:

export function withScrollAway(Component): ComponentType {
    const slideDistance = 100 // if we are sliding out a nav bar at the top of the screen, this will be it's height
    const threshold = 500 // only slide it back when scrolling back at velocity above this positive (or zero) value

    return (props) => {
        const { scrollY } = useScroll()
        const scrollVelocity = useVelocity(scrollY)

        const [isScrollingBack, setIsScrollingBack] = useState(false)
        const [isAtTop, setIsAtTop] = useState(true) // true if the page is not scrolled or fully scrolled back
        const [isInView, setIsInView] = useState(true)

        useEffect(
            () =>
                scrollVelocity.onChange((latest) => {
                    if (latest > 0) {
                        setIsScrollingBack(false)
                        return
                    }
                    if (latest < -threshold) {
                        setIsScrollingBack(true)
                        return
                    }
                }),
            []
        )

        useEffect(
            () => scrollY.onChange((latest) => setIsAtTop(latest <= 0)),
            []
        )

        useEffect(
            () => setIsInView(isScrollingBack || isAtTop),
            [isScrollingBack, isAtTop]
        )

        return (
            <Component
                {...props}
                animate={{ y: isInView ? 0 : -slideDistance }}
                transition={{ duration: 0.2, delay: 0.25, ease: "easeInOut" }}
            />
        )
    }
}

Time to put it all together

Congratulations 🎉. You are a scroll master. With the techniques and concepts you’ve learned here, you have the building blocks of almost every kind of scroll interaction.

To see these overrides in action and more check out this example project.

Partner Banner Color@2x.png


A bit about me

My name is Giles Perry. I’m a Principal Product Designer at Skyscanner. Check out my website or find me on LinkedIn.

Like this article and want to say thanks? Why not buy me a coffee ☕️?

 
Share this