Building a 3D Character Selector with Three.js and React
Character selection screens are a staple of video games. That moment when you browse through available characters, each one rotating in the spotlight while others wait in the wings, creates anticipation before the game even starts. I wanted to recreate this experience for the web using Three.js and React.
The Goal
Build a character selector that:
- Displays a carousel of 3D characters
- Highlights the selected character with a spotlight effect
- Allows navigation via keyboard arrows or clicking
- Automatically rotates the selected character
- Works smoothly in a React application
The Kenney Assets
For this project, I'm using the Kenney Blocky Characters pack - a collection of 18 unique low-poly characters in GLB format. These models are perfect for web use: small file sizes, clean geometry, and a charming aesthetic.
The pack includes:
- 18 unique character models (character-a through character-r)
- Matching texture files for each character
- Static meshes (no rigging or animations)
React Three Fiber Setup
React Three Fiber provides a declarative way to work with Three.js in React. Combined with Drei (a collection of useful helpers), we can build complex 3D scenes with familiar React patterns.
import { Canvas } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
function Scene() {
return (
<Canvas camera={{ position: [0, 1, 5], fov: 50 }}>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 10, 5]} intensity={0.8} />
<CharacterCarousel />
<Environment preset="studio" />
</Canvas>
)
}
Loading GLB Models
The useGLTF hook from Drei makes loading 3D models straightforward:
function CharacterModel({ characterId }: { characterId: string }) {
const modelPath = `/kenney-blocky-characters-20/character-${characterId}.glb`
const { scene } = useGLTF(modelPath)
// Clone the scene to avoid sharing geometry between instances
const clonedScene = useMemo(() => scene.clone(), [scene])
return <primitive object={clonedScene} />
}
The key insight here is cloning the scene. When displaying multiple instances of the same model, Three.js shares the geometry by default. Cloning ensures each character can be positioned and animated independently.
Circular Carousel
Characters are arranged in a circle, allowing infinite rotation in either direction. Each character is positioned using trigonometry:
const CIRCLE_RADIUS = 4
const ANGLE_PER_CHARACTER = (Math.PI * 2) / CHARACTERS.length
// Position on the circle
const x = Math.sin(angle) * CIRCLE_RADIUS
const z = Math.cos(angle) * CIRCLE_RADIUS - CIRCLE_RADIUS
// Scale based on distance from selected (front is bigger)
const normalizedDistance = distanceFromSelected / (CHARACTERS.length / 2)
const scale = THREE.MathUtils.lerp(1.1, 0.5, normalizedDistance)
When selecting a character, the entire carousel rotates smoothly to bring them to the front. The rotation takes the shortest path around the circle:
const handleSelect = (index: number) => {
let diff = index - selectedIndex
// Find shortest path around the circle
if (diff > CHARACTERS.length / 2) {
diff -= CHARACTERS.length
} else if (diff < -CHARACTERS.length / 2) {
diff += CHARACTERS.length
}
setTargetRotation(prev => prev - diff * ANGLE_PER_CHARACTER)
}
Smooth Easing Transitions
The carousel rotation uses frame-based interpolation for buttery smooth transitions. Instead of snapping to the target rotation, we gradually approach it each frame:
useFrame((_, delta) => {
if (groupRef.current) {
const diff = targetRotation - currentRotation.current
// Smooth interpolation - approaches target asymptotically
const step = diff * Math.min(delta * 4, 1)
currentRotation.current += step
groupRef.current.rotation.y = currentRotation.current
}
})
This creates an "ease-out" effect where the carousel starts fast and slows down as it approaches the target position.
Spotlight Effect
The selected character gets a spotlight that creates visual focus:
{isSelected && (
<spotLight
position={[0, 5, 2]}
angle={0.4}
penumbra={0.5}
intensity={1.5}
castShadow
/>
)}
Combined with ContactShadows from Drei, this creates a nice grounded feel:
<ContactShadows
position={[0, -1.5, 0]}
opacity={0.4}
scale={10}
blur={2}
far={4}
/>
Oscillating Animation
Rather than a full 360-degree spin, the selected character gently oscillates left and right to show off different angles. Using the useFrame hook with a sine wave creates this natural motion:
useFrame((state) => {
if (groupRef.current && isSelected) {
// Oscillate between -30 and +30 degrees
const oscillation = Math.sin(state.clock.elapsedTime * 0.8) * 0.5
groupRef.current.rotation.y = oscillation
}
})
This runs every frame (typically 60fps), giving smooth animation without any state updates. The sine wave creates a pendulum-like motion that feels natural.
Keyboard Navigation
Adding keyboard support improves accessibility and feels natural for a character select screen:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') handlePrevious()
if (e.key === 'ArrowRight') handleNext()
if (e.key === 'Enter') handleConfirm()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedIndex])
Try It Out
Here's the character selector embedded right in this post. Use the arrow buttons, click on characters, or use your keyboard to navigate:
You can also visit the full-page version for an immersive experience.
Room for Optimization
There are several performance optimizations I could add but didn't need for this demo:
- Limit visible objects - Only render characters near the selected one instead of all 18
- Level of detail (LOD) - Use simpler geometry for distant characters
- Instanced rendering - Share geometry across identical meshes
- Texture atlasing - Combine textures to reduce draw calls
For 18 low-poly characters, the browser handles everything smoothly without these optimizations. But they'd become important with more complex models or larger scenes.
What's Next: Building a Game
This character selector is the first piece of a larger project. The next step is combining it with the Mappedin SDK to create an indoor navigation game where your selected character explores a 3D map.
The Mappedin SDK handles GLTF/GLB models natively and performantly through its Model component. Instead of managing Three.js scenes directly, you can place 3D characters on indoor maps with a simple coordinate:
import { Model } from '@mappedin/react-sdk'
<Model
coordinate={mapView.createCoordinate(lat, lng, floor)}
url="/character-model.glb"
options={{ scale: 0.65, rotation: [90, 0, 0] }}
/>
The SDK handles all the rendering, floor transitions, and coordinate systems. Combined with its pathfinding API (getDirections), you can create characters that navigate through buildings, find optimal routes, and interact with indoor spaces.
Check out the Mappedin Map Explorer to see the SDK in action, or visit the full character selector to pick your character for the upcoming game.