Animation » Example Animations » 15. Scroll: Progress

15. Scroll: Progress

This example uses Motion values and useTransform() to change the width of the bar at the bottom.

Code component

Here, we have a scrollY Motion value that we pass to the draggable div’s y.

This way, the scrollY Motion value will update whenever you drag the div, and we can change it with useTransform() to another Motion value that changes the width of the bottom div.

const items = [0, 1, 2, 3, 4]
const height = 70
const padding = 10
const size = 150

export default function CC_15_Scroll_Progress(props) {
    const scrollY = useMotionValue(0)

    const width = useTransform(
        scrollY,
        [0, -getHeight(items) + size],
        ["calc(0% - 0px)", "calc(100% - 40px)"]
    )

    return (
        <div>
            <motion.div
                style={{
                    width: 150,
                    height: 150,
                    borderRadius: 30,
                    overflow: "hidden",
                    position: "relative",
                    transform: "translateZ(0)",
                    cursor: "grab",
                }}
                whileTap={{ cursor: "grabbing" }}
            >
                <motion.div
                    style={{
                        width: 150,
                        height: getHeight(items),
                        y: scrollY,
                    }}
                    drag="y"
                    dragConstraints={{
                        top: -getHeight(items) + size,
                        bottom: 0,
                    }}
                >
                    {items.map((index) => {
                        return (
                            <div
                                style={{
                                    width: 150,
                                    height: height,
                                    borderRadius: 20,
                                    backgroundColor: "#fff",
                                    position: "absolute",
                                    top: (height + padding) * index,
                                }}
                            />
                        )
                    })}
                </motion.div>
            </motion.div>
            <motion.div
                style={{
                    width,
                    height: 6,
                    borderRadius: 3,
                    backgroundColor: "#fff",
                    position: "absolute",
                    bottom: 20,
                    left: 20,
                }}
            />
        </div>
    )
}

function getHeight(items) {
    const totalHeight = items.length * height
    const totalPadding = (items.length - 1) * padding
    const totalScroll = totalHeight + totalPadding
    return totalScroll
}

We’re using CSS’s calc() to set the value to a maximum of the available width (100%) minus two times 20px for the margins left and right.

calc()

Code overrides

The useMotionValue() hook only works inside functions, so when sharing a Motion value between overrides, we use motionValue() instead.

Also, here, we’re using a prototyping Scroll  component, so we pass the scrollY Motion value to its contentOffsetY property. (There’s also contentOffsetX for when you’re scrolling horizontally.)

const scrollY = motionValue(0)

const contentHeight = 390
const scrollHeight = 150
const scrollDistance = -contentHeight + scrollHeight
const marginLeftAndRightOfBar = 40

export function Scroll(Component): ComponentType {
    return (props) => {
        return <Component {...props} contentOffsetY={scrollY} />
    }
}

The override for the progress bar takes this scrollY Motion value and changes it to a CSS calc() value that changes this layer’s width.

export function Progress_bar(Component): ComponentType {
    return (props) => {
        const { style, ...rest } = props

        const widthBar = useTransform(
            scrollY,
            [0, scrollDistance],
            ["calc(0% - 0px)", `calc(100% - ${marginLeftAndRightOfBar}px)`]
        )

        return <Component {...rest} style={{ ...style, width: widthBar }} />
    }
}

calc()

Version for a native scroll

You can make this work with a native scroll when you add an onScroll() event that takes the scrollTop value and saves it to the Motion value (using its set() function) every time it changes.

export function NativeScroll(Component): ComponentType {
    return (props) => {
        return (
            <Component
                {...props}
                contentOffsetY={scrollY}
                onScroll={(event) =>
                    scrollY.set(event.nativeEvent.target.scrollTop)
                }
            />
        )
    }
}

set()

And also, since a native scroll uses a positive value for the scroll distance, you have to make the scrollDistance positive. (It was a negative value; I flipped it by adding a -.)

export function Progress_bar(Component): ComponentType {
    return (props) => {
        const { style, ...rest } = props

        const widthBar = useTransform(
            scrollY,
            [0, -scrollDistance],
            ["calc(0% - 0px)", `calc(100% - ${marginLeftAndRightOfBar}px)`]
        )

        return <Component {...rest} style={{ ...style, width: widthBar }} />
    }
}

A version that reads sizes

