← All articles

[ Blog ]

July 1, 2026

11 min read

GLSL Shaders Tutorial for Web Developers

A GLSL shaders tutorial for web developers — vertex vs fragment shaders, uniforms, attributes, varyings, coordinate spaces, and a worked example you can paste into three.js.

GLSLShadersWebGLthree.jsReact Three Fiber
GLSL Shaders Tutorial for Web Developers

Every melting image transition, liquid blob, and impossible gradient you've admired on an Awwwards site is, underneath, a few dozen lines of GLSL. If you can write JavaScript, you can learn shaders — but the mental model is genuinely different, and most tutorials skip the part that actually unblocks you. This GLSL shaders tutorial for web developers gives you that model: what the GPU is really doing, the difference between a vertex and a fragment shader, how data flows in through uniforms, attributes, and varyings, and a real worked example you can paste into a three.js scene today.

I write shaders in production for a living — the displacement and image-effect work this site is known for is all GLSL — and the goal here is to get you from "I copy shaders I don't understand" to "I can read and modify one." No graphics-theory detour, just the parts that matter for the web.

What is a shader (and why it feels alien at first)

A shader is a tiny program that runs on the GPU, in parallel, thousands or millions of times per frame. That parallelism is the whole reason it feels foreign. Your JavaScript runs once, top to bottom, on one thread. A fragment shader runs once per pixel, all at the same time, with no knowledge of what its neighbours are doing. You can't loop over pixels; you write the logic for one pixel and the GPU fans it out.

There are two shaders in every WebGL draw call, and they run in sequence:

  • Vertex shader — runs once per vertex (corner point) of your geometry. Its job is to decide where on screen each vertex lands. It must write gl_Position.
  • Fragment shader (a.k.a. pixel shader) — runs once per pixel that the geometry covers. Its job is to decide what colour that pixel is. In WebGL1/GLSL ES 1.00 it writes gl_FragColor.

The GPU rasterises between them: it takes the three positioned vertices of a triangle, figures out which pixels fall inside, and runs the fragment shader for each. Anything the vertex shader hands off gets smoothly interpolated across the triangle on the way — that's what a varying is, and we'll get to it.

GLSL (OpenGL Shading Language) is the C-like language both shaders are written in. It's strongly typed, has no console.log, and won't auto-cast 1 to 1.0 for you. Those three facts cause about 80% of beginner pain.

The three ways data gets into a shader

This is the single most important table in shader-land. Almost every "why is my shader black" question comes from confusing these three:

QualifierDirectionChanges perTypical use
uniformCPU → both shadersPer draw call (same for all vertices/pixels)Time, mouse position, textures, colours, progress
attributeCPU → vertex shaderPer vertexPosition, UV, normal, custom per-vertex data
varyingVertex → fragmentInterpolated per pixelPass UVs/normals down so the fragment shader can use them

So the flow is: you set uniforms and attributes from JavaScript, the vertex shader reads them and emits varyings, and the fragment shader reads those interpolated varyings to compute a colour. Get that pipeline in your head and shaders stop being magic.

In WebGL2 / GLSL ES 3.00 the keywords change — attribute becomes in, varying becomes in/out, and you declare your own out vec4 instead of gl_FragColor — but the concepts are identical. three.js still uses the GLSL ES 1.00 syntax by default in ShaderMaterial, so that's what I'll write here.

Your first shaders: a flat colour

Here's the absolute minimum pair. The vertex shader positions each vertex; the fragment shader paints every pixel orange.

// Vertex shader
void main() {
  // projectionMatrix and modelViewMatrix are provided by three.js
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// Fragment shader
void main() {
  gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0); // r, g, b, a — all 0..1
}

Two things to notice immediately. First, position, projectionMatrix, and modelViewMatrix appear out of nowhere — three.js's ShaderMaterial injects these built-in attributes and uniforms for you (a RawShaderMaterial would not; you'd declare them yourself). Second, every number is a float with a decimal point. Write vec4(1, 0.5, 0, 1) and the shader fails to compile, because GLSL won't promote the integer 1 to a float. This is the error you will hit most often.

Coordinate spaces: the part nobody explains

A vertex shader is really a chain of coordinate-space transforms, and understanding it demystifies that gl_Position line:

  1. Object/local space — vertex positions as defined in the geometry, centred on the model's own origin.
  2. World space — after the model matrix: where the object sits in the scene.
  3. View space — after the view matrix: positions relative to the camera. three.js folds model + view into one modelViewMatrix.
  4. Clip space — after the projection matrix: the perspective squash, output as gl_Position.

So projectionMatrix * modelViewMatrix * vec4(position, 1.0) reads right-to-left: take the local vertex, move it into camera space, then apply perspective. When you want to deform geometry — a wave, a ripple, a bulge — you modify position before that multiply, in object space. That's the lever for vertex displacement.

In the fragment shader, the coordinate you care about is usually the UV: a 0–1 mapping across the surface, where (0,0) is one corner and (1,1) the opposite. You pass it down as a varying:

