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