React Three Fiber
It's assumed you have a base understanding of react-spring
and react-three-fiber
. If you're new to
either, check out our getting started or alternatively, the r3f docs.
Introduction
In this guide we'll explore why react-spring
is a valuable addition to your r3f
project, working with the imperative API
to create performant animation updates on objects in the scene graph and with the event system of the library
to update parts of your scene that are not wrapped in our animated
HOC. To work with react-spring
and react-three-fiber
you'll need to install the @react-spring/three
package.
yarn add @react-spring/three
Why use the library?
A common question asked is why use react-spring
with react-three-fiber
when you can use the useFrame
hook to update your meshes & objects
instead without knowing another API. Well this is a great question, lets consider a simple use-case.
I have a distortion blob and I want to it to change color on click. You could do this with useFrame
to perform frame by frame updates, useRef
to access the material object and use the THREE.Color.lerp
function, slowly incrementing by lerp value until the color is reached. But this then
requires that I keep track of a THREE
instance of Color
which can lead to memory leaks if not handled correctly and not only that, but it would
be a lot of code. Then you'd need to think about writing your own easing functions. With react-spring
you can do this in a few lines of code.
import { useState } from 'react'
import { useSpring, animated } from '@react-spring/three'
import { MeshDistortMaterial } from '@react-three/drei'
import { Canvas } from '@react-three/fiber'
const AnimatedMeshDistortMaterial = animated(MeshDistortMaterial)
const MyScene = () => {
const [clicked, setClicked] = useState(false)
const springs = useSpring({
color: clicked ? '#569AFF' : '#ff6d6d',
})
const handleClick = () => setClicked(s => !s)
return (
<mesh onClick={handleClick}>
<sphereGeometry args={[1.5, 64, 32]} />
<AnimatedMeshDistortMaterial
speed={5}
distort={0.5}
color={springs.color}
/>
</mesh>
)
}
export default function MyComponent() {
return (
<Canvas>
<ambientLight intensity={0.8} />
<pointLight intensity={1} position={[0, 6, 0]} />
<MyScene />
</Canvas>
)
}
Use the imperative API
In the example below, we use the power of the react-spring
's api to lean into the imperative requirements
of working with performant webGL scenes. The blob follows you round the canvas (lines 70-82) & scales on
interaction (lines 46-56) without a single react render to cause these updates.
In addition we use the provide a function to the config
prop instead of an object to deliver a more sticky
config for the spring in general, but to give the blob a bouncy feel when the scale
key is changed – lines 18-30.
Finally, because we're using the position of the mouse which can be considered a Vector2
and the position
of a mesh
is a Vector3
we use a custom interpolation via the to
method of a SpringValue
to interpolate
the array, this can be seen on line 97.
Why not read more about it the imperative and interpolation APIs?
import { useRef, useEffect, useCallback } from 'react'
import { useSpring, animated } from '@react-spring/three'
import { Canvas, useThree } from '@react-three/fiber'
import { MeshDistortMaterial } from '@react-three/drei'
const AnimatedMeshDistortMaterial = animated(MeshDistortMaterial)
const MyScene = () => {
const isOver = useRef(false)
const { width, height } = useThree(state => state.size)
const [springs, api] = useSpring(
() => ({
scale: 1,
position: [0, 0],
color: '#ff6d6d',
config: key => {
switch (key) {
case 'scale':
return {
mass: 4,
friction: 10,
}
case 'position':
return { mass: 4, friction: 220 }
default:
return {}
}
},
}),
[]
)
const handleClick = useCallback(() => {
let clicked = false
return () => {
clicked = !clicked
api.start({
color: clicked ? '#569AFF' : '#ff6d6d',
})
}
}, [])
const handlePointerEnter = () => {
api.start({
scale: 1.5,
})
}
const handlePointerLeave = () => {
api.start({
scale: 1,
})
}
const handleWindowPointerOver = useCallback(() => {
isOver.current = true
}, [])
const handleWindowPointerOut = useCallback(() => {
isOver.current = false
api.start({
position: [0, 0],
})
}, [])
const handlePointerMove = useCallback(
e => {
if (isOver.current) {
const x = (e.offsetX / width) * 2 - 1
const y = (e.offsetY / height) * -2 + 1
api.start({
position: [x * 5, y * 2],
})
}
},
[api, width, height]
)
useEffect(() => {
window.addEventListener('pointerover', handleWindowPointerOver)
window.addEventListener('pointerout', handleWindowPointerOut)
window.addEventListener('pointermove', handlePointerMove)
return () => {
window.removeEventListener('pointerover', handleWindowPointerOver)
window.removeEventListener('pointerout', handleWindowPointerOut)
window.removeEventListener('pointermove', handlePointerMove)
}
}, [handleWindowPointerOver, handleWindowPointerOut, handlePointerMove])
return (
<animated.mesh
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onClick={handleClick()}
scale={springs.scale}
position={springs.position.to((x, y) => [x, y, 0])}>
<sphereGeometry args={[1.5, 64, 32]} />
<AnimatedMeshDistortMaterial
speed={5}
distort={0.5}
color={springs.color}
/>
</animated.mesh>
)
}
export default function MyComponent() {
return (
<Canvas>
<ambientLight intensity={0.8} />
<pointLight intensity={1} position={[0, 6, 0]} />
<MyScene />
</Canvas>
)
}
Working with spring events
Sometimes, it's necessary to sync the state of a spring with the an external source. This can be done with the event system built into react-spring.
Take the following example, we have multiple blobs on our screen that start in different places and a component higher in our scene graph needs to to know
the position of each blob. Because the position
is controlled by useSpring
you can't simple submit springs.position
to the store because you'll
be dispatching the whole SpringValue
object, which is unnecessary and can weigh down your external store.
Instead, you can use the onChange
event handler to get
the value of your springs and react to them accordingly. The code below is a conveluted example
but demonstrates how you could use the onChange
event handler to sync a THREE.Vector2
that is then returned when the parent component requires it via
useImperativeHandle
.
import {
useRef,
useEffect,
useCallback,
forwardRef,
useState,
useImperativeHandle,
} from 'react'
import { useSpring, animated } from '@react-spring/three'
import { Canvas, useThree } from '@react-three/fiber'
import { MeshDistortMaterial } from '@react-three/drei'
import { Vector2 } from 'three'
const AnimatedMeshDistortMaterial = animated(MeshDistortMaterial)
const MyScene = forwardRef(({}, ref) => {
const isOver = useRef(false)
const [vector2] = useState(() => new Vector2())
const { width, height } = useThree(state => state.size)
const [springs, api] = useSpring(
() => ({
scale: 1,
position: [0, 0],
color: '#ff6d6d',
onChange: ({ value }) => {
vector2.set(value.position[0], value.position[1])
},
config: key => {
switch (key) {
case 'scale':
return {
mass: 4,
friction: 10,
}
case 'position':
return { mass: 4, friction: 220 }
default:
return {}
}
},
}),
[]
)
useImperativeHandle(ref, () => ({
getCurrentPosition: () => vector2,
}))
const handleClick = useCallback(() => {
let clicked = false
return () => {
clicked = !clicked
api.start({
color: clicked ? '#569AFF' : '#ff6d6d',
})
}
}, [])
const handlePointerEnter = () => {
api.start({
scale: 1.5,
})
}
const handlePointerLeave = () => {
api.start({
scale: 1,
})
}
const handleWindowPointerOver = useCallback(() => {
isOver.current = true
}, [])
const handleWindowPointerOut = useCallback(() => {
isOver.current = false
api.start({
position: [0, 0],
})
}, [])
const handlePointerMove = useCallback(
e => {
if (isOver.current) {
const x = (e.offsetX / width) * 2 - 1
const y = (e.offsetY / height) * -2 + 1
api.start({
position: [x * 5, y * 2],
})
}
},
[api, width, height]
)
useEffect(() => {
window.addEventListener('pointerover', handleWindowPointerOver)
window.addEventListener('pointerout', handleWindowPointerOut)
window.addEventListener('pointermove', handlePointerMove)
return () => {
window.removeEventListener('pointerover', handleWindowPointerOver)
window.removeEventListener('pointerout', handleWindowPointerOut)
window.removeEventListener('pointermove', handlePointerMove)
}
}, [handleWindowPointerOver, handleWindowPointerOut, handlePointerMove])
return (
<animated.mesh
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onClick={handleClick()}
scale={springs.scale}
position={springs.position.to((x, y) => [x, y, 0])}>
<sphereGeometry args={[1.5, 64, 32]} />
<AnimatedMeshDistortMaterial
speed={5}
distort={0.5}
color={springs.color}
/>
</animated.mesh>
)
})
export default function MyComponent() {
const blobApi = useRef(null)
useEffect(() => {
const interval = setInterval(() => {
if (blobApi.current) {
const { x, y } = blobApi.current.getCurrentPosition()
console.log('the blob is at position', { x, y })
}
}, 2000)
return () => clearInterval(interval)
}, [])
return (
<Canvas>
<ambientLight intensity={0.8} />
<pointLight intensity={1} position={[0, 6, 0]} />
<MyScene ref={blobApi} />
</Canvas>
)
}
Troubleshooting
Experiencing Jank?
Whilst jank in react-three-fiber
cannot be purely blamed on react-spring
you
might find toward the end of an animation that there's a subtle jump,
which is visible in this demo. It's not pretty, is it?
Whilst by default it would be nice to have this issue resolved without you having
to interact and this is something we'll consider for the next breaking change
in the meantime what you can use is the precision
config prop to avoid this.
By setting the prop to a value like 0.0001
you can notice there is no jump towards 0.
This is because the precision prop is used to figure out how close the animated value
can get to the end goal before we consider the animated value to be equal to the end goal.