Creating an Interactive Animation with React Spring + React Three Fiber
Recently I rebuilt my site with Next.js. For some time, I'd been curious about React Spring and React Three Fiber, and figured this would be a good opportunity to try them out. There's a lot of tutorials about both so I'm not really going to go into the details of each API here. Instead I'll discuss the approach I took and some of the issues I ran into while making this animation, which I'll refer to as the field:
The animation consists of three components. A top-level <Field />
consisting of a <canvas />
and a <FieldGroup />
component, which is an instance of THREE.Group
. <FieldCircle />
are instancess of THREE.Mesh
, and create the actual circles.
<FieldCircle />
This is the most atomic part of the field. It uses a useSpringRef
from React Spring Three instead of React Spring because its applied to a <mesh />
. The component takes care of animating itself in on load, and also adds itself to a passed in refArray
, which is key for coordinating its movement in the parent <FieldGroup />
.
export function FieldCircle({ pos, index, refArray, debug = false }) {
const ref = useSpringRef()
const inDelay = 5 * index
const inConfig = { friction: 10, tension: 320 }
const { scale } = useSpring({
delay: inDelay,
config: inConfig,
from: {
scale: 0,
},
to: {
scale: 1,
},
})
const { position } = useSpring({
ref: ref,
delay: 4000 * index,
position: pos,
})
useEffect(() => {
refArray.push(ref)
}, [])
return (
<animated.mesh ref={ref} key={index} scale={scale} position={position}>
<>
<circleGeometry args={[0.05, 68]} />
<meshBasicMaterial />
</>
</animated.mesh>
)
}
<FieldGroup />
After setting up the canvas, I needed to create a grid of coordinates to map to the circles:
const OFFSET = 0.3
const xPositions: number[] = Array(13)
.fill([])
.map((el, index) => {
const x = index * OFFSET - 1.8
return x
})
export const positions: [number, number, number][] = Array(18)
.fill([])
.map((el, index) => {
const y = index * OFFSET - 2.6
return [y, 0]
})
.sort((a, b) => b[0] - a[0])
.map(([y, z]): [number, number, number][] => {
return xPositions.map((x) => [x, y, z])
})
.reduce((a, b) => {
return a.concat(b)
})
This gave back an 2-dimensional array of 234 items with each element something like [-1.5, 2.5, 0]
, mapping to 13 columns and 18 rows. The first element in each inner array represents x
, increasing from left to right, with second element representing y
, increasing from top to bottom. The third element, z
, is always zero.
<FieldGroup />
then takes those positions and renders a <FieldCircle />
. It also renders another <mesh />
that is responsible for tracking mouse movement and clicks.
<mesh
ref={planeRef}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerOver={onPointerOver}
onPointerOut={onPointerOut}
>
<planeGeometry args={[9.3, 8]} />
<meshStandardMaterial opacity={debug ? 1 : 0} color={'black'} />
</mesh>
<group ref={groupRef}>
{positions.map((pos, index) => {
return (
<FieldCircle
pos={pos}
index={index}
refArray={refArray}
key={index}
debug={false}
/>
)
})}
</group>
The <planeGeometry args={[9.3, 8]} />
gives dimensions to the wrapping <mesh />
. Essentially, the <planeGeometry />
acts as a hit area to receive pointer events. The field of <FieldCircles />
is positioned overlaying the <planeGeometry />
.
The pointer events are just used to track state for some of the interactive logic.
const [hovered, setHover] = useState(null)
const [down, setDown] = useState(null)
function onPointerDown() {
setDown(true)
}
function onPointerUp() {
setDown(false)
}
function onPointerOver() {
setHover(true)
}
function onPointerOut() {
setHover(false)
}
The refArray
is an array of refs that map to <FieldCircle />
instances.
const ogRefArray = useRef([])
const refArray = ogRefArray.current
The useFrame
hook from React Three Fiber allows you to execute code on every rendered frame, just like requestAnimationFrame
. You receive the state model, which contains the default renderer, the scene, camera, pointer events, etc. When a component unmounts, its automatically unsubscribed from the rener-loop, which makes it pretty handy to work with.
useFrame(({ raycaster, mouse, camera }) => {
if (!hovered) return
if (!planeRef.current) return
raycaster.setFromCamera(mouse, camera)
// tracks mouse position over the <planeGeometry />
const intersects = raycaster.intersectObjects([planeRef.current])
if (intersects.length === 1) {
// get mouse coordinates relative to <planeGeometry />
const { x: _x, y: _y } = intersects[0].point
const RADIUS = down ? 0.9 : 1.2
// generates an array of all points on the circumference of
// the current mouse position at given RADIUS.
// bascially, this creates a circle that follows the mouse
const allPointsInCircumference = circleDegrees.map((deg) =>
pointOnCircumference(_x, _y, RADIUS, radians(deg))
)
// iterate over each <FieldCircle />
groupRef.current.children.forEach((mesh: Mesh, index) => {
if (!hovered) return
if (!positions[index]) return
// grab their original positions
const ogXPos = positions[index][0]
const ogYPos = positions[index][1]
// grab the <FieldCircle /> ref object
const ref = refArray[index]
// distance of mouse from the <FieldCircle /> mesh's original position
const originalMouseDistance = distance(
_x,
_y,
ogXPos + groupRef.current.position.x,
ogYPos + groupRef.current.position.y
)
// distance of mouse from the <FieldCircle /> mesh as it animates
const mouseDistance = distance(
_x,
_y,
mesh.position.x + groupRef.current.position.x,
mesh.position.y + groupRef.current.position.y
)
// is the mouse within the RADIUS of the <FieldCircle />'s
// orignal position?
const inZone = getZone(originalMouseDistance, RADIUS)
// if so, need to animate it away
if (inZone) {
// grab the coordinate that is closest to this <FieldCircle />'s
// original position from the mouse's current circumference
const closestPoint = kClosest(
allPointsInCircumference,
1,
ogXPos,
ogYPos
)
// react-spring API
// animate to that closest point
ref.start({
to: { position: [closestPoint[0].x, closestPoint[0].y, 0] },
config: { mass: 1, friction: 15, tension: 300 },
})
}
// if not, need to animate it back to original position
else {
ref.start({
to: { position: [ogXPos, ogYPos, 0] },
delay: mouseDistance,
config: {
mass: 1.5,
friction: 50 / mouseDistance,
tension: 250,
},
})
}
})
}
})
A few key functions were needed to get the above working properly. First, I needed to figure out a way to keep track of all the points surrounding the mouse as it moved. I was imagining an invisible circle following the mouse.
// generate an array from 0 - 361
const circleDegrees = Array(361)
.fill(0)
.map((el, idx) => idx)
// calc degree in radians
export const radians = (degrees) => {
return (degrees * Math.PI) / 180
}
// parabolic equation
// given an x, y, radius and degree in radians,
// calculates a given point on a circle
// https://stackoverflow.com/questions/839899/how-do-i-calculate-a-point-on-a-circle-s-circumference
const pointOnCircumference = (cx, cy, r, radians) => {
return { x: cx + r * Math.cos(radians), y: cy + r * Math.sin(radians) }
}
// The above are used to grab all points on
// the circumference of the current mouse position
const allPointsInCircumference = circleDegrees.map((deg) =>
pointOnCircumference(_x, _y, RADIUS, radians(deg))
)
Now that I had the coordinates for this invisible circle, I needed to examine each point, and move each <FieldCircle />
to whatever point was the closest to that circle's original position.
// Calculate and return the squared Euclidean distance
const squaredDistance = ({ x, y }, cx, cy) => (x - cx) ** 2 + (y - cy) ** 2
// compares an array of points and sorts them by distance
// returns the first sorted item, which is the closest
const kClosest = function (points, k, cx, cy) {
// Sort the array with a custom lambda comparator function
points.sort((a, b) => squaredDistance(a, cx, cy) - squaredDistance(b, cx, cy))
// Return the first k elements of the sorted array
return points.slice(0, k)
}
// within useFrame, grabbing the closest point to the circle's original position
const closestPoint = kClosest(allPointsInCircumference, 1, ogXPos, ogYPos)
Debugging
To help debug the animation as I worked on it, I added in some options for a clearer visualization. This really helped me in determining what a given circle was doing as the mouse moved over the field, and helped me identify some of the issues that arose with my first pass.
Issues
I ran into a couple time consuming issues as I worked on this. The first was that I wanted to tweak the colors of the field when toggling the theme. It took me some time to discover that React context cannot be used between two renderes. After some reading I decided on using jotai, from the creators of React Spring and React Three Fiber. This slightly increased the learning curve for what was supposed to be a simpler task. But, I was able to convert the needed state to use it relatively quickly.
Another issue, which still persists, is a very mysterious bug in which the theme toggler only animates to its relevant state (sun/moon), when on the home page, where the <Field />
exists in the DOM. Everything else about the theme toggle still works - the theme updates correctly. It's just that the animation, which is also using React Spring, doesn't update if you are on any other page.
After much experimentation, it was only after commenting out some code in <FieldGroup />
(specifically using an <animatied.mesh />
within a <group />
) was the toggle back working.
This was quite maddening as removing <Field />
entirely from the home page didn't fix the issue. I can only assume Spring and/or Fiber were somehow still evaluating and doing something with the code, so that its internal state was affected and caused issues with the animation.
Probably I'm doing something wrong but I got tired of trying to figure out what.
Anyway, the hack I implemented was to add a hidden, empty <Canvas />
from React Fiber to the header, which is where the theme toggle lives, and that solved everything.
Conclusion
While I enjoyed diving into React Spring and React Three Fiber and am quite happy with the result, I probably could have skipped using Fiber and instead used svg
elements, and animated on those. I have a feeling React Spring could have handled that just fine, performance wise. This proably would have saved a lot of time for all issues I described above. But it did pique my interest about custom React renderers, something I'd like to take a look at in the future.