Scene Customization with React-Three-Fiber: useFrame, Shader and Controls
In my previous articles, we created a basic 3D scene with React-Three-Fiber (R3F), built a rotating cube, and learned how to integrate GLTF/GLB models into the scene.
Then, we enhanced our scenes visually using the Drei library and shared some practical performance optimization tips.
In this article, we will take it a step further and make our scene more dynamic, interactive, and customizable.
I will cover alternative control methods beyond OrbitControls, the animation logic with useFrame, and how to create custom materials using shaders.
Customizing Controls
One of the most commonly used components in R3F projects is OrbitControls, but in some cases, we may need more flexible or custom control behaviors.
For example, in situations such as first-person view, keyboard-based movement, or restricted rotation along specific axes, we can use a manual control approach.
Below is a simple example for moving the camera with keyboard input:
"use client";
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
function CustomControls() {
const { camera } = useThree()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Camera movement with WASD keys
if (e.key === 'w') camera.position.z -= 0.1
if (e.key === 's') camera.position.z += 0.1
if (e.key === 'a') camera.position.x -= 0.1
if (e.key === 'd') camera.position.x += 0.1
// Move up/down with Q/E
if (e.key === 'q') camera.position.y += 0.1
if (e.key === 'e') camera.position.y -= 0.1
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [camera])
return null
}With this control structure, you can freely move within the scene.
For example, you can create a “first-person” camera experience or add pointer lock to implement game-like movement behavior.
useFrame for Animation
We touched on this topic in previous articles, but I wanted to go over it again.
useFrame is one of the most powerful hooks in R3F. It runs on every render frame and allows us to control time-based updates of objects in the scene.
A simple rotating cube example:
"use client";
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import { Mesh } from 'three'
function RotatingBox() {
const ref = useRef<Mesh>(null)
useFrame((state, delta) => {
if (ref.current) {
// FPS-independent animation using delta
ref.current.rotation.y += delta
ref.current.rotation.x += delta
ref.current.rotation.z += delta
}
})
return (
<mesh ref={ref} position={[2, 0, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="orange" />
</mesh>
)
}Note: The delta value changes depending on the frame duration, which ensures that animations appear at the same speed on both high and low FPS devices.
Get Ertugrul Yaman’s stories in your inbox
Join Medium for free to get updates from this writer.
This approach can be used not only for rotation but also for various dynamic effects such as position change, light intensity, or material color.
Shader & Custom Material Usage
Standard materials are often sufficient, but when we want to create light-sensitive, dynamic, or wavy surface effects in the scene, shaderMaterial becomes very useful. This method allows us to define our own materials completely by using shader code (GLSL).
Let’s create a “wavy surface” example that includes both vertex and fragment stages:
"use client";
import { shaderMaterial } from '@react-three/drei'
import { extend, useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import * as THREE from 'three'
// Define a custom shader material
const WaveMaterial = shaderMaterial(
{
uTime: 0,
uColor: new THREE.Color(0.2, 0.6, 1.0)
},
// Vertex Shader - modifies geometry
`
uniform float uTime;
varying vec2 vUv;
varying float vWave;
void main() {
vUv = uv;
vec3 pos = position;
// Deform the surface with sine waves
float wave = sin(pos.x * 4.0 + uTime) * sin(pos.y * 3.0 + uTime * 0.5) * 0.3;
pos.z += wave;
vWave = wave;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}`,
// Fragment Shader - defines colors
`
uniform vec3 uColor;
varying vec2 vUv;
varying float vWave;
void main() {
// Change color according to wave height
vec3 color = uColor + vWave * 0.5;
gl_FragColor = vec4(color, 0.8);
}`
)
// Register the material in R3F
extend({ WaveMaterial })
// Extend TypeScript for JSX support
// This allows waveMaterial to be used inside JSX
declare module '@react-three/fiber' {
interface ThreeElements {
waveMaterial: any
}
}
function WaveMesh() {
const materialRef = useRef<any>(null)
useFrame((state, delta) => {
if (materialRef.current) {
// Update time - for wave animation
materialRef.current.uTime += delta
}
})
return (
<mesh position={[-2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[2, 2, 32, 32]} />
<waveMaterial ref={materialRef} transparent />
</mesh>
)
}Info:
The shader code passed to shaderMaterial is written in GLSL (OpenGL Shading Language).
This is not JavaScript, it’s a language that runs directly on the GPU.
Shaders have two stages:
Vertex Shader:
- Changes the position of each vertex.
- Here, we deform the geometry surface using sine waves.
Fragment Shader:
- Defines the color of each pixel.
- In this example, color tones are calculated based on wave height.
Also, with useFrame, we increase the uTime variable each frame to keep the waves moving continuously. As a result, the surface gains a smooth animation effect, as if water is rippling.
Conclusion
In this article, we explored how to make our R3F scene more interactive and unique by using custom controls, useFrame-based animations, and shader-based material customizations.
These techniques are powerful tools for bringing creativity to the forefront and making scenes feel more “alive” especially in advanced projects.
See you in the next topics!






