PluginBench
Skill
Pass
Audit score 90

r3f-interaction

enzed/r3f-skills

Handle pointer events, camera controls, and 3D object interactions in React Three Fiber.

What is r3f-interaction?

React Three Fiber interaction skill provides pointer event handling (click, hover, drag), multiple camera control systems (OrbitControls, FlyControls, PointerLockControls, etc.), and transform gizmos for interactive 3D scenes. Use when building user input handling, camera navigation, or object manipulation in R3F applications.

  • Attach pointer events to meshes (onClick, onPointerOver, onPointerOut, onPointerMove, onDoubleClick, onContextMenu, onWheel)
  • Access intersection data including world coordinates, distance, UV, face normal, and ray information
  • Implement multiple camera control types: OrbitControls, MapControls, FlyControls, FirstPersonControls, PointerLockControls, CameraControls, TrackballControls, ArcballControls
  • Enable hover effects with state management and cursor changes
  • Use TransformControls gizmo for translate/rotate/scale operations on objects
  • Control raycasting behavior with selective layers and custom raycast functions

How to install r3f-interaction

npx skills add https://github.com/enzed/r3f-skills --skill r3f-interaction
Prerequisites
  • React Three Fiber (@react-three/fiber) installed
  • Three.js (@react-three/drei) utilities library installed
Claude Code
Cursor
Windsurf
Cline

How to use r3f-interaction

  1. 1.Add pointer event handlers (onClick, onPointerOver, onPointerOut) to mesh elements to detect user interaction
  2. 2.Access event properties like event.point for world coordinates and event.object for the clicked mesh
  3. 3.Choose appropriate camera controls based on your use case (OrbitControls for general 3D viewing, FlyControls for free movement, PointerLockControls for FPS)
  4. 4.Configure camera control parameters like enableDamping, minDistance, maxDistance, and rotation limits
  5. 5.Use TransformControls with a ref to enable object manipulation with visual gizmos
  6. 6.Manage state (useState) for hover effects and selection to update material colors or scale based on interaction

Use cases

Good for
  • Building interactive 3D product viewers with orbit camera and click detection
  • Creating FPS-style games with PointerLockControls and first-person camera
  • Implementing 3D model editors with transform gizmos for moving and rotating objects
  • Adding hover feedback and selection states to clickable 3D elements
  • Building map-based 3D interfaces with MapControls for top-down navigation
Who it's for
  • 3D web developers building interactive scenes
  • Game developers creating browser-based 3D games
  • Product visualization engineers
  • 3D modeling and CAD tool builders
  • Interactive data visualization developers

r3f-interaction FAQ

How do I detect which object was clicked?

Use the onClick event handler on a mesh. The event object contains event.object (the clicked mesh), event.point (world coordinates), and event.intersections (all intersected objects). Call event.stopPropagation() to prevent bubbling to parent objects.

How do I change the cursor on hover?

In onPointerOver, set document.body.style.cursor = 'pointer' (or other cursor values). Reset it in onPointerOut with document.body.style.cursor = 'default'.

Which camera control should I use?

OrbitControls for general 3D viewing around a target; MapControls for top-down maps; FlyControls for free movement; FirstPersonControls or PointerLockControls for FPS games; CameraControls for smooth animated transitions.

How do I disable interaction on specific objects?

Set raycast={() => null} on the mesh to exclude it from raycasting, or use layers to selectively raycast only against specific layer numbers.

Can I use both OrbitControls and TransformControls together?

Yes, but disable OrbitControls during transform operations by setting orbitRef.current.enabled = false in TransformControls onMouseDown, then re-enable it on mouse up.

Full instructions (SKILL.md)

Source of truth, from enzed/r3f-skills.


name: r3f-interaction description: React Three Fiber interaction - pointer events, controls, gestures, selection. Use when handling user input, implementing click detection, adding camera controls, or creating interactive 3D experiences.

React Three Fiber Interaction

Quick Start

import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'