Please find this version in the file: CO_15B_Scroll_Progress.tsx.

The above versions use a (positive or negative) scrollDistance that’s calculated from predefined contentHeight (total of all the scrollable content) and scrollSize (height of the scrollable area) variables. The version below gets those numbers directly from the scroll layer and its content.

This is again a version for a non-native scroll (so no added onScroll()). Here, we have a ref, created using React’s useRef() hook, passed to the scroll component.

const useStore = createStore({ scrollHeight: 0, contentHeight: 0 })

const scrollY = motionValue(0)

export function Scroll(Component): ComponentType {
    return (props) => {
        const [store, setStore] = useStore()

        const ref = useRef(null)

        useEffect(() => {
            const rect = ref.current.getBoundingClientRect()
            setStore({ scrollHeight: rect.height })
        }, [])

        return <Component {...props} contentOffsetY={scrollY} ref={ref} />
    }
}

We now have access to the HTML element through the ref, enabling us to get its height by calling getBoundingClientRect() on the reference. Inside an useEffect() because we want to do this when everything is drawn on the screen.

useRef(), Manipulating the DOM with Refs
getBoundingClientRect()

And we can save the returned value in the data store as scrollHeight.

const useStore = createStore({ scrollHeight: 0, contentHeight: 0 })

const scrollY = motionValue(0)

export function Scroll(Component): ComponentType {
    return (props) => {
        const [store, setStore] = useStore()

        const ref = useRef(null)

        useEffect(() => {
            const rect = ref.current.getBoundingClientRect()
            setStore({ scrollHeight: rect.height })
        }, [])

        return <Component {...props} contentOffsetY={scrollY} ref={ref} />
    }
}

But we also need the height of the scrollable content. So there’s now an extra (and similar) override for this. It saves that value in the data store’s contentHeight.

