Three.js Camera Controls in Next.js: Interactive 3D Navigation
Building interactive 3D experiences in Next.js requires mastering camera controls to let users navigate your scenes naturally. Whether you're showcasing product models, architectural visualizations, or interactive demos, smooth camera movement makes the difference between a frustrating experience and an engaging one.
This tutorial walks you through implementing three essential camera control types in Next.js: OrbitControls for object inspection, FirstPersonControls for immersive navigation, and custom animations for guided experiences. You'll also learn mobile touch handling and performance optimization techniques that keep your 3D scenes running smoothly across all devices.
Prerequisites
Before starting, ensure you have:
- A Next.js project set up (version 13 or later)
- Three.js installed:
npm install three @types/three - Basic familiarity with React hooks and Three.js concepts
- A 3D model file (GLTF format recommended)
If you need help optimizing your 3D models for web performance, check out our guide on 3D model file size optimization.
Setting Up the Basic Three.js Scene
Start by creating a reusable Three.js component that will house your camera controls. Create components/ThreeScene.js:
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
export default function ThreeScene({ children, cameraType = 'orbit' }) {
const mountRef = useRef(null)
const sceneRef = useRef(null)
const rendererRef = useRef(null)
const cameraRef = useRef(null)
const controlsRef = useRef(null)
useEffect(() => {
if (!mountRef.current) return
// Scene setup
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setClearColor(0x222222)
mountRef.current.appendChild(renderer.domElement)
// Store references
sceneRef.current = scene
rendererRef.current = renderer
cameraRef.current = camera
// Initial camera position
camera.position.set(5, 5, 5)
camera.lookAt(0, 0, 0)
return () => {
if (mountRef.current && renderer.domElement) {
mountRef.current.removeChild(renderer.domElement)
}
renderer.dispose()
}
}, [])
return <div ref={mountRef} style={{ width: '100%', height: '100vh' }} />
}
This foundation gives you a responsive Three.js scene with proper cleanup. The component accepts a cameraType prop that we'll use to switch between different control schemes.
Implementing OrbitControls for Object Inspection
OrbitControls are perfect for product showcases and model viewers where users need to examine objects from all angles. Add this to your scene setup:
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
// Add this inside your useEffect, after camera setup
if (cameraType === 'orbit') {
const controls = new OrbitControls(camera, renderer.domElement)
// Configure orbit behavior
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.enableZoom = true
controls.enablePan = true
controls.enableRotate = true
// Set limits to prevent disorientation
controls.maxDistance = 50
controls.minDistance = 2
controls.maxPolarAngle = Math.PI * 0.75 // Prevent going under ground
controls.minPolarAngle = Math.PI * 0.1 // Prevent going too high
controlsRef.current = controls
// Animation loop
const animate = () => {
requestAnimationFrame(animate)
controls.update()
renderer.render(scene, camera)
}
animate()
}
OrbitControls work exceptionally well for e-commerce applications where customers need to inspect products. The damping creates smooth, natural movement that feels responsive without being jittery. Setting distance and angle limits prevents users from getting lost or confused about the object's orientation.
For touch devices, OrbitControls automatically handle pinch-to-zoom and touch-drag rotation. However, you might want to adjust the touch sensitivity:
// Enhanced touch handling
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN
}
controls.rotateSpeed = 0.5
controls.zoomSpeed = 0.8
Adding FirstPersonControls for Immersive Navigation
FirstPersonControls create an immersive experience perfect for architectural walkthroughs or game-like environments. This control scheme requires more setup but provides powerful navigation:
import { FirstPersonControls } from 'three/examples/jsm/controls/FirstPersonControls'
// Replace orbit controls setup with:
if (cameraType === 'firstPerson') {
const controls = new FirstPersonControls(camera, renderer.domElement)
// Configure first-person movement
controls.movementSpeed = 10
controls.lookSpeed = 0.1
controls.lookVertical = true
controls.constrainVertical = true
controls.verticalMin = 1.0
controls.verticalMax = 2.0
// Disable auto-forward (can be jarring)
controls.autoForward = false
controlsRef.current = controls
// Clock for smooth movement
const clock = new THREE.Clock()
const animate = () => {
requestAnimationFrame(animate)
const delta = clock.getDelta()
controls.update(delta)
renderer.render(scene, camera)
}
animate()
}
FirstPersonControls require a clock delta for smooth movement calculations. The movementSpeed and lookSpeed values need tuning based on your scene scale. For architectural visualization, slower speeds (5-15) work better than game-like speeds (50+).
One challenge with FirstPersonControls on mobile devices is the lack of keyboard input. Consider adding virtual joystick controls or gesture-based movement for touch interfaces.
Creating Custom Camera Animations
Sometimes you need guided camera movements for storytelling or highlighting specific features. Custom animations give you complete control:
import { gsap } from 'gsap'
// Custom animation function
function animateCameraToPosition(camera, controls, targetPosition, targetLookAt, duration = 2) {
// Disable controls during animation
if (controls) controls.enabled = false
const startPosition = camera.position.clone()
const startQuaternion = camera.quaternion.clone()
// Create temporary camera for target orientation
const tempCamera = camera.clone()
tempCamera.position.copy(targetPosition)
tempCamera.lookAt(targetLookAt)
// Animate position
gsap.to(camera.position, {
duration,
x: targetPosition.x,
y: targetPosition.y,
z: targetPosition.z,
ease: "power2.inOut"
})
// Animate rotation
gsap.to(camera.quaternion, {
duration,
x: tempCamera.quaternion.x,
y: tempCamera.quaternion.y,
z: tempCamera.quaternion.z,
w: tempCamera.quaternion.w,
ease: "power2.inOut",
onComplete: () => {
// Re-enable controls
if (controls) {
controls.enabled = true
controls.target.copy(targetLookAt)
controls.update()
}
}
})
}
// Usage example
const handleViewProduct = () => {
animateCameraToPosition(
cameraRef.current,
controlsRef.current,
new THREE.Vector3(2, 3, 4),
new THREE.Vector3(0, 0, 0)
)
}
Custom animations work brilliantly for onboarding sequences, feature highlights, or transitioning between different views. The key is temporarily disabling user controls during animations to prevent conflicts.
Optimizing Mobile Touch Handling
Mobile performance requires special attention for three.js camera controls nextjs applications. Touch events can be resource-intensive, especially with complex 3D scenes:
// Optimized touch handling
const setupMobileOptimizations = (controls, renderer) => {
let touchStartTime = 0
let isAnimating = false
const canvas = renderer.domElement
// Throttle touch events
canvas.addEventListener('touchstart', (e) => {
touchStartTime = Date.now()
})
canvas.addEventListener('touchmove', (e) => {
// Prevent scrolling on mobile
e.preventDefault()
// Throttle expensive updates
if (!isAnimating) {
isAnimating = true
requestAnimationFrame(() => {
isAnimating = false
})
}
}, { passive: false })
// Reduce render quality during interaction
canvas.addEventListener('touchstart', () => {
renderer.setPixelRatio(Math.min(window.devicePixelRatio * 0.5, 1))
})
canvas.addEventListener('touchend', () => {
// Restore full quality after interaction
setTimeout(() => {
renderer.setPixelRatio(window.devicePixelRatio)
}, 100)
})
}
This optimization reduces pixel ratio during touch interactions, maintaining smooth performance on mobile devices. The technique is particularly effective for complex scenes with high polygon counts.
Common Mistakes and Troubleshooting
Camera controls can behave unexpectedly if not configured properly. Here are the most frequent issues:
Controls not responding: Usually caused by missing controls.update() calls in your animation loop. OrbitControls with damping enabled require continuous updates even when not moving.
Jittery movement on mobile: Often results from conflicting touch event handlers. Ensure you're calling preventDefault() on touch events and not mixing multiple control types.
Memory leaks: Always dispose of controls in your cleanup function: controls.dispose(). This prevents event listeners from persisting after component unmount.
For Next.js applications specifically, server-side rendering can cause issues with Three.js. Wrap your 3D components in dynamic imports:
import dynamic from 'next/dynamic'
const ThreeScene = dynamic(() => import('../components/ThreeScene'), {
ssr: false
})
Next Steps
With camera controls mastered, consider enhancing your 3D Next.js applications further. Implement loading states for better user experience, add physics interactions with libraries like Cannon.js, or explore advanced lighting techniques for more realistic rendering.
For production applications, monitor performance metrics and consider implementing level-of-detail (LOD) systems for complex scenes. The Next.js production deployment guide covers optimization strategies that apply to 3D applications as well.
Camera controls form the foundation of engaging 3D web experiences. Master these patterns, and you'll create applications that feel natural and responsive across all devices.