function InteractiveMesh() {
  return (
    <mesh
      onClick={(e) => console.log('Clicked!', e.point)}
      onPointerOver={(e) => console.log('Hover')}
      onPointerOut={(e) => console.log('Unhover')}
    >
      <boxGeometry />
      <meshStandardMaterial color="hotpink" />
    </mesh>
  )
}

export default function App() {
  return (
    <Canvas>
      <ambientLight />
      <InteractiveMesh />
      <OrbitControls />
    </Canvas>
  )
}

Pointer Events

R3F provides built-in pointer events on mesh elements.

Available Events

<mesh
  // Click events
  onClick={(e) => {}}           // Click (pointerdown + pointerup on same object)
  onDoubleClick={(e) => {}}     // Double click
  onContextMenu={(e) => {}}     // Right click

  // Pointer events
  onPointerDown={(e) => {}}     // Pointer pressed
  onPointerUp={(e) => {}}       // Pointer released
  onPointerMove={(e) => {}}     // Pointer moved while over object
  onPointerOver={(e) => {}}     // Pointer enters object
  onPointerOut={(e) => {}}      // Pointer leaves object
  onPointerEnter={(e) => {}}    // Pointer enters object (no bubbling)
  onPointerLeave={(e) => {}}    // Pointer leaves object (no bubbling)
  onPointerMissed={(e) => {}}   // Click that missed all objects

  // Wheel
  onWheel={(e) => {}}           // Mouse wheel

  // Touch
  onPointerCancel={(e) => {}}   // Touch cancelled
>
  <boxGeometry />
  <meshStandardMaterial />
</mesh>

Event Object

function InteractiveMesh() {
  const handleClick = (event) => {
    // Stop propagation to parent objects
    event.stopPropagation()

    // Event properties
    console.log({
      object: event.object,           // The mesh that was clicked
      point: event.point,             // World coordinates of intersection
      distance: event.distance,       // Distance from camera
      face: event.face,               // Intersected face
      faceIndex: event.faceIndex,     // Face index
      uv: event.uv,                   // UV coordinates at intersection
      normal: event.normal,           // Face normal
      camera: event.camera,           // Current camera
      ray: event.ray,                 // Ray used for intersection
      intersections: event.intersections, // All intersections
      nativeEvent: event.nativeEvent, // Original DOM event
      delta: event.delta,             // Click distance (useful for drag detection)
    })
  }

  return (
    <mesh onClick={handleClick}>
      <boxGeometry />
      <meshStandardMaterial />
    </mesh>
  )
}

Hover Effects

import { useState } from 'react'

function HoverableMesh() {
  const [hovered, setHovered] = useState(false)

  return (
    <mesh
      onPointerOver={(e) => {
        e.stopPropagation()
        setHovered(true)
        document.body.style.cursor = 'pointer'
      }}
      onPointerOut={(e) => {
        setHovered(false)
        document.body.style.cursor = 'default'
      }}
      scale={hovered ? 1.2 : 1}
    >
      <boxGeometry />
      <meshStandardMaterial color={hovered ? 'hotpink' : 'orange'} />
    </mesh>
  )
}

Selective Raycasting

// Disable raycasting for specific objects
<mesh raycast={() => null}>
  <boxGeometry />
  <meshStandardMaterial />
</mesh>

// Or use layers
<mesh
  layers={1}  // Only raycast against layer 1
  onClick={() => console.log('clicked')}
>
  <boxGeometry />
  <meshStandardMaterial />
</mesh>

Camera Controls

OrbitControls

import { OrbitControls } from '@react-three/drei'

function Scene() {
  return (
    <>
      <mesh>
        <boxGeometry />
        <meshStandardMaterial />
      </mesh>

      <OrbitControls
        makeDefault                    // Use as default controls
        enableDamping                  // Smooth movement
        dampingFactor={0.05}
        enableZoom={true}
        enablePan={true}
        enableRotate={true}
        autoRotate={false}
        autoRotateSpeed={2}
        minDistance={2}
        maxDistance={50}
        minPolarAngle={0}              // Top limit
        maxPolarAngle={Math.PI / 2}    // Horizon limit
        minAzimuthAngle={-Math.PI / 4} // Left limit
        maxAzimuthAngle={Math.PI / 4}  // Right limit
        target={[0, 1, 0]}             // Look-at point
      />
    </>
  )
}

