Some Examples » 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 Mighty Guides 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.



Leave a Reply