Jere Codes
Galleries
Back to Blog

Building a 3D Character Selector with Three.js and React

Jere on January 25, 2025
•
5 min read
threejs
react
3d
game-dev
react-three-fiber

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:

Loading 3D scene...

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:

  1. Limit visible objects - Only render characters near the selected one instead of all 18
  2. Level of detail (LOD) - Use simpler geometry for distant characters
  3. Instanced rendering - Share geometry across identical meshes
  4. 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.

Related Posts

Indoor Route Optimization with Mappedin SDK

Building a grocery shopping route optimizer using Mappedin's multi-destination routing API for retail picking, packing, and BOPIS operations.

January 18, 2026

The Fastest Quickstart to Web Maps with React

Build an interactive web map in React with custom markers in under 5 minutes using react-map-gl and OpenFreeMap.

October 26, 2025

Building an SVG Indoor Map Editor

How I built a complex SVG editing tool for Mappedin indoor maps using React and modern web APIs.

August 26, 2025
J

About Jere

Software developer passionate about indoor mapping, web technologies, and building useful tools.

GitHubTwitter/XYouTube