OrbitControls with Ref

import { OrbitControls } from '@react-three/drei'
import { useRef, useEffect } from 'react'

function Scene() {
  const controlsRef = useRef()

  useEffect(() => {
    // Access controls methods
    if (controlsRef.current) {
      controlsRef.current.reset()
      controlsRef.current.target.set(0, 1, 0)
      controlsRef.current.update()
    }
  }, [])

  return <OrbitControls ref={controlsRef} />
}

MapControls

Top-down map-style controls.

import { MapControls } from '@react-three/drei'

<MapControls
  enableDamping
  dampingFactor={0.05}
  screenSpacePanning={false}  // Pan in world space
  maxPolarAngle={Math.PI / 2}
/>

FlyControls

Free-flying camera controls.

import { FlyControls } from '@react-three/drei'

<FlyControls
  movementSpeed={10}
  rollSpeed={Math.PI / 24}
  dragToLook
/>

FirstPersonControls

FPS-style controls.

import { FirstPersonControls } from '@react-three/drei'

<FirstPersonControls
  movementSpeed={10}
  lookSpeed={0.1}
  lookVertical
/>

PointerLockControls

Lock pointer for FPS games.

import { PointerLockControls } from '@react-three/drei'
import { useRef } from 'react'

function Scene() {
  const controlsRef = useRef()

  return (
    <>
      <PointerLockControls ref={controlsRef} />

      {/* Click to lock pointer */}
      <mesh onClick={() => controlsRef.current?.lock()}>
        <planeGeometry args={[10, 10]} />
        <meshBasicMaterial color="green" />
      </mesh>
    </>
  )
}

CameraControls

Advanced camera controls with smooth transitions.

import { CameraControls } from '@react-three/drei'
import { useRef } from 'react'

function Scene() {
  const controlsRef = useRef()

  const focusOnObject = async () => {
    // Smooth transition to target
    await controlsRef.current?.setLookAt(
      5, 3, 5,    // Camera position
      0, 0, 0,    // Look-at target
      true        // Enable transition
    )
  }

  return (
    <>
      <CameraControls ref={controlsRef} />

      <mesh onClick={focusOnObject}>
        <boxGeometry />
        <meshStandardMaterial color="red" />
      </mesh>
    </>
  )
}

TrackballControls

Unconstrained rotation controls.

import { TrackballControls } from '@react-three/drei'

<TrackballControls
  rotateSpeed={2.0}
  zoomSpeed={1.2}
  panSpeed={0.8}
  staticMoving={true}
/>

ArcballControls

Arc-based rotation controls.

import { ArcballControls } from '@react-three/drei'

<ArcballControls
  enableAnimations
  dampingFactor={25}
/>

Transform Controls

Gizmo for moving/rotating/scaling objects.

import { TransformControls, OrbitControls } from '@react-three/drei'
import { useRef, useState } from 'react'

function Scene() {
  const meshRef = useRef()
  const [mode, setMode] = useState('translate')
  const orbitRef = useRef()

  return (
    <>
      <OrbitControls ref={orbitRef} makeDefault />

      <TransformControls
        object={meshRef}
        mode={mode}  // 'translate' | 'rotate' | 'scale'
        space="local"  // 'local' | 'world'
        onMouseDown={() => {
          // Disable orbit while transforming
          if (orbitRef.current) orbitRef.current.enabled = false
        }}
        onMouseUp={() => {
          if (orbitRef.current) orbitRef.current.enabled = true
        }}
      />

      <mesh ref={meshRef}>
        <boxGeometry />
        <meshStandardMaterial color="orange" />
      </mesh>

      {/* Mode switching buttons in HTML */}
      <div className="controls">
        <button onClick={() => setMode('translate')}>Move</button>
        <button onClick={() => setMode('rotate')}>Rotate</button>
        <button onClick={() => setMode('scale')}>Scale</button>
      </div>
    </>
  )
}

