GLSL Shader Effects in React: Noise, Distortion and Color Grading
Learn how to wire GLSL shaders into React with Three.js and react-three-fiber — noise, UV distortion, and cinematic color grading with real working code.
Why Bother With GLSL in a React App?
GLSL runs on the GPU. That single sentence is why you'd skip CSS filters and canvas 2D entirely for certain effects. When you need per-pixel noise, real-time distortion, or cinematic color grading that scales to full-screen canvases without dropping frames, fragment shaders are your only real option — nothing in CSS land comes close at that level of control.
That said, the tooling barrier used to be brutal. Writing raw WebGL boilerplate in 2019 meant hundreds of lines before you even cleared the canvas. In 2024 react-three-fiber (r3f) changed the equation: you get a declarative React component tree over Three.js r160+, and shader materials slot right in as JSX props. The mental model clicked for a lot of frontend devs who'd been avoiding WebGL for years.
Honestly, once you wire up your first shaderMaterial in r3f you'll realize the hard part was never GLSL itself — it was the scaffolding. The language is small. A fragment shader is just a function that returns a vec4 color for every pixel on screen. That's the whole contract.
Worth noting: if you're already building with Empire UI's glassmorphism components or layering effects from the glassmorphism generator, GLSL shaders let you push those visuals far beyond what backdrop-filter alone can achieve — animated noise grains, chromatic aberration, displacement maps running at 60fps.
Setting Up: react-three-fiber and drei
Install the stack first. You need three packages:
``bash
npm install three @react-three/fiber @react-three/drei
`
That's it. No separate WebGL context setup, no manual canvas wrangling. r3f handles the renderer lifecycle and hooks it into React's tree automatically. The drei helpers library gives you shaderMaterial from @react-three/drei` which generates a typed Three.js material from raw GLSL strings and a uniforms object — saves probably 40 lines of boilerplate per material.
Your entry point looks like this:
``tsx
import { Canvas } from '@react-three/fiber'
import { ShaderPlane } from './ShaderPlane'
export default function Scene() {
return (
<Canvas style={{ width: '100vw', height: '100vh' }}>
<ShaderPlane />
</Canvas>
)
}
`
The Canvas` component creates the WebGL renderer, sets up a camera, and starts the render loop. Everything inside is Three.js scene graph, but expressed as React elements. State changes trigger reconciler updates, not full re-renders of the GPU pipeline — that's the clever part.
One more thing — you'll want to configure drei's shaderMaterial with extend so TypeScript knows about your custom JSX element:
``tsx
import { shaderMaterial } from '@react-three/drei'
import { extend } from '@react-three/fiber'
import * as THREE from 'three'
const NoiseMaterial = shaderMaterial(
{ uTime: 0, uScale: 3.0 },
vertexShader,
fragmentShader
)
extend({ NoiseMaterial })
declare global {
namespace JSX {
interface IntrinsicElements {
noiseMaterial: any
}
}
}
`
Typescript will stop complaining and autocomplete will actually help you. Quick aside: the uTime uniform is the clock — you increment it each frame via useFrame` from r3f and that drives all your animations.
Fractal Brownian Motion: The Noise Everyone Actually Wants
Perlin noise is fine. fBm (fractal Brownian motion) is what makes things look *good*. You layer multiple octaves of noise at increasing frequencies and decreasing amplitudes, and you get the kind of organic turbulence you see in aurora backgrounds, fluid simulations, and procedural clouds. Here's the GLSL:
``glsl
// Fragment shader
precision highp float;
uniform float uTime;
uniform float uScale;
varying vec2 vUv;
vec3 mod289(vec3 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, 0.366025403784439,
-0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
m = m*m;
m = m*m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0+h*h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
vec2 shift = vec2(100.0);
mat2 rot = mat2(cos(0.5), sin(0.5), -sin(0.5), cos(0.5));
for (int i = 0; i < 5; i++) {
v += a * snoise(p);
p = rot * p * 2.0 + shift;
a *= 0.5;
}
return v;
}
void main() {
vec2 uv = vUv * uScale;
float n = fbm(uv + uTime * 0.15);
vec3 color = mix(vec3(0.05, 0.0, 0.2), vec3(0.4, 0.1, 0.8), n * 0.5 + 0.5);
gl_FragColor = vec4(color, 1.0);
}
`
The 5-octave loop is the fBm core. Each iteration rotates the UV coordinates by a half-radian matrix to break repetitive tiling — skip that rotation and you'll see obvious grid artifacts at uScale` values above 4.0.
Hook this up in React with useFrame to drive uTime:
``tsx
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
export function ShaderPlane() {
const matRef = useRef<any>(null)
useFrame(({ clock }) => {
if (matRef.current) {
matRef.current.uTime = clock.getElapsedTime()
}
})
return (
<mesh>
<planeGeometry args={[2, 2]} />
<noiseMaterial ref={matRef} uScale={3.0} />
</mesh>
)
}
`
clock.getElapsedTime() returns seconds since the canvas mounted. You pass it straight into the uniform. The GPU does the rest — every fragment gets a slightly different noise value that shifts as uTime` grows.
In practice, 5 octaves at highp float precision runs at a solid 60fps on any device from 2022 onward, including mid-range Android. Push to 8 octaves and you'll start seeing frame drops on integrated graphics. Stay at 5 unless you have a specific reason to go higher.
UV Distortion: Warping Images and Textures
Noise-based UV distortion is how you get that liquid, melting-screen effect. Instead of using noise as a color directly, you *offset* the UV coordinates you sample your texture with. The displacement shifts pixels away from where they'd normally land, and the result looks like heat haze or underwater refraction.
``glsl
uniform sampler2D uTexture;
uniform float uTime;
uniform float uStrength;
varying vec2 vUv;
void main() {
// Generate offset from noise
float nx = snoise(vUv * 3.0 + uTime * 0.3);
float ny = snoise(vUv * 3.0 + uTime * 0.3 + 43.7);
// Displace UVs — 0.04 = 4% of texture width max shift
vec2 distortedUv = vUv + vec2(nx, ny) * uStrength;
vec4 texColor = texture2D(uTexture, distortedUv);
gl_FragColor = texColor;
}
`
The + 43.7 offset for ny` is a cheap trick to decorrelate the x and y noise fields. Without it both axes deform in the same direction and the distortion looks like a uniform wobble rather than genuine turbulence.
Load your texture via r3f's useTexture hook from drei:
``tsx
import { useTexture } from '@react-three/drei'
export function DistortedImage({ src }: { src: string }) {
const texture = useTexture(src)
const matRef = useRef<any>(null)
useFrame(({ clock }) => {
if (matRef.current) matRef.current.uTime = clock.getElapsedTime()
})
return (
<mesh>
<planeGeometry args={[1.6, 0.9]} />
<distortMaterial ref={matRef} uTexture={texture} uStrength={0.03} />
</mesh>
)
}
`
A uStrength of 0.03 is a 3% UV offset — that's actually a lot visually. Start at 0.01 and creep up. At 0.08` you're into full glitch territory, which pairs well with cyberpunk or vaporwave aesthetic UI work.
What about the vertex shader? For flat planes it's just the standard passthrough:
``glsl
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
Always pass vUv` as a varying — it's how the rasterizer interpolates UV coordinates across your geometry so each fragment knows its position on the texture.
Color Grading: Filmic Tone Mapping in a Fragment Shader
Color grading is where shaders start feeling like actual cinematography tools. You can implement Reinhard tone mapping, ACES filmic curves, or custom LUT-based grading all in the fragment stage, at zero CPU cost. Here's a solid ACES approximation that's been floating around graphics forums since around 2017:
``glsl
vec3 aces(vec3 x) {
float a = 2.51;
float b = 0.03;
float c = 2.43;
float d = 0.59;
float e = 0.14;
return clamp((x*(a*x+b))/(x*(c*x+d)+e), 0.0, 1.0);
}
void main() {
vec2 uv = vUv;
// ... your base color computation ...
vec3 base = /* your color */;
// Exposure
base *= 1.2;
// ACES filmic
vec3 graded = aces(base);
// Subtle vignette — darkens edges by 24px equivalent at 1920px
float vignette = smoothstep(1.0, 0.3, length(uv - 0.5));
graded *= vignette;
gl_FragColor = vec4(graded, 1.0);
}
``
The ACES curve compresses highlights (values above 1.0 that would normally clip to white) into a pleasing roll-off. Without it, any bright regions in your noise or texture just burn out to solid white.
You can also do split toning — push shadows toward cool blues and highlights toward warm oranges:
``glsl
vec3 splitTone(vec3 color, vec3 shadows, vec3 highlights) {
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
return mix(mix(color, shadows, 1.0 - luminance),
mix(color, highlights, luminance),
0.5);
}
// In main:
vec3 graded = splitTone(
base,
vec3(0.05, 0.08, 0.2), // blue-ish shadows
vec3(0.9, 0.75, 0.5) // warm highlights
);
``
This is legitimately how film emulation works. The luminance-weighted mix pushes different tonal ranges toward different hues. Tweak the shadow and highlight colors and you can match Fuji Provia, Kodak Portra, or whatever you're after.
One more thing — combine all three techniques (fBm noise as base, UV distortion on a texture, ACES grade on the output) and you get something that CSS simply cannot replicate. The gradient generator is great for quick static gradients, but when you need animated, GPU-driven visual effects that respond to audio, scroll, or pointer position, GLSL is the move.
Look, you don't need to master all of this before shipping something. Start with the noise shader, get it running in your Canvas, then layer in the grading. That incremental approach beats trying to wire everything up in one session.
Performance Tips and Common Pitfalls
Conditionals in GLSL are expensive. Unlike CPUs, GPUs evaluate *both branches* of an if statement and then discard the unused result — so a deeply nested conditional tree hurts more than equivalent arithmetic. Replace if/else with mix(), step(), and smoothstep() wherever you can. mix(a, b, step(threshold, value)) is the GLSL equivalent of a ternary.
Texture lookups in a loop are the other big perf killer. If you're sampling a texture inside your fBm loop for each octave, that's 5 texture fetches per fragment. On a 1920×1080 canvas that's over 10 million texture samples per frame. Cache what you can outside the loop, and consider baking your noise into a DataTexture on the CPU at startup and sampling that instead — it trades GPU computation for a memory bandwidth cost that's usually much cheaper.
Dispose of your materials. This is a React-specific trap: when a component unmounts, Three.js objects don't get garbage collected automatically unless you call .dispose(). r3f's useEffect cleanup is your friend:
``tsx
useEffect(() => {
return () => {
matRef.current?.dispose()
}
}, [])
``
Skip this in a Next.js app with fast refresh and you'll slowly leak GPU memory across hot reloads until the tab crashes.
Keep your vertex shader boring. All the interesting work belongs in the fragment shader. The vertex shader should do the minimum — transform position, pass varyings. Doing complex noise in the vertex stage means you only get one sample per vertex rather than per pixel, and the quality difference is obvious at 1px scale on smooth gradients.
FAQ
Yes — you can write raw WebGL via a useRef canvas and useEffect, or use libraries like regl or ogl. That said, react-three-fiber removes so much boilerplate that it's almost always the right call unless you have a hard bundle-size constraint.
Store it in a ref, not useState — refs don't trigger re-renders. Update the ref in a pointermove handler and read it inside useFrame to push the value into the uniform each tick.
Yes, WebGL 1.0 is supported on every modern mobile browser including iOS Safari 15+ and Android Chrome. Stick to mediump float on fragment shaders for mobile targets and avoid loops with more than 6-8 iterations.
Vertex shaders run once per geometry vertex and position things in clip space. Fragment shaders run once per pixel (fragment) that the rasterizer fills in. For visual effects like noise and color grading, you're almost always writing fragment shaders.