A Dot’s Journey
A Love Letter to React and Shaders
Shaders have always held extraordinary power—capable of light, motion, and worlds that feel alive. But working with them has never felt natural. GLSX brings the philosophy of React to graphics: simple, interactive, and beautifully composable.
Every great journey begins with a single step. In the world of shaders, that step is a dot—a simple circle that will guide us through the fundamentals of GPU programming, from basic shapes to stunning visual effects.
Setup
Let's start by installing GLSX into your React project:
npm install glsxNow you can create your first shader component:
import { Canvas, glsl } from 'glsx'
export default function App() {
return (
<Canvas width={300} height={300}>{
glsl.fragment`
return vec4(0.0, 0.0, 0.0, 0.0);
`
}</Canvas>
)
}The Canvas component creates a HTML canvas with WebGL context. Within it we implemented a GLSX fragment shader inside a tagged string.
A fragment shader must return a RGBA color. In our code, we are returning a vec4(0.0, 0.0, 0.0, 0.0) vector. It is very similar to the rgba(0, 0, 0, 0) syntax in CSS. This renders as fully transparent black (a blank canvas).
Now let's change the alpha value from 0 to uv.x, it's rendering a gradient:
import { Canvas, glsl } from 'glsx'
export default function App() {
return (
<Canvas width={300} height={300}>{
glsl.fragment`
return vec4(0.0, 0.0, 0.0, uv.x);
`
}</Canvas>
)
}uv is a built-in variable ("uniform" in GLSL shaders) representing normalized X and Y coordinates (from 0.0 to 1.0) for each pixel. The fragment shader executes in parallel on the GPU for every pixel's X and Y.
Hence vec4(0.0, 0.0, 0.0, uv.x)'s alpha value changes dynamically from 0 to 1.
import { Canvas, glsl } from 'glsx'
function MyShader() {
return glsl.fragment`
return vec4(0.0, 0.0, 0.0, uv.x);
`
}
export default function App() {
return (
<Canvas width={300} height={300}>
<MyShader />
</Canvas>
)
}From here on, examples will focus on the shader code and omit the boilerplate setup.
Birth of a Dot
Every journey begins somewhere. Let's create our first circle—a simple dot that will be our companion throughout this exploration.
function Circle() {
return glsl.fragment`
float dist = distance(uv, vec2(0.5, 0.5));
bool inCircle = dist < 0.25;
return inCircle ? vec4(0.0, 0.0, 0.0, 1.0) : vec4(1.0);
`
}We use the distance function to calculate how far each pixel is from the center (0.5, 0.5). If it's within our radius of 0.25, we draw black; otherwise, white. Notice the hard edge—our dot is sharp and crisp, almost pixelated.
Smooth Edges
Hard edges have their place, but our dot deserves refinement. The smoothstep function creates smooth transitions, giving our circle soft, anti-aliased edges.
function Circle() {
return glsl.fragment`
float dist = distance(uv, vec2(0.5, 0.5));
float circle = smoothstep(0.25, 0.24, dist);
return vec4(vec3(0.0), circle);
`
}smoothstep(0.25, 0.24, dist) creates a smooth transition between our two edge values. When the distance is greater than 0.25, it returns 0 (transparent). When it's less than 0.24, it returns 1 (solid). Between these values, it smoothly interpolates. This tiny gradient is what creates the anti-aliasing effect.
Adding Color
A dot in black and white is elegant, but color brings it to life. Let's paint our circle with a beautiful blue hue.
function Circle() {
return glsl.fragment`
float dist = distance(uv, vec2(0.5, 0.5));
float circle = smoothstep(0.25, 0.24, dist);
vec3 color = vec3(0.2, 0.5, 0.9);
return vec4(color * circle, 1.0);
`
}We can even create gradients within our circle, blending colors from center to edge:
function Circle() {
return glsl.fragment`
float dist = distance(uv, vec2(0.5, 0.5));
float circle = smoothstep(0.25, 0.24, dist);
vec3 innerColor = vec3(1.0, 0.3, 0.5);
vec3 outerColor = vec3(0.2, 0.5, 0.9);
vec3 color = mix(outerColor, innerColor, dist / 0.25);
return vec4(color * circle, 1.0);
`
}The mix function blends between two colors based on a third parameter. By dividing dist by our radius (0.25), we get a value from 0 to 1 that creates a radial gradient.
Making it Reusable
Now that we have the basics down, let's create a reusable circle component that accepts its center position as props:
function Circle({ x = 0.5, y = 0.5 }: { x: number; y: number }) {
return glsl.fragment`
bool inCircle = distance(uv, vec2(${x}, ${y})) < 0.25;
return inCircle ? vec4(0.0, 0.0, 0.0, 1.0) : vec4(1.0);
`
}
function MyShader() {
return glsl.fragment`
vec4 circle1 = ${<Circle x={0.3} y={0.3} />};
vec4 circle2 = ${<Circle x={0.7} y={0.7} />};
return min(circle1, circle2);
`
}
Here, we've created a Circle component that takes x and y props to define its center. The component uses the GLSL distance function to determine whether each fragment lies within a radius of 0.25 from the center.
We then reuse this Circle component twice to draw two circles at different positions. The min(circle1, circle2) function combines them by taking the minimum value for each RGBA channel. Since black pixels have a value of 0.0, any pixel that's black in either circle will be black in the final output.
This componentized approach also enables seamless mixing of GLSL shaders with HTML elements. For example, here's a gradient logo component:
function GradientLogo({ name }: { name: string }) {
return <>
{glsl.fragment`
return vec4(uv.x, uv.y, 0.5, 1.0);
`}
<h1 className='absolute inset-0 flex items-center justify-center font-black text-6xl bg-clip-text text-transparent'>
{name}
</h1>
</>
}Then, you can use <GradientLogo name="GLSX" /> inside your main shader component just like any other React component. Remember to set the parent container to position: relative.
Giving it Life
A static dot is beautiful, but what if we could control it? Let's add interactivity with React state, allowing sliders to control our circle's position:
import { useState } from 'react'
import { Canvas, glsl } from 'glsx'
function MyShader() {
const [x, setX] = useState(0.5)
const [y, setY] = useState(0.5)
return <>
{glsl.fragment`
bool inCircle =
distance(uv, vec2(${x}, ${y})) < 0.25;
return inCircle
? vec4(0.0, 0.0, 0.0, 1.0)
: vec4(1.0, 1.0, 1.0, 1.0);
`}
<div className='flex flex-col gap-2 p-2'>
<input
type='range'
min={0}
max={1}
step={0.01}
value={x}
onChange={e => {
setX(Number(e.target.value))
}}
/>
<input
type='range'
min={0}
max={1}
step={0.01}
value={y}
onChange={e => {
setY(Number(e.target.value))
}}
/>
</div>
</>
}In this example, we use React's useState hook to create two state variables, x and y, which represent the center of the circle. We then use these variables in our fragment shader to determine if the current fragment is inside the circle using the GLSL built-in distance function.
What's powerful here is that GLSX allows us to seamlessly integrate React's state management with GLSL uniform variables — think of that as two-way data binding between React state and GLSL, just like how we bind data to the DOM.
The Flow of Time
Our dot can now be positioned anywhere. But what about motion? Time is the secret ingredient that brings animation to life. Let's create a useTime hook to track elapsed time:
import { useEffect, useState } from 'react'
export function useTime() {
const [time, setTime] = useState(0)
useEffect(() => {
const start = performance.now()
const interval = setInterval(() => {
setTime((performance.now() - start) / 1000)
}, 16)
return () => clearInterval(interval)
}, [])
return time
}This hook updates approximately 60 times per second, giving us smooth time-based animations. Now we can use it to bring motion to our dot:
import { Canvas, glsl } from 'glsx'
import { useTime } from './use-time'
function MyShader() {
const time = useTime()
return glsl.fragment`
float x = 0.5 + 0.2 * sin(${time});
float y = 0.5 + 0.2 * cos(${time});
float dist = distance(uv, vec2(x, y));
float circle = smoothstep(0.2, 0.19, dist);
return vec4(vec3(0.0), circle);
`
}By using sine and cosine functions with our time value, we create circular motion. The dot orbits around the center, forever tracing its path through time.
Freedom to Move
Our dot can move on its own, but what if we want direct control? Let's create a Draggable wrapper component that lets us drag the canvas content with our mouse. This demonstrates the power of higher-order components in GLSX:
import { useState } from 'react'
import { Canvas, glsl } from 'glsx'
import { Draggable } from './draggable'
function MyShader() {
return <>
<Draggable>
{glsl.fragment`
bool inCircle =
distance(uv, vec2(0.5, 0.5)) < 0.25;
return inCircle
? vec4(0.0, 0.0, 0.0, 1.0)
: vec4(1.0, 1.0, 1.0, 1.0);
`}
</Draggable>
</>
}
export default function App() {
return (
<Canvas width={300} height={300}>
<MyShader />
</Canvas>
)
}The Draggable component works by modifying the uv coordinates before rendering its children. Here's a simplified view of the implementation:
// draggable.tsx
import { glsl, useCanvas } from 'glsx'
import { useEffect, useState } from 'react'
export function Draggable({ children }: { children: React.ReactNode }) {
const { ref, dpr } = useCanvas()
const [xOffset, setXOffset] = useState(0)
const [yOffset, setYOffset] = useState(0)
useEffect(() => {
// Setup mouse event listeners for dragging
// Update xOffset and yOffset based on mouse movement
}, [ref])
return glsl.fragment`
vec2 uv0 = uv;
uv.x = uv.x - ${xOffset * dpr} / resolution.x;
uv.y = uv.y + ${yOffset * dpr} / resolution.y;
vec4 result = ${children};
uv = uv0;
return result;
`
}It saves the original uv, applies offsets to shift the coordinate space, renders children in this shifted space, then restores the original coordinates. This pattern—modifying context, rendering children, then restoring—is fundamental to building reusable shader effects.
Patterns and Repetition
One dot is elegant, but what about many? Using the same pattern as Draggable, we can create a Repeat component that tiles any shader into a grid:
function Repeat({ count = 5, children }) {
return glsl.fragment`
vec2 uv0 = uv;
uv = mod(uv * ${count}, 1.0);
vec4 result = ${children};
uv = uv0;
return result;
`
}
function Dot() {
return glsl.fragment`
float dist = distance(uv, vec2(0.5));
float circle = smoothstep(0.4, 0.38, dist);
return vec4(vec3(0.0), circle);
`
}
// Usage:
<Repeat count={5}>
<Dot />
</Repeat>mod(uv * count, 1.0) scales the coordinates and wraps them back to the 0-1 range. Each cell gets its own local coordinate space. The Dot component knows nothing about repetition—it just draws a single circle at (0.5, 0.5). By wrapping it with Repeat, we get a 5×5 grid.
Since both Draggable and Repeat follow the same pattern, they compose naturally:
<Draggable>
<Repeat count={5}>
<Dot />
</Repeat>
</Draggable>Try dragging the grid. The entire pattern moves together because Draggable shifts the coordinates before Repeat tiles them. This is the power of composition—simple wrappers combine to create complex, interactive effects.
Effects and Transformations
Now that we understand how wrapper components work, we can stack them to create layered effects. A Pixelate component can be combined with Draggable to create retro-style graphics:
import { Canvas, glsl } from 'glsx'
import { Pixelate, Draggable } from './utils'
function Circle() {
return glsl.fragment`
float dist = distance(uv, vec2(0.5, 0.5));
float circle = smoothstep(0.25, 0.24, dist);
return vec4(vec3(0.0), circle);
`
}
export default function App() {
return (
<Canvas width={300} height={300}>
<Pixelate pixelSize={0.02}>
<Draggable>
<Circle />
</Draggable>
</Pixelate>
</Canvas>
)
}The order matters! Watch what happens when we swap Pixelate and Draggable:
export default function App() {
return (
<Canvas width={300} height={300}>
<Draggable>
<Pixelate pixelSize={0.02}>
<Circle />
</Pixelate>
</Draggable>
</Canvas>
)
}In the first example, we pixelate the draggable content, so the pixels move smoothly. In the second, we drag the pixelated content, so the pixels snap to a grid. This composability lets you experiment and find the perfect combination of effects.
Color and Utility Functions
Our dot has been monochrome long enough. Let's introduce the full spectrum of color. We can define reusable GLSL functions with glsl.function:
function ColorWheel() {
return <>
{glsl.function`
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
`}
{glsl.fragment`
vec2 center = uv - 0.5;
float angle = atan(center.y, center.x);
float hue = angle / 6.28318 + 0.5;
float dist = length(center);
float circle = smoothstep(0.45, 0.42, dist);
vec3 color = hsv2rgb(vec3(hue, 1.0, 1.0));
return vec4(color, circle);
`}
</>
}
// Compose with Pixelate and Draggable:
<Pixelate pixelSize={0.04}>
<Draggable>
<ColorWheel />
</Draggable>
</Pixelate>The hsv2rgb function converts HSV (Hue, Saturation, Value) to RGB. We use atan(center.y, center.x) to get the angle from center, then map it to hue—creating a color wheel. Wrapping with Pixelate gives it that chunky, retro aesthetic. Try dragging and adjusting the pixel size.
Now let's combine our rainbow with our circle to create something truly colorful:
function Circle() {
return glsl.fragment`
bool inCircle = distance(uv, vec2(0.5, 0.5)) < 0.25;
return inCircle ? vec4(0.0, 0.0, 0.0, 1.0) : vec4(1.0);
`
}
function Rainbow() {
return <>
{glsl.function`
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
`}
{glsl.fragment`
vec3 rgb = hsv2rgb(vec3(uv.x, 1.0, 1.0));
return vec4(rgb, 1.0);
`}
</>
}
export function ExampleRainbowWithCircle() {
return (
<div className='flex flex-col'>
<Canvas width={300} height={300}>
{glsl.fragment`
vec4 circleColor = ${(
<Draggable>
<Circle />
</Draggable>
)};
// Combine the two effects:
// - Draw rainbow when inside the circle
// - Otherwise, show white
return circleColor.r < 1.0
? ${(<Rainbow />)}
: vec4(1.0);
`}
</Canvas>
</div>
)
}We can take this further with blend modes. Let's create a Blend component that combines two shaders using different blending algorithms—just like layer blend modes in Photoshop:
function Blend({
layerA,
layerB,
mode = 'multiply',
}) {
if (mode === 'multiply') {
return glsl.fragment`
vec4 colorA = ${layerA};
vec4 colorB = ${layerB};
return colorA * colorB;
`
} else if (mode === 'screen') {
return glsl.fragment`
vec4 colorA = ${layerA};
vec4 colorB = ${layerB};
return vec4(1.0) - (vec4(1.0) - colorA) * (vec4(1.0) - colorB);
`
} else if (mode === 'overlay') {
return glsl.fragment`
vec4 colorA = ${layerA};
vec4 colorB = ${layerB};
vec4 result;
for (int i = 0; i < 3; i++) {
if (colorA[i] < 0.5) {
result[i] = 2.0 * colorA[i] * colorB[i];
} else {
result[i] = 1.0 - 2.0 * (1.0 - colorA[i]) * (1.0 - colorB[i]);
}
}
result.a = 1.0;
return result;
`
}
}
function MyShader() {
const [mode, setMode] = useState('multiply')
return <>
<Blend
mode={mode}
layerA={<Rainbow />}
layerB={
<Draggable>
<Circle />
</Draggable>
}
/>
<select
value={mode}
onChange={e => setMode(e.target.value)}
className=''
>
<option value='multiply'>Multiply</option>
<option value='screen'>Screen</option>
<option value='overlay'>Overlay</option>
</select>
</>
}Debugging and Visualization
Complex shaders can be hard to debug. GLSX makes this easier by letting you build debugging tools right into your components. A Grid wrapper overlays coordinate guidelines, while a LayerControl component lets you reorder and toggle layers:
<Grid>
<LayerControl
layers={[
{
name: 'Red Square',
color: '#ef4444',
content: glsl.fragment`
vec2 p = abs(uv - 0.5);
float sq = step(max(p.x, p.y), 0.2);
return vec4(1.0, 0.0, 0.0, sq);
`,
},
{
name: 'Blue Circle',
color: '#3b82f6',
content: glsl.fragment`
float d = distance(uv, vec2(0.3, 0.3));
float c = smoothstep(0.2, 0.19, d);
return vec4(0.2, 0.5, 1.0, c);
`,
},
{
name: 'Black Circle',
color: '#1f2937',
content: glsl.fragment`
float d = distance(uv, vec2(0.7, 0.7));
float c = smoothstep(0.2, 0.19, d);
return vec4(0.0, 0.0, 0.0, c);
`,
},
]}
/>
</Grid>Toggle visibility, reorder layers, and drag the blue circle. The grid shows coordinate space while layer controls let you experiment with rendering order. These tools are themselves GLSX components—you can build your own debugging utilities tailored to your needs.
Textures and Images
Our dot has traveled far, but what about working with images? GLSX makes it easy to load and manipulate textures using glsl.sampler2D:
import { glsl } from 'glsx'
function Logo() {
return glsl.fragment`
vec4 texColor = texture(
${glsl.sampler2D('/vercel.png')},
(uv - .5) * vec2(1., 3.) + .5
);
return vec4(texColor.rgb, 1.0);
`
}Textures compose seamlessly with all our other components. Let's pixelate an image with a controllable pixel size:
import { sampler2D } from 'glsx'
function Logo() {
const [size, setSize] = useState(0.2)
return <Pixelate pixelSize={size}>
{
glsl.fragment`
vec4 texColor = texture(
${sampler2D('/him.jpg')},
uv
);
return vec4(texColor.rgb, 1.0);
`
}
<input
type='range'
min={0.05}
max={0.5}
step={0.01}
value={size}
onChange={e => setSize(+e.target.value)}
/>
</Pixelate>
}Memory and Persistence
What if our dot could remember where it's been? The backbuffer gives shaders access to the previous frame, enabling effects like motion blur, trails, and feedback loops. Let's create a dot that leaves a fading trail:
function MotionBlur() {
const [mix, setMix] = useState(0.9)
const time = useTime()
return <>
{glsl.fragment`
// Get the previous frame
vec4 prevFrame = texture(${glsl.backbuffer()}, uv);
// Create a moving circle
vec2 center = vec2(
0.5 + 0.3 * sin(${time * 2}),
0.5 + 0.3 * cos(${time * 2})
);
float dist = distance(uv, center);
float circle = smoothstep(0.1, 0.09, dist);
vec4 currentFrame = vec4(circle, circle, circle, 1.0);
// Mix with previous frame for trail effect
return mix(currentFrame, prevFrame, ${mix});
`}
<input
type='range'
min={0.5}
max={1}
step={0.01}
value={mix}
onChange={(e) => setMix(parseFloat(e.target.value))}
/>
</>
}
By mixing the current frame with the previous one, we create a temporal effect. The mix value controls how quickly the trail fades—higher values preserve more of the past, creating longer, more ghostly trails.
The Journey Continues
From a simple dot to complex effects, we've explored the fundamentals of shader programming through GLSX. Our circle has learned to exist, to move, to multiply, to remember, and to transform itself in countless ways.
But this is just the beginning. With these building blocks—smooth edges, colors, time, patterns, effects, and composition—you can create infinite variations. The beauty of shaders lies not in their complexity, but in how simple rules combine to create emergence: the unexpected, the beautiful, the alive.
GLSX gives you the tools to explore this space with the familiarity of React. Components, hooks, props, state—everything you know about building UIs now applies to building graphics. Your dot's journey doesn't end here. It's an invitation to begin your own.
Ready to start? Install GLSX and let your creativity flow.
npm install glsx