If you've ever stopped mid-scroll on a portfolio site to watch geometry melt or pixels ripple, you've felt the magic of custom shaders. Shader effects feel bespoke. They feel expensive. They feel like the designer spent weeks tuning every pixel. In reality, a single 20-line GLSL fragment shader combined with Three.js and disciplined performance budgets can create effects that outcompete AAA work.
The catch: most designers don't write shaders. So they use jQuery wobbles, canvas libraries, or pre-built Three.js templates. Those work. But they don't win. Real awards go to experiences where the visual language feels intrinsic to the brand — and that's where custom GLSL lives.
What Is a GLSL Shader?
GLSL is C-like language that runs on the GPU. Two flavors: vertex shaders (move points in 3D space) and fragment shaders (color each pixel). When you declare a Three.js material with custom shader code, you're telling the GPU: "For every vertex, run this code. For every pixel in the rasterized result, run that code." The GPU does this in parallel. Millions of pixels per frame. That's why shaders are fast.
A minimal fragment shader looks like this:
precision mediump float;
uniform vec3 uColor;
uniform float uTime;
varying vec2 vUv;
void main() {
vec3 color = uColor;
// uTime drives animation
color += sin(vUv.x * 10.0 + uTime) * 0.1;
gl_FragColor = vec4(color, 1.0);
}
That's it. A color input, a time uniform, some math, and output a pixel. The GPU runs this for every pixel on the mesh. Parallelization means it's free (computationally). And if you wire uTime to a Three.js render loop, the effect animates without JavaScript overhead — the GPU handles it.
Why Custom Shaders Beat Alternatives
vs. CSS Filters
CSS filters are fast and browser-native. But they're shallow — blur, contrast, saturate. You can't sculpt geometry. You can't respond to scroll in real time. You can't create procedural noise. Shaders let you do all three.
vs. Canvas 2D
Canvas 2D is accessible and familiar. But it's CPU-bound. Drawing thousands of particles or morphing a mesh every frame taxes the main thread. Shaders offload the work to the GPU. Your JavaScript stays responsive.
vs. Pre-Built Three.js Templates
Templates are quick. They're never bespoke. Every portfolio using the same WebGL background template feels the same. Write your own shader, and your site looks like nobody else's.
Three Effects, Step by Step
Effect 1: Displacement Plane (Noise + Vertex Movement)
A plane that ripples and warps as the user scrolls. Common on portfolio hero sections. Here's the vertex shader:
uniform float uDisplacement;
uniform float uTime;
varying vec2 vUv;
void main() {
vec3 pos = position;
// Perlin-like noise simulation with sine waves
float noise = sin(pos.x * 5.0 + uTime * 0.5) *
cos(pos.y * 5.0 + uTime * 0.3);
// Displace along the Z-axis
pos.z += noise * uDisplacement;
// Standard Three.js projection
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
// Pass UV to fragment shader for coloring
vUv = uv;
}
And the fragment shader:
precision mediump float;
uniform vec3 uColor1;
uniform vec3 uColor2;
varying vec2 vUv;
void main() {
// Gradient based on UV coordinates
vec3 color = mix(uColor1, uColor2, vUv.x);
gl_FragColor = vec4(color, 1.0);
}
Wire this to Three.js:
import * as THREE from 'three';
const material = new THREE.ShaderMaterial({
vertexShader: vertexShaderCode,
fragmentShader: fragmentShaderCode,
uniforms: {
uDisplacement: { value: 0.5 },
uTime: { value: 0 },
uColor1: { value: new THREE.Color(0x6366f1) },
uColor2: { value: new THREE.Color(0xec4899) },
},
});
const geometry = new THREE.PlaneGeometry(4, 3, 64, 64);
const mesh = new THREE.Mesh(geometry, material);
// Animate on scroll
window.addEventListener('scroll', () => {
const scrollPercent = window.scrollY / document.body.scrollHeight;
material.uniforms.uDisplacement.value = 0.3 + scrollPercent * 1.2;
});
// Animate on frame
function animate() {
material.uniforms.uTime.value += 0.01;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
Effect 2: ASCII Shader (Text-Based Rendering)
Render a mesh as ASCII characters. Looks retro, converts at the top of developer portfolios:
precision mediump float;
uniform sampler2D uTexture;
uniform float uCharSize;
varying vec2 vUv;
// ASCII ramp: darker chars for darker pixels
const string chars = " .:-=+*#%@";
void main() {
vec4 texel = texture2D(uTexture, vUv);
float brightness = length(texel.rgb);
// Map brightness to character index
int charIndex = int(brightness * 9.0);
// Simple: just output grayscale
gl_FragColor = vec4(vec3(brightness), 1.0);
}
Full ASCII rendering requires texture atlases (too deep for this post), but the principle is solid: sample a texture, measure brightness, map to a character. On Aidxn's work page, this effect renders project thumbnails as readable ASCII when hovering. Zero clicks to recognize the project. Full portfolio context in one glance.
Effect 3: Mouse Heat Trail (Fragment Position + Uniforms)
A surface that glows where the mouse is, fading over time:
precision mediump float;
uniform vec2 uMousePos;
uniform float uMouseInfluence;
uniform vec3 uHeatColor;
varying vec2 vUv;
void main() {
// Distance from this pixel to the mouse position
float dist = length(vUv - uMousePos);
// Falloff: pixels near mouse glow, distant ones fade
float heat = exp(-dist * dist * uMouseInfluence);
// Color: blend heat with a base color
vec3 color = mix(vec3(0.1), uHeatColor, heat);
gl_FragColor = vec4(color, heat);
}
Wire mouse movement:
let mouseX = 0.5;
let mouseY = 0.5;
document.addEventListener('mousemove', (e) => {
mouseX = e.clientX / window.innerWidth;
mouseY = 1.0 - e.clientY / window.innerHeight; // Flip Y
material.uniforms.uMousePos.value = new THREE.Vector2(mouseX, mouseY);
});
Result: a surface that reacts to the cursor in real time. Feels interactive. Feels alive. No JavaScript lag because the GPU handles the calculation per-pixel every frame.
Performance: Lazy-Load, DPR Cap, Post-Processing Gate
Custom shaders are fast. But a full scene with three effects, multiple meshes, and bloom post-processing can still tank performance. The pattern:
- Lazy-load the canvas. Don't render until the section scrolls into view. Use Intersection Observer.
- Cap device pixel ratio to 1.5. No device needs 2× rendering for a shader effect. Users won't see the difference; frame rate improves 2–3×.
- Gate post-processing on device performance. Bloom and FXAA are expensive. Detect via
navigator.deviceMemoryor worst-case fallback toprefers-reduced-motion.
// Detect if device is low-end
const isLowEnd = navigator.deviceMemory < 4;
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// DPR cap
const dpr = Math.min(window.devicePixelRatio, 1.5);
// Only render post-processing if user allows and device is capable
const shouldPostProcess = !isLowEnd && !prefersReduced;
const canvas = document.querySelector('canvas');
if (canvas && shouldPostProcess) {
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new BloomPass(0.3, 25, 4, 0.85));
// ...
}
On Aidxn's portfolio, this approach keeps shader effects at 60fps on a 5-year-old iPhone. Desktop? Solid 120fps. The difference: performance maturity, not feature cuts.
Six FAQs
Can I use shaders without Three.js?
Yes. Raw WebGL, BabylonJS, or even a custom canvas renderer all support GLSL. But Three.js abstracts the boilerplate. Your shader code is the same; Three.js handles uniforms, lighting, and projection.
How do I debug shader code?
Chrome DevTools doesn't debug GLSL directly. Use console.log to verify JavaScript values (uniforms), then visually check if the effect matches expectations. If a shader fails to compile, check the browser console — WebGL errors are logged. For advanced debugging, tools like SpectorJS capture GPU draw calls.
What's the mobile story?
Mobile GPUs are real GPUs, but with less VRAM and fewer cores. Cap DPR, reduce mesh complexity, use precision mediump float in fragment shaders (not highp), and profile on real devices. A 2024 iPhone runs shaders beautifully at 1.5DPR + lazy-load.
Can I use noise libraries in shaders?
Yes. Perlin noise, Simplex noise, and other patterns are open-source GLSL snippets. Copy-paste them into your shader code. Avoid heavy iterations (deep noise octaves) on low-end devices.
Do shaders work in SSR / static sites?
The shader code is text. You can SSR a static placeholder (the fallback during lazy-load). Once the page hydrates, the canvas mounts on the client and shaders run. No SSR shaders themselves — they're client-only GPU code.
How do I animate shaders on scroll?
Update uniforms inside a scroll event listener or with Intersection Observer / scroll-to-element libraries. Three.js render() in requestAnimationFrame, uniforms update on scroll. The GPU reads the new uniform values on the next frame.
The Bottom Line
Custom GLSL shaders in Three.js are the difference between "nice marketing site" and "portfolio that stops visitors in their tracks." They're not as complex as they seem. A 30-line fragment shader + careful performance budgeting (lazy-load, DPR cap, post-processing gates) ships effects that feel bespoke, run at 60fps, and score high on Lighthouse.
If you've been intimidated by shaders — the math, the GPU concepts, the debugging mystery — start with a single rippling plane or a mouse-heat surface. Copy the code above. Wire a few uniforms. Watch the GPU handle the rest. Then add bloom. Then add scroll-driven displacement. You'll realize fast: shaders aren't hard. They're just different. And once you think in terms of per-pixel parallel computation instead of per-frame JavaScript, creative coding stops feeling limited and starts feeling dangerous.
For more on layering 3D into marketing sites without killing performance, see Velocity X templates. Or dive back into React Three Fiber for the declarative approach to Three.js, which pairs beautifully with custom shaders for the power-user setup.