The question everyone asks
"Where do I start?"
I hear this all the time from people who want to learn TSL. You've seen the amazing shader work out there, you know Three.js has this powerful new shading language, but when you sit down to code, you're staring at a blank file wondering what comes first.
The setup feels overwhelming. WebGPU renderer, materials, nodes, viewports, cameras - where do you even begin?
I've definitely been there myself, and plenty of great ideas have been lost to the void because of the whole setup process. So, how can we get past that part and just get to the good stuff?

Why a boilerplate matters
A good boilerplate removes all the friction. Instead of spending hours figuring out renderer configuration or camera setup, you get straight to the fun part: writing shaders.
I primarily work in React for many projects in my design engineering day job, so I've built a boilerplate designed to work with React Three Fiber. But increasingly, I've been finding working in vanilla JavaScript really fun.
The vanilla boilerplate I use is intentionally minimal. It's not a framework - it's just enough code to get a fullscreen canvas running with WebGPU and TSL. Everything else is up to you.
Here's what it gives you:
- A working
WebGPU rendererwith proper color space settings - A
fullscreen plane geometryready for your shader code Basic viewport handling and resize logic- A clean
animation loop Proper cleanupwhen you're done
That's it. No magic, no abstractions you need to learn - just the essentials.
Setting up with Vite
First, let's get a project running. We'll use Vite because it's fast and handles a lot of things out of the box.
Create a new directory and initialize a Vite project:
pnpm create vite@latest my-tsl-boilerplate-project --template vanilla
cd my-tsl-boilerplate-project
pnpm installThe Vite CLI will ask you a couple of questions, including whether you want to navigate to the folder, install deps and start. You can do that if you like!
Now install Three.js:
pnpm install threeThat's it for dependencies. Three.js includes everything you need for TSL and WebGPU rendering.
You should have an index.html file in the root of your project - update it to look like this (note the canvas element and script tag):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TSL Boilerplate</title>
</head>
<body>
<canvas id="webgpu-canvas"></canvas>
<script type="module" src="/src/main.js"></script>
</body>
</html>The canvas element is where your shader will render. Next, set up some styles for your canvas and clear your main.js file - we'll update that later.
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
width: 100svw;
height: 100svh;
}
canvas {
width: 100%;
height: 100%;
}There are a bunch of other files in this project that you can remove for now.
A WebGPUSketch component
I like to create a component that renders a fullscreen shader. It's a simple class that can be used in any project and takes a canvas object, a colorNode, and an optional onFrame callback.
import {
WebGPURenderer,
MeshBasicNodeMaterial,
PlaneGeometry,
Scene,
Mesh,
OrthographicCamera,
DoubleSide,
NoToneMapping,
LinearSRGBColorSpace,
} from 'three/webgpu'
import { vec3, Fn } from 'three/tsl'
class WebGPUSketch {
constructor(canvas, colorNode = null, onFrame = null) {
this._canvas = canvas
this._colorNode = colorNode
this._onFrame = onFrame
this._meshInitialized = false
}
async init() {
this._scene = new Scene()
// Viewport sizes
this._viewport = {
width: window.innerWidth,
height: window.innerHeight,
}
// Camera
this._camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 100)
this._camera.position.z = 1
this._scene.add(this._camera)
// Renderer
this._renderer = new WebGPURenderer({
canvas: this._canvas,
antialias: true,
toneMapping: NoToneMapping,
outputColorSpace: LinearSRGBColorSpace,
})
this._renderer.setSize(this._viewport.width, this._viewport.height)
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this._renderer.setClearColor('#000000')
window.addEventListener('resize', this._resizeHandler)
}
_initMesh() {
// Sketch geometry
this._geometry = new PlaneGeometry(1, 1, 1, 1)
// Sketch material
this._material = new MeshBasicNodeMaterial({
transparent: true,
side: DoubleSide,
depthWrite: false,
})
this._material.colorNode = this._colorNode ? this._colorNode : vec3(0, 0, 0)
// Add a fullscreen sketch plane to the scene
this._mesh = new Mesh(this._geometry, this._material)
this._mesh.scale.set(2, 2, 1)
this._scene.add(this._mesh)
}
_resizeHandler = () => {
// Update sizes
this._viewport.width = window.innerWidth
this._viewport.height = window.innerHeight
// Update camera
this._camera.aspect = this._viewport.width / this._viewport.height
this._camera.updateProjectionMatrix()
// Update renderer
this._renderer.setSize(this._viewport.width, this._viewport.height)
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
}
async render() {
if (!this._meshInitialized) {
this._initMesh()
this._meshInitialized = true
}
await this._renderer.renderAsync(this._scene, this._camera)
if (this._onFrame) {
this._onFrame(this._colorNode, this._renderer)
}
const frame = this.render.bind(this)
this._animationFrameId = window.requestAnimationFrame(frame)
}
dispose() {
// Cancel any pending animation frames
if (this._animationFrameId) {
window.cancelAnimationFrame(this._animationFrameId)
}
window.removeEventListener('resize', this._resizeHandler)
// Dispose geometry
if (this._geometry) {
this._geometry.dispose()
}
// Dispose material
if (this._material) {
this._material.dispose()
}
// Clear references
this._scene = null
this._geometry = null
this._material = null
this._camera = null
this._renderer = null
this._mesh = null
}
}
export default WebGPUSketchThe component pulls in Three.js WebGPU classes (WebGPURenderer, MeshBasicNodeMaterial, etc.) and TSL functions. The three/webgpu import gives us the renderer and materials, while three/tsl gives us node functions.
Breaking down the code
Let's walk through what each part does:
Scene setup - The scene is just a container for your 3D objects. Simple as that.
Viewport tracking - We store window dimensions (window.innerWidth and window.innerHeight) to update the camera and renderer on resize.
Camera - OrthographicCamera(-1, 1, 1, -1, ...) creates a fullscreen view. The bounds match our scaled plane perfectly - with a 2x2 plane, this gives us a fullscreen quad.
Renderer - The WebGPURenderer needs NoToneMapping and LinearSRGBColorSpace for correct colors. Without these, everything looks washed out. We also set antialiasing and cap the pixel ratio at 2 to avoid performance issues on high-DPI displays.
Geometry - PlaneGeometry(1, 1, 1, 1) creates a 1x1 unit plane. The last two parameters are segment counts - we keep them at 1 for a simple quad.
Material - MeshBasicNodeMaterial is what accepts TSL nodes. The settings matter:
transparent: truelets you layer effectsside: DoubleSiderenders both sides of the planedepthWrite: falseprevents z-fighting issues
The colorNode property is where we assign our TSL function - this is what actually renders your shader code.
Mesh scaling - scale.set(2, 2, 1) makes the plane fill the screen. The camera bounds are -1 to 1, so a 2x2 plane covers everything.
Resize handler - When the window resizes, we update the viewport dimensions, camera aspect ratio, and renderer size. The pixel ratio is capped at 2 to avoid performance issues on high-DPI displays.
Animation loop - The render() method calls renderAsync to render each frame. Note the await - WebGPU rendering is async, so we need to wait for each frame before starting the next. After rendering, if an onFrame callback is provided, it's called with the color node and renderer for any per-frame updates.
Cleanup - The dispose() method cancels animation frames, removes event listeners, and properly disposes of geometry, materials, and other resources to prevent memory leaks.
Save this as webgpu_sketch.js and place it in the src directory (I like to keep these in a separate components folder for easy reuse).
The main.js file
Here's the full code that goes in src/main.js:
import './style.css'
import { vec3, Fn } from 'three/tsl'
import WebGPUSketch from './webgpu_sketch'
/**
* A colorNode that outputs a red color.
*/
const uvGradientNode = Fn(() => {
return vec3(1, 0, 0)
})
const canvas = document.querySelector('#webgpu-canvas')
const sketch = new WebGPUSketch(canvas, uvGradientNode())
sketch.init()
sketch.render()Breaking down the code
Imports - We're pulling in TSL functions from three/tsl (vec3, Fn) and our WebGPUSketch component. The TSL imports give us the node functions we'll use to build shaders.
Color node definition - uvGradientNode is a TSL function node created with Fn(() => ...). Inside, vec3() creates a color vector. Here we're returning a simple red color vec3(1, 0, 0) - you can replace this with any TSL expression to create your shader.
Canvas selection - document.querySelector('#webgpu-canvas') grabs the canvas element from the HTML.
Sketch initialization - We create a new WebGPUSketch instance, passing the canvas and our color node. The component handles all the setup internally.
Run pnpm dev and you should see a red background filling your screen. That's your first TSL shader running.
Common gotchas
There are a few things that trip people up:
-
Color space - WebGPU needs
LinearSRGBColorSpaceandNoToneMappingfor correct color output. Without these, colors look washed out or wrong. -
Camera setup - The orthographic camera uses
-1, 1, 1, -1bounds for a fullscreen view. This matches the plane geometry scaled to 2x2. -
Material settings -
transparent: trueanddepthWrite: falseprevent z-fighting and let you layer effects properly. -
Async rendering - WebGPU uses
renderAsyncinstead ofrender. Don't forget theawaitor your frame loop breaks. -
Initialization order - Call
sketch.init()beforesketch.render(). The init method sets up the renderer, camera, and scene, while render starts the animation loop.
Where to go from here
Once you have the boilerplate running, the real learning begins. Start with simple patterns:
- Output UV coordinates as color - change
colorNodetovec3(uv(), 0.0) - Animate with the
timenode - multiply values bytimefor motion - Mix colors using
mixorlerp- blend between two colors - Add noise functions for texture - import noise utilities from Fragments
Each small experiment teaches you something new. The boilerplate stays the same - you're just changing what happens in that colorNode.
You can find a Sketch that you like and recreate it with the boilerplate.
The real value
A boilerplate isn't about copying code. It's about removing barriers so you can focus on what matters: learning TSL and creating visuals.
When setup is solved, you spend your time experimenting, not debugging renderer configuration. That's when the magic happens - when you're tweaking values, trying new combinations, and discovering what's possible.
The vanilla boilerplate is available on GitHub if you want to use it directly, or you can build your own following the code above. Either way, you'll have a solid foundation to start learning TSL.
If you're looking for a React boilerplate, you can use the Fragments R3F Boilerplate directly from GitHub, and also check out the Boilerplate Project Utility page for more information.

Going further
Ready to dive deeper into TSL and creative coding? Unlock Fragments to get access to the full collection including:
- 11 creative coding techniques
- 36+ helpful utility functions and components
- 110+ sketches with full source code and breakdowns
- Access to the private Fragments Discord community
I'm just getting started too! There's plenty more to come in the coming months.