Shogo Oshima

—Web Developer

Sketches Blog About
GitHub X

Using hover, click, and other events in React Three Fiber

R3FThree.js

Last time, I covered React Three Fiber basics, from first scene to simple animation.

This time, as a follow-up, let’s make things respond to events like hover and click.

Using these kinds of events in plain three.js means implementing them with a Raycaster1, which takes a bit of work.

In React Three Fiber, though, you can add hover effects and the like just by passing props to a component.

Reacting to events with props

First, let’s print clicked with console.log() when the mesh is clicked:

<mesh ref={meshRef} onClick={() => console.log('clicked')}>
  <sphereGeometry args={[2, 16, 16]} />
  <meshStandardMaterial color="#5B5BD6" flatShading />
</mesh>

That’s it. Ridiculously easy.

For a hover effect, use onPointerEnter:

<mesh ref={meshRef} onPointerEnter={() => console.log('hovered')}>

console.log() alone is boring, so let’s change the mesh’s appearance on hover.

For cases like this, we bring React state into the mix.

Combining with React state

First, import useState and set up an isActive state:

// ...

import { useRef, useState } from 'react';
// ...

function Sphere() {
  const [isActive, setIsActive] = useState(false);

  // ...
}

We’ll activate on hover and deactivate when the pointer leaves, using onPointerEnter and onPointerLeave respectively:

<mesh
  ref={meshRef}
  onPointerEnter={() => setIsActive(true)}
  onPointerLeave={() => setIsActive(false)}
>

Now let’s make the material’s appearance depend on isActive — here, a metallic orange:

<mesh
  ref={meshRef}
  onPointerEnter={() => setIsActive(true)}
  onPointerLeave={() => setIsActive(false)}
>
  <sphereGeometry args={[2, 16, 16]} />
  <meshStandardMaterial
    metalness={isActive ? 0.5 : 0}
    roughness={isActive ? 0.25 : 1}
    color={isActive ? '#F76B15' : '#5B5BD6'}
    flatShading
  />
</mesh>

Looking good.

Still a little understated, though — so let’s add lights to the scene when the mesh is hovered.

Updating the scene on mesh hover

Right now the isActive state lives in the <Sphere /> component, but to update the scene, the <App /> component needs to own it. So let’s lift the state up.

We also pass setIsActive down as a prop so the <Sphere /> component can still update it:

function App() {
  const [isActive, setIsActive] = useState(false);

  return (
    <Canvas>
      <directionalLight args={['white', 1.5]} position={[0.5, 0.5, 1]} />
      <Sphere isActive={isActive} setIsActive={setIsActive} />
    </Canvas>
  );
}

Remove the useState() from the <Sphere /> component and add the props:

function Sphere() { 
  const [isActive, setIsActive] = useState(false); 
function Sphere({ 
  isActive, 
  setIsActive, 
}: { 
  isActive: boolean; 
  setIsActive: (isActive: boolean) => void; 
}) { 
  useFrame(({ clock }) => {
    if (!meshRef.current) return;
    meshRef.current.rotation.y = clock.elapsedTime * 0.3;
    meshRef.current.rotation.z = clock.elapsedTime * 0.2;
  });
  const meshRef = useRef<Mesh>(null);

  return (
    <mesh
      ref={meshRef}
      onPointerEnter={() => setIsActive(true)}
      onPointerLeave={() => setIsActive(false)}
    >
      <sphereGeometry args={[2, 16, 16]} />
      <meshStandardMaterial
        metalness={isActive ? 0.5 : 0}
        roughness={isActive ? 0.25 : 1}
        color={isActive ? '#F76B15' : '#5B5BD6'}
        flatShading
      />
    </mesh>
  );
}

Now hovering the mesh updates the isActive state in the <App /> component.

Let’s try adding a single light: show a <pointLight /> when isActive is true:

<Canvas>
  <directionalLight args={['white', 1.5]} position={[0.5, 0.5, 1]} />
  {isActive && <pointLight intensity={30} position={[0, 3, 1]} />}
  <Sphere isActive={isActive} setIsActive={setIsActive} />
</Canvas>

A bit more festive already!

Finally, let’s add even more lights, animate them, and go for a full glitter effect.

First, import the PointLight and DirectionalLight types for use with useRef:

import type { Mesh, PointLight, DirectionalLight } from 'three';

Create a <DiscoLights /> component:

function DiscoLights() {
  const lightsRef = useRef<PointLight[] | DirectionalLight[]>([]);

  useFrame(({ clock }) => {
    lightsRef.current.forEach((light, index) => {
      if (!light) return;

      const speed = 0.4 + index * 0.1;
      const offset = index * 2;
      const radius = 3 + (index % 2);

      if (index % 2 === 0) {
        light.position.x =
          Math.sin(clock.elapsedTime * speed + offset) * radius;
        light.position.y =
          Math.cos(clock.elapsedTime * speed + offset) * radius;
      } else {
        light.position.y =
          Math.sin(clock.elapsedTime * speed + offset) * radius;
        light.position.z =
          Math.cos(clock.elapsedTime * speed + offset) * radius;
      }
    });
  });

  return (
    <>
      {[
        { color: 'red', intensity: 5, type: 'point' },
        { color: 'green', intensity: 5, type: 'point' },
        { color: 'purple', intensity: 4, type: 'point' },
        { color: 'orange', intensity: 3, type: 'point' },
        { color: 'cyan', intensity: 5, type: 'point' },
        { color: 'blue', intensity: 8, type: 'directional' },
        { color: 'green', intensity: 5, type: 'directional' },
        { color: 'purple', intensity: 6, type: 'directional' },
        { color: 'red', intensity: 3, type: 'directional' },
      ].map(({ color, intensity, type }, index) =>
        type === 'point' ? (
          <pointLight
            key={`${type}-${color}`}
            ref={(el) => {
              if (el) lightsRef.current[index] = el;
            }}
            color={color}
            intensity={intensity}
          />
        ) : (
          <directionalLight
            key={`${type}-${color}`}
            ref={(el) => {
              if (el) lightsRef.current[5 + index] = el;
            }}
            color={color}
            intensity={intensity}
          />
        ),
      )}
      <pointLight color="white" intensity={10} position={[0, 2, 1]} />
      <pointLight color="white" intensity={10} position={[0, -3, 1]} />
    </>
  );
}

Make the active material even more metallic:

<meshStandardMaterial
  metalness={isActive ? 0.9 : 0}
  roughness={isActive ? 0.2 : 1}
  color={isActive ? '#FFF' : '#5B5BD6'}
  flatShading
/>

Add <DiscoLights /> to <App />:

function App() {
  const [isActive, setIsActive] = useState(false);

  return (
    <Canvas>
      <directionalLight args={['white', 1.5]} position={[0.5, 0.5, 1]} />
      {isActive && <DiscoLights />}
      <Sphere isActive={isActive} setIsActive={setIsActive} />
    </Canvas>
  );
}

And here’s the result — try hovering it yourself:

Sparkly!

Wrapping up

In R3F, responding to hover, click, and other events takes nothing more than props — wonderfully painless.

Combine that with React state to add components or change a mesh’s appearance in response, and making 3D objects interactive becomes trivial.

While building the demo for this post, the actual event-handling part was over so quickly that I ended up making the mesh needlessly sparkly.

That’s another thing I like about R3F: it keeps the setup time minimal so you can spend your time on the fun presentational parts.

React Three Fiber keeps getting more appealing!

Footnotes

  1. Raycaster – three.js docs: Raycasting is used for mouse picking (working out what objects in the 3d space the mouse is over) amongst other things. ↩

References