Animation » Example Animations » 15. Scroll: Progress

15. Scroll: Progress

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

Code component

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: 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
}

Code overrides

Pro tip: Don’t use a ‘native’ scroll with overrides, or things will not work.

Version A

You can only use the useMotionValue() hook inside an override (or code component). So I used motionValue() here instead, outside of the overrides.

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} />
    }
}

export function Progress_bar(Component): ComponentType<any> {
    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 }} />
    }
}

Version B

The above version has a predefined contentHeight (total of all the scrollable content) and scrollHeight (height of the scrollable area). This version gets those numbers by giving the Scroll and its content a ref and then reading their sizes through getBoundingClientRect().

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} />
    }
}

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} />
    }
}

const marginLeftAndRightOfBar = 40

export function Progress_bar(Component): ComponentType<any> {
    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 }} />
    }
}

15 comments on “15. Scroll: Progress”

  • 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