Framer X » Animation » Example Animations » 18. Scroll: Progress

18. Scroll: Progress

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

Open the Framer Motion version in CodeSandbox

Code component

const items = [1, 2, 3, 4, 5, 6]
const height = 70
const padding = 10
const scrollSize = 150

export function CC18ScrollProgress() {
    const itemsHeightTotal = items.length * height
    const totalPadding = (items.length - 1) * padding
    const contentHeight = itemsHeightTotal + totalPadding

    const scrollDistance = -contentHeight + scrollSize

    const scrollY = useMotionValue(0)
    const width = useTransform(
        scrollY,
        [0, scrollDistance],
        ["calc(0% - 0px)", "calc(100% - 40px)"]
    )

    return (
        <div>
            <Scroll
                // Visual & layout
                width={150}
                height={150}
                radius={30}
                center
                // Scrolling
                contentOffsetY={scrollY}
            >
                {items.map(index => {
                    return (
                        <Frame
                            // Visual & layout
                            width={scrollSize}
                            height={height}
                            radius={20}
                            backgroundColor="#fff"
                            top={(height + padding) * (index - 1)}
                            // Required by React
                            key={index}
                        />
                    )
                })}
            </Scroll>
            <Frame
                width={width}
                height={6}
                radius={3}
                backgroundColor="#fff"
                bottom={20}
                left={20}
            />
        </div>
    )
}

Framer Motion

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

export function FM18ScrollProgress() {
    const scrollY = useMotionValue(0)

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

    return (
        <Center>
            <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 (
                            <motion.div
                                style={{
                                    width: 150,
                                    height: height,
                                    borderRadius: 20,
                                    backgroundColor: "#fff",
                                    position: "absolute",
                                    top: (height + padding) * index,
                                }}
                                key={index}
                            />
                        )
                    })}
                </motion.div>
            </motion.div>
            <motion.div
                style={{
                    width: width,
                    height: 6,
                    borderRadius: 3,
                    backgroundColor: "#fff",
                    position: "absolute",
                    bottom: 20,
                    left: 20,
                }}
            />
        </Center>
    )
}

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

Overrides

const appState = Data({
    widthBar: "calc(0% - 0px)",
})

export function Progress_bar(): Override {
    return {
        width: appState.widthBar,
    }
}

export function Scroll(props): Override {
    const contentHeight = props.children[0].props.children[0].props.height
    const scrollSize = props.height as number
    const scrollDistance = -contentHeight + scrollSize

    const scrollY = useMotionValue(0)
    const width = useTransform(
        scrollY,
        [0, scrollDistance],
        ["calc(0% - 40px)", "calc(100% - 40px)"]
    )
    return {
        contentOffsetY: scrollY,
        onScroll() {
            appState.widthBar = width.get()
        },
    }
}

15 comments on “18. Scroll: Progress”

  • Espen Staver says:

    Will this override example work with a scroll component made with the scroll tool on the canvas? Having some difficulties…can’t seem to get to the height values this way?

    • Tes Mat says:

      Yes, the Overrides version works with a Scroll from the Canvas. Check the file with all these examples on: https://framerbook.com/x/animation/example-animations/

      But this example is rather complicated because it also reads the height of the scroll content in a hacky way (by digging into the props of the children)

      I found a simpler ‘scroll progress’ example (with hard coded values) in the Framer Web beta:

      import { Override, motionValue, useTransform } from "framer"
      
      // Track the scroll amount with a motion value
      const scrollY = motionValue(0)
      
      export function Scroll(): Override {
          return {
              contentOffsetY: scrollY,
          }
      }
      
      export function Progress(): Override {
          // Animated width in relation to the scrolled amount
          const width = useTransform(scrollY, [0, -150], [0, 90])
      
          return {
              width: width,
          }
      }
      • Espen Staver says:

        Ah, thanks! Will have a look.

        • Espen Staver says:

          I am still a little puzzled by this. If i copy your example 1:1 in a new framer document, it still does not work. In your file, the props.children[0].props.children[0].props.height yields the height, however in my copy it comes up as “undefined”?

          • 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!!

Leave a Reply