// Vertex shader
varying vec2 vUv;
void main() {
  vUv = uv; // 'uv' attribute supplied by three.js
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// Fragment shader
varying vec2 vUv;
void main() {
  gl_FragColor = vec4(vUv, 0.0, 1.0); // x→red, y→green: a gradient
}

That gradient — black-to-red horizontally, black-to-green vertically — is the "hello world" of fragment shaders. If you can produce it, your varying pipeline works end to end.

A worked example: an animated gradient

Let's make it move. We add a uTime uniform (driven from JavaScript) and use trigonometry on the UVs to get a living gradient. This is the exact pattern that scales up into the displacement and noise effects you see on premium sites.

// Fragment shader
uniform float uTime;
varying vec2 vUv;

void main() {
  // Animate a wave across the x axis
  float wave = sin(vUv.x * 10.0 + uTime) * 0.5 + 0.5; // remap -1..1 to 0..1
  vec3 colorA = vec3(0.05, 0.0, 0.3);  // deep indigo
  vec3 colorB = vec3(1.0, 0.85, 0.0);  // the brand yellow
  vec3 color = mix(colorA, colorB, wave * vUv.y);
  gl_FragColor = vec4(color, 1.0);
}

Three GLSL built-ins are doing the heavy lifting and you'll use them constantly: sin() for oscillation, mix(a, b, t) for linear interpolation between two values, and the * 0.5 + 0.5 trick to remap a -1..1 range into 0..1. Master those and you can build an enormous range of effects.

Now wire it up in React Three Fiber, which is the cleanest way to feed uniforms and run the frame loop:

import { useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import * as THREE from "three";

const vertex = /* glsl */ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = /* glsl */ `
  uniform float uTime;
  varying vec2 vUv;
  void main() {
    float wave = sin(vUv.x * 10.0 + uTime) * 0.5 + 0.5;
    vec3 color = mix(vec3(0.05, 0.0, 0.3), vec3(1.0, 0.85, 0.0), wave * vUv.y);
    gl_FragColor = vec4(color, 1.0);
  }
`;

function GradientPlane() {
  const mat = useRef<THREE.ShaderMaterial>(null);
  useFrame((_, delta) => {
    if (mat.current) mat.current.uniforms.uTime.value += delta;
  });

  return (
    <mesh>
      <planeGeometry args={[2, 2, 1, 1]} />
      <shaderMaterial
        ref={mat}
        vertexShader={vertex}
        fragmentShader={fragment}
        uniforms={{ uTime: { value: 0 } }}
      />
    </mesh>
  );
}

export default function Scene() {
  return (
    <Canvas>
      <GradientPlane />
    </Canvas>
  );
}

That mat.current.uniforms.uTime.value += delta line is the bridge between your two worlds: JavaScript runs the clock, the GPU runs the maths. Swap the fragment shader for one that samples a texture and offsets the UVs, and you've built the WebGL image displacement hover effect — the gradient example and the displacement effect are the same machine with a different fragment body.

The gotchas that cost beginners hours

These are the ones I still see senior JS developers hit on day one:

  • Integers vs floats. 1 is an int, 1.0 is a float, and GLSL won't mix them. float x = 1; fails. sin(5) fails. Decimal-point everything.
  • No implicit vector math the way you expect. You can't add a float to a vec3 with + in older GLSL without care, but you can multiply (vec3 * float scales each component). Read the type of every operand.
  • A black screen is the default failure. If the shader compiles but you see nothing: your geometry may be behind the camera, your gl_Position maths is wrong, or gl_FragColor alpha is 0. Temporarily output gl_FragColor = vec4(1.0); to confirm the fragment shader runs at all.
  • Silent compile errors. A typo'd shader often just renders black. Check the browser console — three.js logs the GLSL compiler error with a line number. Read it; it's precise.
  • Precision on mobile. Declare precision highp float; (or mediump) at the top of fragment shaders when writing raw GLSL. three.js's ShaderMaterial adds this for you, but raw WebGL and some Android GPUs need it explicit or you get artefacts.

Performance notes from production

Shaders are cheap by nature — that's the point — but they're not free, and the fragment shader is where cost hides because it runs per pixel:

  • Fragment cost scales with screen pixels, not geometry. A full-screen shader on a 4K display at devicePixelRatio 3 is running ~24 million times per frame. Clamp dpr on the canvas (dpr={[1, 2]} in R3F) — it's the single biggest win on phones.
  • Branching is expensive. GPUs run pixels in lockstep groups; an if where neighbouring pixels take different paths makes the GPU run both sides. Prefer mix(), step(), and smoothstep() over conditionals where you can.
  • Move work to the vertex shader when possible. A plane has 4 vertices but a million pixels — anything you can compute per vertex and pass down as a varying is dramatically cheaper than computing it per fragment.
  • Don't render when nothing changes. A static shader doesn't need a render loop; use frameloop="demand" and invalidate on interaction. The same discipline that keeps a full three.js scene optimised applies to a single shader plane.

FAQ

Do I need to know graphics math to write shaders?

No — not to start. Vectors, mix, sin, and clamp get you a long way, and you'll absorb the rest by reading and tweaking. Trigonometry helps for waves and circular motion; linear algebra (matrices) only matters when you go deep into vertex transforms, and three.js handles most of that for you.

What's the difference between GLSL and WebGL?

WebGL is the browser API that lets JavaScript talk to the GPU and issue draw calls. GLSL is the language the shaders themselves are written in. You use WebGL (or a wrapper like three.js) to compile and run your GLSL. They're two layers of the same stack, not alternatives.

Should I learn raw WebGL or use three.js / React Three Fiber?

Learn the shader concepts on three.js or React Three Fiber first — they remove the 200 lines of context, buffer, and program boilerplate so you can focus on the actual GLSL. Drop to raw WebGL later only if you need fine control or are shipping a tiny no-dependency widget. The GLSL you write is the same either way.

Where do I practise GLSL outside a project?

The Book of Shaders is the canonical interactive primer for fragment shaders, and ShaderToy lets you read and fork thousands of live examples. Both run GLSL directly in the browser with instant feedback — ideal for building intuition before wiring it into a real scene.


Shaders are the skill that separates a nice site from one that makes people screenshot it. Once the vertex/fragment/uniform model clicks, the displacement transitions, particle fields, and generative gradients on award-winning sites stop looking like magic and start looking like things you can build. If you want this kind of GPU-driven craft built into your product, see how I work with clients and studios or browse the projects it's shipped on.