PivotControls

Alternative transform gizmo with pivot point.

import { PivotControls } from '@react-three/drei'

function Scene() {
  return (
    <PivotControls
      anchor={[0, 0, 0]}         // Anchor point
      depthTest={false}          // Always visible
      lineWidth={2}              // Axis line width
      axisColors={['red', 'green', 'blue']}
      scale={1}                  // Gizmo scale
      fixed={false}              // Fixed screen size
    >
      <mesh>
        <boxGeometry />
        <meshStandardMaterial color="orange" />
      </mesh>
    </PivotControls>
  )
}

Drag Controls

useDrag from @use-gesture/react

npm install @use-gesture/react
import { useDrag } from '@use-gesture/react'
import { useSpring, animated } from '@react-spring/three'
import { useThree } from '@react-three/fiber'

function DraggableMesh() {
  const { size, viewport } = useThree()
  const aspect = size.width / viewport.width

  const [spring, api] = useSpring(() => ({
    position: [0, 0, 0],
    config: { mass: 1, tension: 280, friction: 60 }
  }))

  const bind = useDrag(({ movement: [mx, my], down }) => {
    api.start({
      position: down ? [mx / aspect, -my / aspect, 0] : [0, 0, 0]
    })
  })

  return (
    <animated.mesh {...bind()} position={spring.position}>
      <boxGeometry />
      <meshStandardMaterial color="hotpink" />
    </animated.mesh>
  )
}

DragControls (Drei)

import { DragControls, OrbitControls } from '@react-three/drei'
import { useRef } from 'react'

function Scene() {
  const meshRef = useRef()
  const orbitRef = useRef()

  return (
    <>
      <OrbitControls ref={orbitRef} makeDefault />

      <DragControls
        onDragStart={() => {
          if (orbitRef.current) orbitRef.current.enabled = false
        }}
        onDragEnd={() => {
          if (orbitRef.current) orbitRef.current.enabled = true
        }}
      >
        <mesh ref={meshRef}>
          <boxGeometry />
          <meshStandardMaterial color="orange" />
        </mesh>
      </DragControls>
    </>
  )
}

Keyboard Controls

KeyboardControls (Drei)

import { KeyboardControls, useKeyboardControls } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'

