Shake, shake, shake with rapier and r3f-scroll-rig
Coordinating DOM layout, WebGL scenes, and Rapier physics.

The challenge
We know that mixing DOM with WebGL could bring some tears. Especially when handling layout and resize synchronization. If we are using React with react-three/fiber we can take advantage of a super useful library called r3f-scroll-rig made by the awesome 14 islands studio.
This will help us to sync WebGL objects with DOM while scrolling. And talking about awesomeness it also has Lenis from the awesome darkroom engineering studio by default. How many times I wrote awesome?
The docs state that r3f-scroll-rig is 100% compatible with the @react-three ecosystem so I was in the mood to start learning a little bit more about physics by adding react-three/rapier to the game and using it with some 3D models.
I'm using Next.js, so I added <GlobalCanvas> and <SmoothScrollBar> to my layout as the docs from14islands/r3f-scroll-rig suggest. I also added the physics world inside <GlobalCanvas>.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20<div ref={eventSource}> {/* Set eventSource so events works in both Html and Canvas */} <GlobalCanvas eventSource={eventSource} eventPrefix="client" style={{ pointerEvents: 'none', zIndex: 100 }} scaleMultiplier={0.01} > {(globalChildren) => ( <Suspense fallback="null"> <Physics debug gravity={[0, 2, 0]}> {globalChildren} </Physics> </Suspense> )} </GlobalCanvas> <SmoothScrollbar> {(bind) => <main {...bind}>{children}</main>} </SmoothScrollbar> </div>
I decided to start simply by drawing just a cube with some physics properties.
1 2 3 4 5 6 7 8 9const CubeWithPhysics = () => { return ( <> <RigidBody type="dynamic" colliders="cuboid" linearDamping={6}> <Box args={[1, 1, 1]} material-color="yellow" /> </RigidBody> </> ); };
The cube's <RigidBody> has type "dynamic", which means it can be affected by other bodies. It has a cuboid collider (a default cube skeleton) and linearDamping set to 6, which controls the resistance to forces.
I also added a <Pointer> component that follows the cursor position and interacts with the physics objects.
The Pointer <RigidBody> is a sphere with a kinematicPosition type which means it can follow controlled translations and affect other bodies like the dynamic ones.
We added a <BallCollider> collider component to simulate a ball physics behavior.
Also added a useFrame hook to check in every frame and set the position of our sphere to the pointer position.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21function Pointer({ size = 0.5, vec = new THREE.Vector3() }) { const ref = useRef(null!); useFrame(({ pointer, viewport }) => { ref.current?.setNextKinematicTranslation( vec.set( (pointer.x * viewport.width) / 2, (pointer.y * viewport.height) / 2, 0 ) ); }); return ( <RigidBody type="kinematicPosition" colliders={false} ref={ref}> <BallCollider args={[size]} /> <mesh visible={false}> <sphereGeometry args={[size, 16, 64]} /> <meshBasicMaterial color="green" /> </mesh> </RigidBody> ); }
To continue simply, I added my Physics components in a minimal Home page. The cube will be drawn in the position of a sample image which is positioned near to the middle of the viewport in a container that the height is two screens size. I also placed the Pointer which will follow the cursor.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43export default function Home() { const trackedElement = useRef(null!); const { hasSmoothScrollbar } = useScrollRig(); return ( <div style={{ backgroundColor: 'blue', height: '200vh', }} > <div style={{ position: 'absolute', top: '50%', }} > <div ref={trackedElement}> <Image src="/next.svg" alt="next" width={3000} height={3000} className={styles.hiddenWhenSmooth} /> </div> {hasSmoothScrollbar && ( <UseCanvas> <ScrollScene track={trackedElement}> {(props) => { return ( <> <Pointer size={0.2} /> <CubeWithPhysics {...props} /> </> ); }} </ScrollScene> </UseCanvas> )} </div> </div> ); }
And voilà , we have our yellow cube reactive to the <Pointer> component. Here's a StackBlitz code playground with the implemented approach.
But wait, what's going on here? There's a problem with this implementation: the <Cube> meshes and colliders are visually desynchronized.
Open Source Wisdom
What did we miss? I looked to the r3f-scroll-rig rapier docs and respective examples in order to find some answers but I couldn't find a solution. So I asked about this in the r3f-scroll-rig Issues section and thankfully David Lindkvist aka ffdead one of the creators and contributors of the library replied to me with some useful insights and an example.
The key aspect:
- Physics elements should not be wrapped in the r3f-scroll-rig ScrollScene component because that will move the whole physics world.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24<div style={{ backgroundColor: 'blue', height: '200vh', }} > <div style={{ position: 'absolute', top: '50%', }} > <div ref={trackedElement}> <Image src="/next.svg" alt="next" width={3000} height={3000} className={styles.hiddenWhenSmooth} /> </div> {hasSmoothScrollbar && <UseCanvas /> } // The ScrollScene component is removed </div> </div>
- Instead, wrap canvas children directly in the physics world.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19<div ref={eventSource}> {/* Set eventSource so events works in both Html and Canvas */} <GlobalCanvas eventSource={eventSource} eventPrefix="client" style={{ pointerEvents: 'none', zIndex: 100 }} scaleMultiplier={0.01} > <Suspense fallback="null"> <Physics debug gravity={[0, 2, 0]}> <Pointer /> <CubeWithPhysics /> // now the physics components are wrapped in the physics world </Physics> </Suspense> </GlobalCanvas> <SmoothScrollbar> {(bind) => <main {...bind}>{children}</main>} </SmoothScrollbar> </div>
Now the mesh and the colliders are synchronized.
Here's a StackBlitz code playground with the implemented approach.
But we’re not finished yet. Since we removed the ScrollScene component we’re not able to draw our WebGL element in the Image position anymore. The Cube now is positioned based on the Three.js Canvas position and this takes out a lot of the fun of using this library.
Master positioning with useTracker
The solution I found was to use the helpful useTracker hook from r3f-scroll-rig. useTracker according to r3f-scroll-rig docs is used internally by <ScrollScene> and <ViewportScrollScene> to track DOM elements as the user scrolls.
const tracker: Tracker = useTracker(track: MutableRefObject<HTMLElement>, options?: TrackerOptions)I set up a zustand state and store the ref from the Home page.
1 2 3 4 5 6 7 8// in Home page const trackedElement = useRef(null!); const { setTrackedElementRef } = useAppStore(); useEffect(() => { if (trackedElement.current) { setTrackedElementRef(trackedElement); } }, [trackedElement]);
In my Layout, I pass the trackedElementRef to the tracker and set the trackerOptions.
1 2 3 4 5 6 7 8// in Layout const { trackedElementRef } = useAppStore(); const trackerOptions = { rootMargin: '50%', threshold: 0, autoUpdate: true, }; const tracker = useTracker(trackedElementRef, trackerOptions);
Now I can use the position properties to position the cube.
1 2 3 4 5 6 7 8// CubeWithPhysics component <RigidBody ref={rigidBody} type="dynamic" colliders="cuboid" linearDamping={6} position={[tracker.position.x, tracker.position.y, tracker.position.z]} >
Last thoughts
With this method, I was able to draw WebGL elements in image positions that are properly synced with DOM elements and the physics world.
Like the legs model I placed earlier this post, which has a force applied to the userTracker position props.
Any questions, feedback, or just want to chat? Send me an email at hello@emilianolucero.info or dm me on Twitter.
Credits to one of the best visual artists I know, Inés Labarrère Ventroni for the 3D assets used in this post.
ðŸ¤