Implementing WebGL Animations and 3D Effects on a Website
WebGL is not just "adding beauty." It's a programmable graphics pipeline directly in the browser with GPU access. When you need to render thousands of particles, deform geometry by audio signal, or build interactive 3D scenes without plugins—this is the only tool that delivers without compromises.
WebGL 2.0 is used (95%+ browser support as of 2025), typically via Three.js or directly via WebGL API for non-standard tasks.
Stack and Approaches
Three.js—the de-facto standard for most web projects. Abstracts shaders and buffers, provides scene, camera, lighting. Version r169+ supports WebGPU as an alternative renderer.
Raw WebGL is applied when you need full control: custom geometric primitives, non-standard blend modes, minimal bundle size without unnecessary code.
GLSL shaders are written manually for each effect—universal solutions don't exist here.
// Vertex shader—plane deformation by noise
uniform float uTime;
uniform float uAmplitude;
varying vec2 vUv;
// Simplex noise (embedded as a function)
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
void main() {
vUv = uv;
vec3 pos = position;
float noise = snoise(vec2(pos.x * 0.5 + uTime * 0.3, pos.y * 0.5));
pos.z += noise * uAmplitude;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
// Three.js—scene initialization with post-processing
import * as THREE from 'three'
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('#webgl'),
antialias: true,
alpha: true,
})
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.toneMapping = THREE.ACESFilmicToneMapping
const composer = new EffectComposer(renderer)
composer.addPass(new RenderPass(scene, camera))
composer.addPass(new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.8, // strength
0.4, // radius
0.85 // threshold
))
Typical Effects and Implementation
Shader Background with Noise
One of the most requested effects: animated gradient background that responds to mouse movement. Implemented via PlaneGeometry covering the entire viewport with a fragment shader based on FBM (fractional Brownian motion).
// Fragment shader—color noise
uniform float uTime;
uniform vec2 uMouse;
uniform vec2 uResolution;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
vec2 mouse = uMouse / uResolution;
// UV offset by mouse position
uv += (mouse - 0.5) * 0.05;
float noise = fbm(uv * 3.0 + uTime * 0.15);
vec3 colorA = vec3(0.1, 0.0, 0.4);
vec3 colorB = vec3(0.0, 0.3, 0.8);
vec3 colorC = vec3(0.8, 0.1, 0.3);
vec3 color = mix(colorA, colorB, noise);
color = mix(color, colorC, smoothstep(0.4, 0.7, noise));
gl_FragColor = vec4(color, 1.0);
}
Particle System
For 100k+ particles, use BufferGeometry with Float32Array attributes. Animation happens entirely in the vertex shader—CPU is not involved at runtime.
const COUNT = 150000
const positions = new Float32Array(COUNT * 3)
const randoms = new Float32Array(COUNT)
for (let i = 0; i < COUNT; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 10
positions[i * 3 + 1] = (Math.random() - 0.5) * 10
positions[i * 3 + 2] = (Math.random() - 0.5) * 10
randoms[i] = Math.random()
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1))
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uSize: { value: 3.0 * renderer.getPixelRatio() },
},
vertexShader: particleVertexShader,
fragmentShader: particleFragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
})
Image Distortion on Hover
Image texture is loaded as THREE.Texture, deformed via displacement map by cursor position. A "liquid" hover effect.
// Uniforms for passing to shader
const uniforms = {
uTexture: { value: texture },
uDisplacement: { value: displacementTexture },
uMouse: { value: new THREE.Vector2(0, 0) },
uVelo: { value: 0 },
}
// Track mouse movement velocity
let lastMouse = new THREE.Vector2()
let currentVelo = 0
window.addEventListener('mousemove', (e) => {
const current = new THREE.Vector2(
e.clientX / window.innerWidth,
1.0 - e.clientY / window.innerHeight
)
const delta = current.distanceTo(lastMouse)
currentVelo = Math.min(delta * 10, 1.0)
lastMouse.copy(current)
uniforms.uMouse.value.copy(current)
})
React Integration
Via @react-three/fiber (R3F), Three.js integrates into React components declaratively. @react-three/drei provides ready-made helpers: useGLTF, MeshTransmissionMaterial, Float, Environment.
import { Canvas, useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import * as THREE from 'three'
function AnimatedMesh() {
const meshRef = useRef<THREE.Mesh>(null)
useFrame(({ clock, pointer }) => {
if (!meshRef.current) return
meshRef.current.rotation.y = clock.getElapsedTime() * 0.3
meshRef.current.position.x = THREE.MathUtils.lerp(
meshRef.current.position.x,
pointer.x * 2,
0.05
)
})
return (
<mesh ref={meshRef}>
<icosahedronGeometry args={[1.5, 4]} />
<meshStandardMaterial
color="#5500ff"
wireframe={false}
roughness={0.1}
metalness={0.8}
/>
</mesh>
)
}
export function Scene() {
return (
<Canvas
camera={{ position: [0, 0, 5], fov: 45 }}
gl={{ antialias: true, alpha: true }}
dpr={[1, 2]}
>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} intensity={1} />
<AnimatedMesh />
</Canvas>
)
}
Performance
Framerate target—60fps on desktop, 30fps on mobile with automatic quality reduction. Determined via navigator.hardwareConcurrency and benchmark on first render.
Key rules:
- One drawcall instead of thousands:
InstancedMeshfor repeating geometry -
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))—don't render at 3x on Retina without reason - Dispose on unmount:
geometry.dispose(),material.dispose(),texture.dispose() -
requestAnimationFramevia Three.js renderer, not custom loop - Post-processing only when
prefersReducedMotion === false
// Check before initializing heavy effects
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const isMobile = /Mobi|Android/i.test(navigator.userAgent)
const config = {
bloomEnabled: !prefersReduced && !isMobile,
particleCount: isMobile ? 10000 : 150000,
pixelRatio: isMobile ? 1 : Math.min(devicePixelRatio, 2),
}
Asset Loading
3D models—.glb format (GLB = binary GLTF). Compression via Draco (geometry) + KTX2 (textures). Loading via GLTFLoader + DRACOLoader.
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/') // wasm in public/
const ktx2Loader = new KTX2Loader()
ktx2Loader.setTranscoderPath('/basis/')
ktx2Loader.detectSupport(renderer)
const loader = new GLTFLoader()
loader.setDRACOLoader(dracoLoader)
loader.setKTX2Loader(ktx2Loader)
loader.load('/models/scene.glb', (gltf) => {
scene.add(gltf.scene)
}, (progress) => {
const pct = (progress.loaded / progress.total * 100).toFixed(0)
onProgress(pct)
})
Timeline and Stages
Prototype with main effect—3–5 days. Full integration into website with responsiveness, fallback for weak devices, bundle optimization—10–20 days depending on scene complexity. Animated hero with shader background and mouse reaction—closer to lower bound. Interactive 3D product model with material configurator—closer to upper bound.







