Add Shader Effects to a Web App
Mount GPU-accelerated WebGL shader backgrounds using Paper Shaders and Vite.
Introduction
30 Second Summary
Every app has moments where a static background just feels flat. Animated visuals catch the eye, guide attention, and make interfaces feel alive.
In this project, you will build a shader effects playground using Vite and TypeScript that renders GPU-accelerated animated backgrounds. You will mount multiple shader effects to DOM elements, wire up sliders that morph visuals in real time, and extract a reusable helper function for adding shaders anywhere in a codebase.
What You'll Build
Open your finished page and watch a flowing mesh gradient animate across the hero section while cards below pulse with their own distinct effects. Drag a slider and the gradient warps instantly under your finger.
By the end of this project, you'll have:
- A full-viewport animated mesh gradient hero background you can layer text and UI on top of, proving that GPU-rendered effects outperform static CSS gradients.
- Two cards with distinct shader accents (neuro noise and god rays) that you can swap in anywhere a design needs motion.
- A live control panel with sliders that update shader uniforms in real time, so you can twist distortion, swirl, speed, and colors and watch the visuals respond instantly.
- A reusable mountShader helper function you can copy into any TypeScript project to mount shaders to any DOM element in one line.
- Secret Mission: Add a shader switcher that lets you swap between all available shader effects on the hero with smooth transitions.
Are there any prerequisites?
You should be comfortable reading basic TypeScript (types, imports, function signatures). You also need Node.js installed on your machine. No prior knowledge of WebGL or shaders is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This is your chance to think about how GPU-accelerated shader effects can elevate a web app's visuals beyond what static CSS alone can do.
Set Up the Vite Project and Install Paper Shaders
Shader effects run on the GPU through WebGL, which means your development environment needs to bundle and serve TypeScript modules without manual configuration.
Vite gives you instant hot module replacement. Every shader tweak appears in the browser the moment you save. In this step, you will scaffold a Vite project and install Paper Shaders, the library that provides pre-built fragment shaders with a simple mounting API.
In this step, get ready to:
- Scaffold a Vite project with the vanilla TypeScript template.
- Install Paper Shaders with a pinned version.
- Start the dev server and confirm the starter page loads.
Scaffold a new Vite project
You need a project folder on your Desktop so you can find it easily later.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new Vite project with the vanilla TypeScript template by running this command:
npm create vite@latest shader-playground -- --template vanilla-ts
What does this command do?
- npm create vite@latest runs the Vite project scaffolder.
- shader-playground is the name of the folder it creates.
- -- --template vanilla-ts tells Vite to use the vanilla TypeScript template, skipping the interactive template picker.
Seeing a prompt to install create-vite?
If your terminal asks "Need to install the following packages: create-vite@latest. Ok to proceed? (y)", type y and press Enter. This is a one-time install of the Vite scaffolding tool.
- Move into your new project folder by running:
cd shader-playground
- Confirm the scaffold created your project files by running:
ls
You should see index.html, package.json, src, and tsconfig.json listed in the output.
Don't see these files?
- Make sure you ran the scaffold command from ~/Desktop, not from inside another project folder.
- Check that you typed shader-playground (with a hyphen) as the project name.
- If you see a network error, check your internet connection and try again.
Help me troubleshoot my Vite scaffold command that didn't create the expected files.
Install Paper Shaders
Paper Shaders ships breaking changes under 0.0.x versioning while the API stabilises. Pinning to a specific version prevents your project from breaking when a new release lands.
- Install Paper Shaders at the pinned version, then install the rest of the project dependencies, by running these commands:
npm install @paper-design/shaders@0.0.77
npm install
Why two install commands?
The first command adds @paper-design/shaders@0.0.77 to your package.json and installs it. The second command ensures all other dependencies from the Vite template (like typescript and vite) are also installed.
When both commands finish, you should see a node_modules folder appear in your project. The terminal output shows the number of packages installed.
Seeing install errors?
- Confirm you are inside the shader-playground folder by running pwd. You should see a path ending in shader-playground.
- If you see a permission error, try prefixing the command with sudo (macOS/Linux) or running your terminal as Administrator (Windows).
Help me fix npm install errors in my shader-playground project.
Open the project in Cursor and start the dev server
Now that the project is scaffolded and dependencies are installed, you can open it in Cursor and launch the development server.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- In Cursor, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the shader-playground folder, and click Open.
- Open Cursor's integrated terminal by pressing Ctrl+` (both macOS and Windows).
- Start the development server by running:
npm run dev
Before you open the URL, what do you expect to see in the browser? A blank page, an error, or something else?
- Hold Cmd (macOS) or Ctrl (Windows) and click the localhost URL shown in the terminal to open it in your browser.
You should see the default Vite starter page with the Vite logo and a counter button. This confirms your development environment is working.
Don't see the Vite starter page?
- Make sure the terminal still shows the dev server running. If you see an error, check that node_modules exists in your project by running ls in a new terminal tab.
- If the localhost URL does not load, try opening http://localhost:5173 manually in your browser.
- If port 5173 is in use, Vite picks the next available port. Check the terminal output for the actual URL.
Help me get the Vite dev server running for my shader-playground project.
Your development environment is ready. Next up, you will replace the starter content with a full-viewport animated shader that runs on the GPU.
Mount Your First Shader as a Hero Background
The fastest way to understand how ShaderMount works is to see a shader fill the screen. Static CSS gradients are flat and lifeless. A WebGL shader runs on the GPU, producing fluid, animated visuals that respond to parameters in real time.
In this step, you will mount a mesh gradient shader to a full-viewport hero section. The moment you save, you will see the GPU rendering flowing colors behind your text.
In this step, get ready to:
- Replace the default Vite HTML with a hero section and styling.
- Import and configure Paper Shaders in your TypeScript file.
- Mount an animated mesh gradient shader to the hero container.
Replace the default HTML with a hero section
The shader needs a DOM container with explicit dimensions. Without them, the canvas renders at zero size and you see nothing.
- In Cursor, open index.html from the shader-playground folder.
- Select all the existing content and replace it with the following HTML and CSS:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shader Playground</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #ffffff; }
#hero { position: relative; width: 100%; height: 60vh; overflow: hidden; }
.hero-content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 2rem; }
.hero-content h1 { font-size: 3rem; font-weight: 700; margin-bottom: 0.5rem; }
.hero-content p { font-size: 1.2rem; opacity: 0.8; }
</style>
</head>
<body>
<section id="hero">
<div class="hero-content">
<h1>Shader Playground</h1>
<p>GPU-accelerated visual effects with Paper Shaders</p>
</div>
</section>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
What does this code do?
- The #hero section is set to position: relative with 60vh height. This gives the shader canvas a concrete area to fill.
- The .hero-content div uses z-index: 1 so text sits above the shader canvas (which Paper Shaders positions absolutely at z-index 0).
- The overflow: hidden on #hero prevents the shader canvas from spilling outside the hero bounds.
- Save index.html.
- Switch to your browser. You should see white text reading "Shader Playground" centered on a black background.
Don't see any text?
- Make sure you saved the file. Vite hot-reloads on save, so unsaved changes will not appear.
- Confirm your browser is still pointed at the localhost URL from npm run dev. If you closed the tab, open a new one and navigate to the URL shown in the terminal.
Help me debug why my index.html changes aren't showing in the browser.
Import Paper Shaders and get the hero container
Now you will wire up the TypeScript side. Paper Shaders exports a ShaderMount class that takes a container element and a fragment shader, then prepends a WebGL canvas into that container.
- Open src/main.ts in Cursor.
- Delete all the existing boilerplate code.
- Add the following imports and container reference:
import {
ShaderMount,
meshGradientFragmentShader,
getShaderColorFromString,
} from '@paper-design/shaders';
// Get the hero container where the shader canvas will be mounted
const heroContainer = document.getElementById('hero') as HTMLElement;
What does this code do?
- ShaderMount is the class that handles WebGL context creation, the animation loop, and canvas resizing.
- meshGradientFragmentShader is a pre-built GLSL fragment shader string that produces flowing color gradients.
- getShaderColorFromString converts CSS hex colors like #5100ff into the [r, g, b, a] array format that shaders expect.
Define uniforms and mount the shader
Uniforms are the parameters you pass into a shader to control its appearance. Think of them as the knobs on a synthesizer. Each uniform drives a different visual property.
- Add the following code below the heroContainer line in src/main.ts:
// Define the uniform parameters that control the mesh gradient
const heroUniforms = {
u_colors: [
getShaderColorFromString('#5100ff'),
getShaderColorFromString('#00ff80'),
getShaderColorFromString('#ffcc00'),
getShaderColorFromString('#ea00ff'),
],
u_colorsCount: 4,
u_distortion: 0.8,
u_swirl: 0.1,
u_grainMixer: 0,
u_grainOverlay: 0,
u_fit: 2, // cover mode fills the container
u_scale: 1,
u_rotation: 0,
u_offsetX: 0,
u_offsetY: 0,
u_originX: 0.5,
u_originY: 0.5,
u_worldWidth: 0,
u_worldHeight: 0,
};
// Mount the shader, starting the WebGL animation loop immediately
const heroShader = new ShaderMount(heroContainer, meshGradientFragmentShader, heroUniforms, undefined, 1, 0);
What do the key uniforms control?
- u_colors is an array of four colors that blend together in the gradient. Each is converted from a hex string.
- u_distortion controls how much the gradient warps. Higher values produce more dramatic deformations.
- u_swirl adds a twisting rotation to the gradient pattern.
- u_fit: 2 means "cover" mode. The shader fills the entire container, just like CSS background-size: cover.
- The final ShaderMount arguments set speed to 1 (normal playback) and frame to 0 (start from the beginning).
- Save src/main.ts.
Before you switch to the browser, what do you expect to see? Will the text disappear, or will it stay on top of the shader?
- Switch to your browser.
You should see a full-viewport animated mesh gradient with flowing purple, green, yellow, and magenta colors. The text "Shader Playground" is readable on top of it, thanks to the z-index layering.
Seeing a black screen with no animation?
- Check that the id="hero" in your HTML matches getElementById('hero') in your TypeScript. A mismatch means the shader has no container to mount to.
- Make sure #hero in your CSS has height: 60vh. Without explicit dimensions, the container collapses to zero height and the canvas is invisible.
- Open DevTools and check for errors in the Console tab. A typo in the import path or uniform name will show up as a runtime error.
Help me debug why my shader isn't rendering.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shader Playground</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #ffffff; }
#hero { position: relative; width: 100%; height: 60vh; overflow: hidden; }
.hero-content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 2rem; }
.hero-content h1 { font-size: 3rem; font-weight: 700; margin-bottom: 0.5rem; }
.hero-content p { font-size: 1.2rem; opacity: 0.8; }
</style>
</head>
<body>
<section id="hero">
<div class="hero-content">
<h1>Shader Playground</h1>
<p>GPU-accelerated visual effects with Paper Shaders</p>
</div>
</section>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
For src/main.ts, scroll up and confirm you have both chunks in this order:
- The import block (ShaderMount, meshGradientFragmentShader, getShaderColorFromString) plus the heroContainer line.
- The heroUniforms object with all uniform keys, followed by the new ShaderMount(...) line assigned to heroShader.
Your first shader is live. The GPU is running a fragment shader on every pixel of that hero section, 60 times per second. Next up, you will add shader-backed cards with different effects to see how multiple shaders compose with UI elements.
Add Shader-Backed Cards with Different Effects
Your hero section proves that a single shader can fill a full-viewport container with animated visuals.
Real apps need more than one effect composed alongside UI elements like text and buttons. In this step, you will mount two additional shaders as card backgrounds and discover a critical sizing requirement that trips up every first-time ShaderMount user.
In this step, get ready to:
- Add a card grid section with two shader-backed cards below the hero.
- Mount neuro noise and god rays shaders to their respective containers.
- Fix the zero-size canvas issue by giving containers explicit dimensions.
Add the card grid HTML structure
Each shader needs its own container element. You will create two cards, each with a .card-shader div for the canvas and a .card-content div for the text overlay.
- In index.html, find the closing </section> tag of the hero section.
- Add the following card grid section directly below it (before the <script> tag):
<section class="card-grid">
<div class="card">
<div class="card-shader" id="card-neuro"></div>
<div class="card-content"><h3>Neuro Noise</h3><p>Glowing neural network pattern</p></div>
</div>
<div class="card">
<div class="card-shader" id="card-godrays"></div>
<div class="card-content"><h3>God Rays</h3><p>Radiating light beams</p></div>
</div>
</section>
What does this HTML do?
- Each .card wraps a shader container and a content overlay.
- The .card-shader divs have IDs (card-neuro, card-godrays) so TypeScript can grab them with getElementById.
- The .card-content divs hold text that will sit on top of the shader canvas via z-indexing.
- Save index.html.
- Open DevTools (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows) and expand the Elements panel.
- You should see the <section class="card-grid"> in the DOM with two .card children inside it.
Mount the neuro noise and god rays shaders
Now you will import two new fragment shaders and mount them to the card containers. Each shader uses a different set of uniforms, which is why they produce distinct visual effects.
- In src/main.ts, find the import block at the top of the file:
import {
ShaderMount,
meshGradientFragmentShader,
getShaderColorFromString,
} from '@paper-design/shaders';
- Add neuroNoiseFragmentShader and godRaysFragmentShader to the import so it looks like this:
import {
ShaderMount,
meshGradientFragmentShader,
neuroNoiseFragmentShader,
godRaysFragmentShader,
getShaderColorFromString,
} from '@paper-design/shaders';
- Scroll to the bottom of src/main.ts (below the heroShader line) and add the neuro noise shader mount:
// --- Card Shaders ---
const neuroContainer = document.getElementById('card-neuro') as HTMLElement;
const neuroUniforms = {
u_colorA: getShaderColorFromString('#0a0a2e'),
u_colorB: getShaderColorFromString('#5100ff'),
u_brightness: 1.2,
u_scale: 1,
};
new ShaderMount(neuroContainer, neuroNoiseFragmentShader, neuroUniforms, undefined, 0.5, 0);
What does this code do?
- Neuro noise uses a different uniform interface than mesh gradient. Instead of a u_colors array, it takes two colors (u_colorA and u_colorB) plus brightness and scale.
- The 0.5 speed parameter slows the animation to half speed for a subtler background effect.
- Save src/main.ts and switch to your browser.
Before you look at the page, predict: do you think the neuro noise shader will be visible on the card?
You should see nothing new below the hero. The card area appears completely empty.
The cards are invisible. This is expected.
ShaderMount uses a ResizeObserver to size the canvas to its parent container. Your .card-shader divs currently have no explicit dimensions, so the canvas renders at zero pixels.
This is the critical sizing requirement: every ShaderMount container MUST have explicit width and height, or the shader is invisible. You will fix this in the next substep.
- Still in src/main.ts, add the god rays shader mount below the neuro noise code:
const godRaysContainer = document.getElementById('card-godrays') as HTMLElement;
const godRaysUniforms = {
u_colors: [
getShaderColorFromString('#ff6b00'),
getShaderColorFromString('#ffcc00'),
getShaderColorFromString('#ff2d00'),
],
u_colorsCount: 3,
u_rays: 12,
u_fit: 2,
u_scale: 1,
u_rotation: 0,
u_offsetX: 0,
u_offsetY: 0,
u_originX: 0.5,
u_originY: 0.5,
u_worldWidth: 0,
u_worldHeight: 0,
};
new ShaderMount(godRaysContainer, godRaysFragmentShader, godRaysUniforms, undefined, 0.3, 0);
What does this code do?
- God rays uses the same u_colors array pattern as mesh gradient, but with a u_rays parameter controlling how many light beams radiate outward.
- The u_fit: 2 means "cover" mode, which stretches the shader to fill the container without letterboxing.
- Speed is set to 0.3 for a slow, ambient rotation.
- Save src/main.ts.
Fix the invisible cards with proper CSS
The shaders are mounted and running, but the canvas has zero dimensions because the containers have no height. Adding explicit sizing and positioning to the card elements solves this.
- In index.html, find the closing .hero-content p rule at the end of the <style> block.
- Add the following CSS rules below it:
.card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 2rem; max-width: 900px; margin: 0 auto; }
.card { position: relative; height: 200px; border-radius: 12px; overflow: hidden; }
.card-shader { position: absolute; inset: 0; }
.card-content { position: relative; z-index: 1; padding: 1.5rem; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; }
.card-content h3 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.25rem; }
.card-content p { font-size: 0.875rem; opacity: 0.8; }
Why does this fix the shaders?
.card gets height: 200px and position: relative. This gives the ShaderMount canvas a non-zero area to fill.
.card-shader uses position: absolute; inset: 0 to stretch across the entire card. The ResizeObserver detects this size and resizes the canvas to match.
.card-content sits on top with z-index: 1, the same layering trick you used for the hero text.
- Save index.html and switch to your browser.
Before you look, predict: will both cards show their shaders now, or just the first one?
Below the hero, you should see two cards side by side. The left card shows a dark, glowing neural pattern in purple and navy. The right card radiates warm orange and yellow light beams. Both are animated and have text content layered on top.
Don't see the shader cards?
- Make sure the new CSS rules are inside the <style> block, not outside it.
- Confirm that #card-neuro and #card-godrays in your HTML match the IDs used in getElementById in main.ts. They are case-sensitive.
- Check that .card has height: 200px. Without this, the container collapses to zero.
Why aren't my shader cards visible even after adding the CSS?
✔️ Awesome, I've got everything!
Great. Double check you have saved both index.html and src/main.ts.
ⓧ I'd like to double check the full code
Confirm you have the following chunks in index.html, in order:
- The original hero CSS rules (*, body, #hero, .hero-content and children).
- The new card CSS rules (.card-grid, .card, .card-shader, .card-content and children).
- The #hero section with .hero-content inside.
- The .card-grid section with two .card divs (IDs card-neuro and card-godrays).
- The <script> tag at the end of the body.
Confirm you have the following chunks in src/main.ts, in order:
- Imports including ShaderMount, meshGradientFragmentShader, neuroNoiseFragmentShader, godRaysFragmentShader, and getShaderColorFromString.
- Hero container grab, heroUniforms object, and heroShader instantiation.
- Neuro noise container grab, neuroUniforms (u_colorA, u_colorB, u_brightness, u_scale), and ShaderMount at speed 0.5.
- God rays container grab, godRaysUniforms (u_colors array of 3, u_rays: 12, plus fit/scale/position uniforms), and ShaderMount at speed 0.3.
You now have three independent shaders running on the same page. Next, you will add interactive controls that let you manipulate the hero shader's uniforms in real time.
Build Interactive Uniform Controls
Shaders become truly powerful when you can tweak them live. Right now, your hero gradient looks great, but the only way to change it is by editing the uniform values in code and waiting for hot reload.
In this step, you will add sliders and color pickers that call setUniforms() in real time. This shows you exactly how uniforms drive the visual output, which is the mental model you need to wire shader parameters to app state in a production codebase.
In this step, get ready to:
- Add a control panel with range sliders for distortion, swirl, and speed.
- Wire event listeners that update shader uniforms on every slider drag.
- Add color pickers that rebuild the gradient palette in real time.
Add the control panel HTML
Each slider maps to a single shader uniform. When the learner drags a range input, you will read its numeric value and pass it straight to the GPU.
- In index.html, find the closing </section> tag of the .card-grid section.
- Add this controls section directly below it (before the <script> tag):
<section class="controls">
<h2>Hero Shader Controls</h2>
<!-- Distortion slider: morphs the gradient shape -->
<div class="control-row">
<label>Distortion</label>
<input type="range" id="distortion" min="0" max="2" step="0.01" value="0.8" />
<span class="value" id="distortion-value">0.8</span>
</div>
<!-- Swirl slider: twists the gradient pattern -->
<div class="control-row">
<label>Swirl</label>
<input type="range" id="swirl" min="0" max="1" step="0.01" value="0.1" />
<span class="value" id="swirl-value">0.1</span>
</div>
<!-- Speed slider: controls animation speed -->
<div class="control-row">
<label>Speed</label>
<input type="range" id="speed" min="0" max="3" step="0.1" value="1" />
<span class="value" id="speed-value">1.0</span>
</div>
<!-- Color pickers: update the gradient palette -->
<div class="color-row">
<label>Colors</label>
<div class="color-inputs">
<input type="color" id="color-0" value="#5100ff" />
<input type="color" id="color-1" value="#00ff80" />
<input type="color" id="color-2" value="#ffcc00" />
<input type="color" id="color-3" value="#ea00ff" />
</div>
</div>
</section>
What does this HTML do?
- Each .control-row holds a label, a range input, and a <span> that displays the current value.
- The min, max, and step attributes match the valid range for each shader uniform.
- The four color inputs default to the same hex values you already passed to the hero shader's u_colors array.
- Now add the CSS to style the control panel. In the same file, find the closing .card-content p rule at the bottom of your <style> block and paste these rules below it:
/* Control panel container */
.controls { max-width: 900px; margin: 2rem auto; padding: 2rem; background: #1a1a1a; border-radius: 12px; }
.controls h2 { font-size: 1.5rem; margin-bottom: 1rem; }
/* Slider rows */
.control-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.control-row label { width: 100px; font-size: 0.875rem; opacity: 0.7; }
.control-row input[type="range"] { flex: 1; }
.control-row .value { width: 40px; font-size: 0.875rem; text-align: right; font-variant-numeric: tabular-nums; }
/* Color picker row */
.color-row { display: flex; align-items: center; gap: 1rem; margin-top: 1rem; }
.color-row label { width: 100px; font-size: 0.875rem; opacity: 0.7; }
.color-inputs { display: flex; gap: 0.5rem; }
.color-inputs input[type="color"] { width: 40px; height: 40px; border: none; border-radius: 8px; cursor: pointer; }
What do these styles do?
The .controls container uses a dark background and rounded corners to visually separate the panel from the shader content above.
Each .control-row uses flexbox so the label, slider, and value display sit neatly on one line. The font-variant-numeric: tabular-nums keeps the value display from jittering as numbers change width.
- Save index.html.
- Switch to your browser. You should see a dark rounded panel below the cards with three labeled sliders (Distortion, Swirl, Speed) and four colored squares.
Don't see the control panel?
- Make sure the <section class="controls"> block is outside the .card-grid section, not nested inside it.
- Check that the CSS rules are inside the <style> block, not after the closing </style> tag.
- If the sliders appear but look unstyled, confirm you have no typos in the class names (control-row not controlRow).
Help me debug my control panel layout.
Wire up the slider event listeners
The sliders look good, but dragging them does nothing yet. You need to listen for input events and pass the new value to the shader on every frame the learner drags.
- Switch to src/main.ts in Cursor.
- At the bottom of the file, below the god rays ShaderMount call, add this code to grab the slider elements and attach listeners:
// --- Controls ---
const distortionSlider = document.getElementById('distortion') as HTMLInputElement;
const swirlSlider = document.getElementById('swirl') as HTMLInputElement;
const speedSlider = document.getElementById('speed') as HTMLInputElement;
const distortionValue = document.getElementById('distortion-value') as HTMLElement;
const swirlValue = document.getElementById('swirl-value') as HTMLElement;
const speedValue = document.getElementById('speed-value') as HTMLElement;
distortionSlider.addEventListener('input', () => {
const value = Number(distortionSlider.value);
heroShader.setUniforms({ u_distortion: value });
distortionValue.textContent = value.toFixed(2);
});
swirlSlider.addEventListener('input', () => {
const value = Number(swirlSlider.value);
heroShader.setUniforms({ u_swirl: value });
swirlValue.textContent = value.toFixed(2);
});
speedSlider.addEventListener('input', () => {
const value = Number(speedSlider.value);
heroShader.setSpeed(value);
speedValue.textContent = value.toFixed(1);
});
What does this code do?
- Each listener reads the slider's current value on every input event (fires continuously while dragging, not just on release).
- For distortion and swirl, heroShader.setUniforms() passes the new number directly to the GPU. The shader recalculates on the next frame.
- Speed uses a separate method heroShader.setSpeed() because it controls the animation loop timing, not a shader uniform value.
- The .textContent update shows the learner the exact numeric value next to each slider.
- Save src/main.ts.
- Switch to your browser and drag the Distortion slider to the right. The hero gradient should visibly warp and stretch as you drag.
- Drag the Speed slider to 0. The animation freezes. Drag it to 3 and it races.
Sliders not changing the shader?
- Open DevTools (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows) and check the console for errors. A common mistake is misspelling distortion in the getElementById call so it returns null.
- Make sure your listener code is below the line const heroShader = new ShaderMount(...) so that heroShader is defined when the listeners reference it.
- Confirm the uniform name is exactly u_distortion (with underscore prefix). Typos in uniform names fail silently.
Help me debug my shader slider controls.
Add color pickers that update the gradient palette
The mesh gradient shader accepts an array of colors as a uniform. When you change any color, you need to rebuild the entire array and pass it back to the shader.
- Still in src/main.ts, add this code below your speed slider listener:
// --- Color Pickers ---
const colorIds = ['color-0', 'color-1', 'color-2', 'color-3'];
colorIds.forEach((id, index) => {
const input = document.getElementById(id) as HTMLInputElement;
input.addEventListener('input', () => {
// Rebuild the full color array from all four pickers
const colors = colorIds.map((cid) => {
const el = document.getElementById(cid) as HTMLInputElement;
return getShaderColorFromString(el.value);
});
heroShader.setUniforms({ u_colors: colors, u_colorsCount: colors.length });
});
});
Why rebuild the entire array?
The shader expects u_colors as a complete array on every update. You cannot patch a single index. So when any one color picker fires, you read all four picker values, convert each from a hex string to a shader-friendly [r, g, b, a] tuple with getShaderColorFromString(), and send the full array.
- Save src/main.ts.
Before you test, think about what you expect to see. If you change just one color picker from purple to bright red, will the entire gradient change or only part of it?
- Click the first color picker (currently purple) and choose a bright red.
- Watch the hero gradient. One of the four flowing colors shifts from purple to red while the others stay the same.
- Now drag the Distortion slider while the new color is active. The red blends and warps with the other colors in real time.
Color picker not updating the gradient?
- Check that your color input IDs match exactly: color-0, color-1, color-2, color-3 (with hyphens, not underscores).
- Make sure getShaderColorFromString is already imported at the top of main.ts. It should be part of your existing import from @paper-design/shaders.
- If you see a type error, confirm you cast the element with as HTMLInputElement so .value is available.
Help me debug my color picker uniform update.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these chunks is present in your code, in order:
- In index.html: the <section class="controls"> block with three .control-row divs and one .color-row div, placed between the card grid and the script tag.
- In index.html: CSS rules for .controls, .control-row, .color-row, and .color-inputs at the bottom of the style block.
- In src/main.ts: six getElementById calls for the three sliders and three value spans.
- In src/main.ts: three addEventListener('input', ...) blocks calling setUniforms or setSpeed.
- In src/main.ts: the colorIds.forEach block that rebuilds u_colors from all four pickers on every input event.
You now have a live control surface that talks directly to the GPU. Drag any slider and the shader responds within a single frame. Next up, you will extract all of this mounting logic into a reusable helper function you can drop into any TypeScript codebase.
Extract a Reusable mountShader Helper
You have shader mounting logic repeated three times in main.ts. Each call follows the same pattern: grab a container, define uniforms, call new ShaderMount(...) with nearly identical arguments.
Extracting this into a typed helper function gives you a clean, reusable pattern you can drop into any TypeScript codebase. It also centralises disposal logic so you never accidentally leak a WebGL context.
In this step, get ready to:
- Create a shader-utils.ts helper module with mountShader and disposeShader functions.
- Refactor main.ts to replace all inline ShaderMount calls with your helper.
- Verify the page looks and behaves identically after the refactor.
Create the mountShader helper function
A helper function encapsulates the mounting pattern and handles one critical detail: disposing any existing shader on the same container before creating a new one. Without this, remounting to the same element leaks the old WebGL context.
- Create a new file called src/shader-utils.ts in the src folder of your project.
- Add the mountShader function by pasting this code:
import { ShaderMount, type ShaderMountUniforms } from '@paper-design/shaders';
interface MountShaderOptions {
speed?: number;
frame?: number;
}
export function mountShader(
container: HTMLElement,
fragmentShader: string,
uniforms: ShaderMountUniforms,
options: MountShaderOptions = {}
): ShaderMount {
const { speed = 1, frame = 0 } = options;
// Dispose existing shader on this container to prevent WebGL context leaks
const existing = (container as any).paperShaderMount as ShaderMount | undefined;
if (existing) { existing.dispose(); }
return new ShaderMount(container, fragmentShader, uniforms, undefined, speed, frame);
}
What does this code do?
The MountShaderOptions interface defines optional speed and frame parameters with sensible defaults (speed 1, frame 0).
Before creating a new ShaderMount, the function checks container.paperShaderMount for an existing instance and calls dispose() on it. This prevents WebGL context leaks when you remount a shader to the same container.
The function returns the new ShaderMount instance so callers can still call setUniforms() or setSpeed() on it.
- Save src/shader-utils.ts.
Seeing a red underline on ShaderMountUniforms?
Make sure you import it as a type with type ShaderMountUniforms (using the type keyword). If your editor still complains, confirm you installed @paper-design/shaders@0.0.77 in Step 1.
Help me fix a TypeScript import error for ShaderMountUniforms in my shader-utils.ts file
Refactor main.ts to use the helper
Now replace the three inline new ShaderMount(...) calls with your mountShader function. This is a pure refactor. The page should look identical afterwards.
- In src/main.ts, find the import block at the top of the file:
import {
ShaderMount,
meshGradientFragmentShader,
neuroNoiseFragmentShader,
godRaysFragmentShader,
getShaderColorFromString,
} from '@paper-design/shaders';
- Replace it with this updated import block that removes ShaderMount and adds the import from your new helper:
import {
meshGradientFragmentShader,
neuroNoiseFragmentShader,
godRaysFragmentShader,
getShaderColorFromString,
} from '@paper-design/shaders';
import { mountShader } from './shader-utils';
Why remove ShaderMount from the import?
Your shader-utils.ts file now owns the ShaderMount import internally. The rest of your app only needs to call mountShader without knowing about the underlying class.
- Find the hero shader instantiation line:
const heroShader = new ShaderMount(heroContainer, meshGradientFragmentShader, heroUniforms, undefined, 1, 0);
- Replace it with:
const heroShader = mountShader(heroContainer, meshGradientFragmentShader, heroUniforms);
- Find the neuro noise shader instantiation line:
new ShaderMount(neuroContainer, neuroNoiseFragmentShader, neuroUniforms, undefined, 0.5, 0);
- Replace it with:
mountShader(neuroContainer, neuroNoiseFragmentShader, neuroUniforms, { speed: 0.5 });
- Find the god rays shader instantiation line:
new ShaderMount(godRaysContainer, godRaysFragmentShader, godRaysUniforms, undefined, 0.3, 0);
- Replace it with:
mountShader(godRaysContainer, godRaysFragmentShader, godRaysUniforms, { speed: 0.3 });
- Save src/main.ts.
Before you check the browser, predict: should anything look different after this refactor?
- Switch to your browser and confirm the page still shows the animated hero, two shader cards, and working sliders.
Everything should look and behave exactly as it did before. The hero gradient animates, the cards glow, and dragging the sliders still morphs the hero in real time.
Page is blank or showing errors?
Check your terminal for TypeScript errors. The most common cause is a typo in the import path. Make sure you wrote './shader-utils' (with the leading ./) in the import statement.
Also confirm you removed ShaderMount from the @paper-design/shaders import line. Leaving it creates a duplicate reference that Vite may not resolve.
Help me debug why my page went blank after refactoring to use mountShader
Add the disposeShader cleanup utility
Browsers limit concurrent WebGL contexts to roughly 8-16. In a single-page app, navigating away from a shader page without calling dispose() leaks that context permanently.
- Return to src/shader-utils.ts and add this function below mountShader:
export function disposeShader(container: HTMLElement): void {
const existing = (container as any).paperShaderMount as ShaderMount | undefined;
if (existing) { existing.dispose(); }
}
What does disposeShader do?
It looks up the paperShaderMount property that ShaderMount attaches to the parent element and calls dispose() on it. This cancels the animation loop, deletes WebGL resources, disconnects the ResizeObserver, and removes the canvas from the DOM.
You can call disposeShader(container) on route changes or when tearing down a page to free up WebGL contexts.
- Save src/shader-utils.ts.
- Check your terminal. Vite should show no errors and the browser should still display all three shaders animating normally.
Seeing a compile error about ShaderMount in disposeShader?
The disposeShader function uses the ShaderMount type that should already be imported at the top of the file. Make sure your first line still reads import { ShaderMount, type ShaderMountUniforms } from '@paper-design/shaders';.
Help me fix a compile error in my disposeShader function in shader-utils.ts
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/shader-utils.ts and src/main.ts.
ⓧ I'd like to double check the full code
Here is the complete src/shader-utils.ts:
import { ShaderMount, type ShaderMountUniforms } from '@paper-design/shaders';
interface MountShaderOptions {
speed?: number;
frame?: number;
}
export function mountShader(
container: HTMLElement,
fragmentShader: string,
uniforms: ShaderMountUniforms,
options: MountShaderOptions = {}
): ShaderMount {
const { speed = 1, frame = 0 } = options;
const existing = (container as any).paperShaderMount as ShaderMount | undefined;
if (existing) { existing.dispose(); }
return new ShaderMount(container, fragmentShader, uniforms, undefined, speed, frame);
}
export function disposeShader(container: HTMLElement): void {
const existing = (container as any).paperShaderMount as ShaderMount | undefined;
if (existing) { existing.dispose(); }
}
For src/main.ts, confirm these chunks are present in order: the updated imports (no ShaderMount, plus import { mountShader } from './shader-utils'), the hero uniform definition and mountShader call, the neuro noise mountShader call with { speed: 0.5 }, the god rays mountShader call with { speed: 0.3 }, and all slider/color event listeners unchanged from Step 4.
Your shader mounting logic now lives in a single, typed helper function. You can drop src/shader-utils.ts into any TypeScript project and call mountShader to add GPU-accelerated backgrounds in one line. Next up in the Secret Mission, you will build a shader switcher that swaps effects on a single canvas using this exact helper.
Secret mission
Build a Shader Switcher
What if users could choose their own background effect? Build a shader switcher that lets you swap between all available effects on the hero canvas, with proper WebGL context cleanup so nothing leaks.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The shader-playground project folder on your Desktop (contains source code, node_modules, and build config).
- The Vite dev server process (if still running in your terminal).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with shader effects.
- Your shader-playground folder stays on your Desktop with zero ongoing cost.
- You can restart the dev server any time by opening a terminal in the project folder and running npm run dev.
- Copy src/shader-utils.ts into any other TypeScript project to reuse the mountShader helper.
Pause - I'll come back to this later
Stop the dev server to free up your terminal. Your project files stay on disk, ready to pick up again.
- Switch to the terminal where the Vite dev server is running.
- Press Ctrl+C to stop the server.
- Your shader-playground folder remains on your Desktop. When you are ready to resume, open a terminal in that folder and run npm run dev.
Delete - I don't want to use this again
Remove all project files and start fresh. This frees disk space taken by node_modules and build artifacts.
- If the Vite dev server is still running, press Ctrl+C in that terminal to stop it.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/shader-playground
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\shader-playground
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). You should no longer see shader-playground listed.
Nice Work!
Nice Work!
You just built a GPU-accelerated shader playground from scratch using Vite, TypeScript, and Paper Shaders. Your page renders animated WebGL effects as hero backgrounds and card accents, with real-time controls that morph them on the fly.
You've learned how to:
- Mount WebGL shaders to DOM elements using ShaderMount and compose them with UI content through z-indexing and absolute positioning.
- Manipulate shader uniforms in real time with interactive sliders and color pickers, controlling distortion, swirl, speed, and colors on the GPU.
- Extract a reusable, typed mountShader helper function that handles disposal and lifecycle, ready to drop into any TypeScript codebase.
- Secret Mission: Built a shader switcher that swaps effects on a single container while properly managing WebGL context lifecycle.
Ready to quiz yourself?