export function ScrollContent(Component): ComponentType {
    return (props) => {
        const [store, setStore] = useStore()

        const ref = useRef(null)

        useEffect(() => {
            const rect = ref.current.getBoundingClientRect()
            setStore({ contentHeight: rect.height })
        }, [])

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

In the code override for the progress bar, we can now take scrollHeight and contentHeight and calculate the correct scrollDistance.

const marginLeftAndRightOfBar = 40

export function Progress_bar(Component): ComponentType {
    return (props) => {
        const { style, ...rest } = props

        const [store, setStore] = useStore()

        const scrollDistance = -store.contentHeight + store.scrollHeight

        const widthBar = useTransform(
            scrollY,
            [0, scrollDistance],
            ["calc(0% - 0px)", `calc(100% - ${marginLeftAndRightOfBar}px)`]
        )

        return <Component {...rest} style={{ ...style, width: widthBar }} />
    }
}

(It’s again a negative value because we’re using a non-native scroll.)


Join the Framer book mailing list    ( ± 6 emails/year )

GDPR

We use Mailchimp as our marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing per their Privacy Policy and Terms.



15 comments on “15. Scroll: Progress”

  • Espen Staver says:

    Ah, thanks! Will have a look.

    • Espen Staver says:

      Got it to work now, reapplying the override fixed it.

    • Espen Staver says:

      Hm, that wasn’t it. There is certainly something quirky going on: When I place the scroll component further down in the container frame, I can get a read from props.height for example. However if the scroll component is placed with no margin at the top , I get an “undefined”. What could be causing this?

      • Tes Mat says:

        Found it! (I hope.)

        Check the how the scroll component is pinned. When it’s pinned to both the top and bottom its props.height will be undefined, and that’s when things break down.

        (It makes sense that height is undefined in that case, because the actual height will then be calculated from the top and bottom distances.)

    • Espen Staver says:

      That was it! Thanks 🙂

  • Debashish Paul says:

    Hey Tes,
    Really need your help here! Is there a way to reset an ongoing animation? I am in a weird situation — I have a progress bar that is animating to it’s 100% width, but in between I want this bar to jump at specific widths and continue from there to its original destination. How can I achieve this?

    Scenario: Let’s say I have a page component with next and prev buttons. I have this progress bar that animates to 100% width and along the way it changes the pages automatically after say, every 4 seconds. But, in addition to the autoplay(which I am able to achieve), I want to tap on the next/prev buttons to change pages and also make the progress bar jump back and forth corresponding to the current page and restart the 4 seconds timer.

    Thanks in advance!

    • Tes Mat says:

      This sounds very much like a Stories interface 😃

      If I understand you correctly, the bar should switch directly to the new position (without animation), and from that point start the new 4-second animation.

      Whenever you restart an animation it will use the same transition values (including duration) so that’s taken care of. Problem is that an animation will always start from a property’s current value. (So no jumping to a new start point.)

      There’s a way, though. There’s this from transition setting, which I’ve never used (I thought: “Why bother? We have initial”), but this is exactly what it’s for.

      I did a quick proof of concept with a few overrides:

      import { Data, Override } from "framer"
      
      const data = Data({
          totalPages: 4,
          activePage: 1,
      })
      
      export function Bar(): Override {
          return {
              animate: { width: (300 / data.totalPages) * data.activePage },
              transition: {
                  from: (300 / data.totalPages) * (data.activePage - 1),
                  duration: 4,
              },
          }
      }
      
      export function Previous(): Override {
          return {
              whileTap: { scale: 0.9 },
              onTap() {
                  data.activePage = data.activePage - 1
              },
          }
      }
      
      export function Next(): Override {
          return {
              whileTap: { scale: 0.9 },
              onTap() {
                  data.activePage = data.activePage + 1
              },
          }
      }

      I hope that this is more or less what you’re looking for.

      • Debashish Paul says:

        haha, only that my progress bar wants to be smarter and causing me pain 🙁
        oh wow, never knew about “from” in transition. This definitely solves a large part of my scenario but in the expected behavior the bar never stops and thats what I was curios about. My main bar animation is going from 0-300 in 32 seconds. Every 4th second the next page loads automatically (I am able to achieve this). But when I tap on the next/prev buttons the pages change, the bar jumps to the corresponding position and autoplays from the new location to the destination of 300. The challenge is how to reset the 32 seconds while it is running.

        I’ll try to build on top of what you shared but if you have anything right off your head to solve this scenario that will be super amazing!

        • Debashish Paul says:

          For reference, here is what I was doing and achieving the autoplay behavior.

          export function ProgressTap(props): Override {
              return {
                  initial: { width: appState.progressWidth },
                  animate: appState.progressAnimate
                      ? { width: 302 }
                      : { width: appState.progressWidth },
                  transition: {
                      ease: "linear",
                      duration: 32,
                  },
                  onUpdate: (latest) => {
                      let absWidth = Math.ceil(Number(latest.width))
                      if (absWidth === 38) {
                          appState.currentPage = 1
                      } else if (absWidth === 76) {
                          appState.currentPage = 2
                      } else if (absWidth === 114) {
                          appState.currentPage = 3
                      } else if (absWidth === 152) {
                          appState.currentPage = 4
                      } else if (absWidth === 190) {
                          appState.currentPage = 5
                      } else if (absWidth === 228) {
                          appState.currentPage = 6
                      } else if (absWidth === 266) {
                          appState.currentPage = 7
                      }
                  },
              }
          }
          • Debashish Paul says:

            Ok so I did it! 🙂

            export function ProgressTap(): Override {
                return {
                    animate: appState.progressAnimate
                        ? {
                              width:
                                  (302 / appState.pagesLength) * appState.currentPage,
                          }
                        : {},
                    transition: {
                        from:
                            (302 / appState.pagesLength) * (appState.currentPage - 1),
                        duration: 4,
                        ease: "linear",
                    },
                    onAnimationComplete() {
                        appState.currentPage += 1
                    },
                }
            }
  • Debashish Paul says:

    Thanks for the help Tes! Really appreciate your quick response on that!!

  • micah.fegley says:

    I’m struggling with a prototype that has text fading in and then out based on scroll position. I’m able to create a single effect using useTransform() but I’m not sure how to have it come from 20% opacity to 100% and back to 20%, all based on a scroll distance of ~500px.

    export function equalityTxt(): Override {
        const newOpacity = useTransform(scrollY, [-3200, -3400], [0, 1])
        return {
            opacity: newOpacity,
        }
    }
    • Tes Mat says:

      You can use more than two values in useTransform(), so you can do something like:

      export function equalityTxt(): Override {
          const newOpacity = useTransform(scrollY, [-3200, -3300, -3400], [0.2, 1, 0.2])
          return {
              opacity: newOpacity,
          }
      }

Leave a Reply