// Define key mappings
const keyMap = [
  { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
  { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
  { name: 'left', keys: ['ArrowLeft', 'KeyA'] },
  { name: 'right', keys: ['ArrowRight', 'KeyD'] },
  { name: 'jump', keys: ['Space'] },
  { name: 'sprint', keys: ['ShiftLeft'] },
]

function Player() {
  const meshRef = useRef()
  const [, getKeys] = useKeyboardControls()

  useFrame((state, delta) => {
    const { forward, backward, left, right, jump, sprint } = getKeys()

    const speed = sprint ? 10 : 5

    if (forward) meshRef.current.position.z -= speed * delta
    if (backward) meshRef.current.position.z += speed * delta
    if (left) meshRef.current.position.x -= speed * delta
    if (right) meshRef.current.position.x += speed * delta
    if (jump) meshRef.current.position.y += speed * delta
  })

  return (
    <mesh ref={meshRef}>
      <boxGeometry />
      <meshStandardMaterial color="blue" />
    </mesh>
  )
}

export default function App() {
  return (
    <KeyboardControls map={keyMap}>
      <Canvas>
        <ambientLight />
        <Player />
      </Canvas>
    </KeyboardControls>
  )
}

Subscribe to Key Changes

import { useKeyboardControls } from '@react-three/drei'
import { useEffect } from 'react'

function KeyListener() {
  const jumpPressed = useKeyboardControls((state) => state.jump)

  useEffect(() => {
    if (jumpPressed) {
      console.log('Jump!')
    }
  }, [jumpPressed])

  return null
}

Selection System

Click to Select

import { useState } from 'react'

function SelectableScene() {
  const [selected, setSelected] = useState(null)

  return (
    <>
      {[[-2, 0, 0], [0, 0, 0], [2, 0, 0]].map((position, i) => (
        <mesh
          key={i}
          position={position}
          onClick={(e) => {
            e.stopPropagation()
            setSelected(i)
          }}
        >
          <boxGeometry />
          <meshStandardMaterial
            color={selected === i ? 'hotpink' : 'orange'}
            emissive={selected === i ? 'hotpink' : 'black'}
            emissiveIntensity={0.3}
          />
        </mesh>
      ))}

      {/* Click on empty space to deselect */}
      <mesh
        position={[0, -1, 0]}
        rotation={[-Math.PI / 2, 0, 0]}
        onClick={() => setSelected(null)}
      >
        <planeGeometry args={[20, 20]} />
        <meshStandardMaterial color="gray" />
      </mesh>
    </>
  )
}

Multi-Select with Outline

import { useState } from 'react'
import { EffectComposer, Outline, Selection, Select } from '@react-three/postprocessing'

function MultiSelectScene() {
  const [selected, setSelected] = useState(new Set())

  const toggleSelect = (id, event) => {
    event.stopPropagation()
    setSelected((prev) => {
      const next = new Set(prev)
      if (event.shiftKey) {
        // Multi-select with shift
        if (next.has(id)) {
          next.delete(id)
        } else {
          next.add(id)
        }
      } else {
        // Single select
        next.clear()
        next.add(id)
      }
      return next
    })
  }

  return (
    <Selection>
      <EffectComposer autoClear={false}>
        <Outline
          blur
          visibleEdgeColor={0xffffff}
          edgeStrength={10}
        />
      </EffectComposer>

      {[0, 1, 2, 3, 4].map((id) => (
        <Select key={id} enabled={selected.has(id)}>
          <mesh
            position={[(id - 2) * 2, 0, 0]}
            onClick={(e) => toggleSelect(id, e)}
          >
            <boxGeometry />
            <meshStandardMaterial color="orange" />
          </mesh>
        </Select>
      ))}
    </Selection>
  )
}

Screen-Space to World-Space

Get World Position from Click

import { useThree } from '@react-three/fiber'
import * as THREE from 'three'

function ClickToPlace() {
  const { camera, raycaster, pointer } = useThree()
  const planeRef = useRef()

  const handleClick = (event) => {
    // Create intersection plane
    const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
    const intersection = new THREE.Vector3()

    // Cast ray from pointer
    raycaster.setFromCamera(pointer, camera)
    raycaster.ray.intersectPlane(plane, intersection)

    console.log('World position:', intersection)
  }

  return (
    <mesh
      ref={planeRef}
      rotation={[-Math.PI / 2, 0, 0]}
      onClick={handleClick}
    >
      <planeGeometry args={[100, 100]} />
      <meshBasicMaterial visible={false} />
    </mesh>
  )
}

World Position to Screen Position

import { useThree, useFrame } from '@react-three/fiber'
import { Html } from '@react-three/drei'
import * as THREE from 'three'

function WorldToScreen({ target }) {
  const { camera, size } = useThree()

  const getScreenPosition = (worldPos) => {
    const vector = worldPos.clone()
    vector.project(camera)

    return {
      x: (vector.x * 0.5 + 0.5) * size.width,
      y: (1 - (vector.y * 0.5 + 0.5)) * size.height
    }
  }

  // Or use Html component which handles this automatically
  return (
    <Html position={target}>
      <div className="label">Label</div>
    </Html>
  )
}

Gesture Recognition

usePinch and useWheel

import { usePinch, useWheel } from '@use-gesture/react'
import { useSpring, animated } from '@react-spring/three'

function ZoomableMesh() {
  const [spring, api] = useSpring(() => ({
    scale: 1,
    config: { mass: 1, tension: 200, friction: 30 }
  }))

  usePinch(
    ({ offset: [s] }) => {
      api.start({ scale: s })
    },
    { target: window }
  )

  useWheel(
    ({ delta: [, dy] }) => {
      api.start({ scale: spring.scale.get() - dy * 0.001 })
    },
    { target: window }
  )

  return (
    <animated.mesh scale={spring.scale}>
      <boxGeometry />
      <meshStandardMaterial color="cyan" />
    </animated.mesh>
  )
}

Scroll Controls

import { Canvas } from '@react-three/fiber'
import { ScrollControls, Scroll, useScroll } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'

function AnimatedOnScroll() {
  const meshRef = useRef()
  const scroll = useScroll()

  useFrame(() => {
    const offset = scroll.offset // 0 to 1
    meshRef.current.rotation.y = offset * Math.PI * 2
    meshRef.current.position.y = offset * 5
  })

  return (
    <mesh ref={meshRef}>
      <boxGeometry />
      <meshStandardMaterial color="orange" />
    </mesh>
  )
}

export default function App() {
  return (
    <Canvas>
      <ScrollControls pages={3} damping={0.25}>
        <Scroll>
          <AnimatedOnScroll />
        </Scroll>

        {/* HTML content that scrolls */}
        <Scroll html>
          <h1 style={{ position: 'absolute', top: '10vh' }}>Page 1</h1>
          <h1 style={{ position: 'absolute', top: '110vh' }}>Page 2</h1>
          <h1 style={{ position: 'absolute', top: '210vh' }}>Page 3</h1>
        </Scroll>
      </ScrollControls>
    </Canvas>
  )
}

Presentation Controls

For product showcases with limited rotation.

import { PresentationControls } from '@react-three/drei'

function ProductShowcase() {
  return (
    <PresentationControls
      global                 // Apply to whole scene
      snap                   // Snap back when released
      speed={1}              // Rotation speed
      zoom={1}               // Zoom speed
      rotation={[0, 0, 0]}   // Initial rotation
      polar={[-Math.PI / 4, Math.PI / 4]}    // Vertical limits
      azimuth={[-Math.PI / 4, Math.PI / 4]}  // Horizontal limits
      config={{ mass: 1, tension: 170, friction: 26 }}
    >
      <mesh>
        <boxGeometry />
        <meshStandardMaterial color="gold" />
      </mesh>
    </PresentationControls>
  )
}

Performance Tips

  1. Stop propagation: Prevent unnecessary raycasts
  2. Use layers: Filter raycast targets
  3. Simpler collision meshes: Use invisible simple geometry
  4. Throttle events: Limit onPointerMove frequency
  5. Disable controls when not needed: enabled={false}
// Use simpler geometry for raycasting
function OptimizedInteraction() {
  return (
    <group>
      {/* Complex visible mesh */}
      <mesh raycast={() => null}>
        <torusKnotGeometry args={[1, 0.4, 100, 16]} />
        <meshStandardMaterial color="purple" />
      </mesh>

      {/* Simple invisible collision mesh */}
      <mesh onClick={() => console.log('clicked')}>
        <sphereGeometry args={[1.5]} />
        <meshBasicMaterial visible={false} />
      </mesh>
    </group>
  )
}

// Throttle pointer move events
import { useMemo, useCallback } from 'react'
import throttle from 'lodash/throttle'

function ThrottledHover() {
  const handleMove = useMemo(
    () => throttle((e) => {
      console.log('Move', e.point)
    }, 100),
    []
  )

  return (
    <mesh onPointerMove={handleMove}>
      <boxGeometry />
      <meshStandardMaterial />
    </mesh>
  )
}

See Also

  • r3f-fundamentals - Canvas and scene setup
  • r3f-animation - Animating interactions
  • r3f-postprocessing - Visual feedback effects (outline, selection)