How to Set WebGL Shader Colors with CSS and JavaScript
January 29, 2025
WebGL shaders rendered to canvas
elements are a great way to add interesting visuals to a website. Unfortunately, unlike HTML, they do not natively integrate with CSS. This post shows a way to work around this limitation and influence WebGL graphics from CSS.
WebGL shaders are programs that the browser runs on the GPU. They are generally used for graphics, which is what we’re focusing on here; in particular we’ll be looking at rendering quads (rectangles) with fragment shaders. For more information I recommend https://webglfundamentals.org/ and the MDN WebGL tutorial. Finally, a lot of what’s happening here is implemented in my quad-shader
WebGL library/template.
What we are aiming for
The sphere below (full code here) has colors derived from the styling of the website. Try toggling dark mode on or off, or even opening the webtools and changing the color
or accent-color
properties on the <canvas>
element. Dark colors will turn light, and vice versa.
The colors used in the fragment shader (responsible for drawing the animation) are dynamically tied to the page’s styles. This means the shader responds in real-time to CSS changes, such as toggling dark mode, applying transitions, or altering properties via the browser’s dev tools. This ensures WebGL visuals adapt seamlessly to your design system without needing manual updates.
This is not a given and will take a bit of work. After a quick recap on how to specify a color as an input to the shader, we’ll then figure out how to politely ask the browser to resolve CSS colors in a format that we can understand (before passing it as an input to the shader). Then we’ll see a couple of niceties with this approach, like out-of-the-box support for CSS transitions.
Let’s go!
WebGL does not care about CSS
To understand the problem, look at the following image:
This image (which is much simpler than the sphere above, for clarity) is also created using a WebGL shader. Though, unlike the sphere, it will not react to changes in the styles — toggling dark mode on or off will not affect it. And as a matter of fact, if your browser (or system) currently has dark mode enabled, you might not even be able to see the image. This is because — as mentioned above — WebGL shaders are not affected by CSS (and black-on-black is not very readable).
This simple image above might be implemented as follows:
// shader.glsl varying vec2 vPosition; void main() { vec4 color = vec4(0., 0., 0., 1.); // black // Render the color only for fragments within a grid-like pattern, // otherwise leave the fragment transparent if (mod(vPosition.x + .1, .5) > .2 && mod(vPosition.y + .1,.5) > .2) { gl_FragColor = color; } }
It is a short fragment (or pixel) shader. It is executed for every pixel (technically, fragment) in the <canvas>
element. Depending on the position of the pixel, it will either render color
(hardcoded to black here) or return without setting a color for the pixel (effectively leaving the pixel as transparent).
NoteThe
vPosition
is a varying variable, though what exactly this means is not important here. What is somewhat relevant to know is that it is set up (somewhere else) so thatvPosition.x
andvPosition.y
range between -1 and 1 (or similarly between - and + the aspect ratio).Do also note: in real life, avoid if/else branches in shaders. They make everything very slow.
Now we need a way to specify inputs to inject colors from the outside, i.e. from JavaScript.
Setting shader inputs from JavaScript
We pass data from JavaScript to the shader by setting a uniform variable. A uniform variable remains constant for every pixel during a single render pass.
This is done by replacing the local color
variable and instead using a new variable (well, identifier) uColor
, marked as uniform
:
// shader.glsl uniform vec4 uColor; varying vec2 vPosition; void main() { // Render the color only for fragments within a grid-like pattern, // otherwise leave the fragment transparent if (mod(vPosition.x + .1, .5) > .2 && mod(vPosition.y + .1,.5) > .2) { gl_FragColor = uColor; } }
NoteThe leading
u
inuColor
is a convention,u
stands for “uniform”. Similarly, thev
invPosition
stands for “varying”.
Using uniform variables is the simplest way to set data that should be the same across every execution of the shader. Uniforms can be set from JavaScript as follows:
// index.ts const color = { r: 0, g: 0, b: 0, a: 1 }; // Black gl.uniform4f( gl.getUniformLocation(program, "uColor"), color.r, color.g, color.b, color.a, );
Here gl
is the WebGLRenderingContext
that would be attached to the <canvas>
rendering the shader, and program
is the WebGLProgram
resulting from compiling the shader(s).
The uniform4f
method on the gl
context allows us to specify values for the uniform: we look up an identifier from the program (uColor
) and pass in the data. Because we declared uColor
as a vec4
in the shader — a vector of 4 elements — we have to pass 4 floats (and hence the 4f
in the method name) between 0
and 1
(and not between 0
and 255
, for instance. Shader stuff, not CSS.).
Now let’s figure out how to get the color value from CSS in a way that is practical to work with.
Getting the color from CSS
A naive solution (with some drawbacks as we’ll see in a sec) is to directly get the value from e.g. a custom CSS property:
> getComputedStyle(document.body).getPropertyValue("--col-1") '#ff0053'
The hex value of the color represents the red (ff
), green (00
) and blue (53
) components of --col-1
. As we saw above, in order to use uniform4f
to write to uColor
we need red, green and blue as values between 0
and 1.
, which we could achieve using e.g. parseInt("ff", 16)
.
Unfortunately we have no way to guarantee that all custom properties will return a hex value. Let’s assume we have another custom property, with a different CSS color notation:
/* main.css */ :root { --col-1: #ff0053; --col-2: hsl(60deg, 100%, 97%); }
In this case we see that looking up the value of a property returns the value exactly as it was set:
> getComputedStyle(document.body).getPropertyValue("--col-2") 'hsl(60deg, 100%, 97%)'
Browsers can interpret CSS colors in various formats (e.g., #hex, hsl, rgb, etc), and — unfortunately for our use case — custom properties return these values verbatim.
However, when a color is applied to a rendered element (e.g., color, background-color, or accent-color), the browser resolves the value into an easy-to-parse RGB format, regardless of how the color was originally specified. This makes it a reliable and consistent source for WebGL inputs.
Let’s set the body’s color
to --col-1
:
/* main.css */ :root { --col-1: #ff0053; } body { color: var(--col-1); }
> getComputedStyle(document.body).color 'rgb(255, 0, 83)'
Notice how the browser returned rgb(...)
even though the color was specified in hex
format (rgb(...)
would also be returned if the custom property was specified with hsl()
, for instance).
NoteWith more exotic colorspaces like
oklch()
you may get a different string. For a less efficient but more robust solution, see the Appendix.
Now it’s fairly straightforward to set the uColor
value in our shader to the element’s color
property:
// index.ts // turns 'rgb(...)' or 'rgba(...)' into { r: ..., g: ..., b: ..., a: ... } // // full implementation further down const parseRGBA = (color) => { ...} ; const color = parseRGBA(getComputedStyle(document.body).color); gl.uniform4f( gl.getUniformLocation(program, "uColor"), color.r, color.g, color.b, color.a, );
And as we’ll see below there are some extra benefits to reading the color from a rendered element’s CSS property, instead of reading it from a custom property like --col-1
. When called on every frame, this method ensures real-time updates: any CSS change (toggling dark mode, modifying styles via dev tools, etc) will immediately propagate to the shader.
NoteThe browser may return the string as either
rgb(r,g,b)
orrgba(r,g,b,a)
. Also, even though modern CSS syntax allows skipping the commas, the value returned from the browser has commas between ther
,g
,b
anda
values.
Here is the implementation of parseRGBA
using a regex match:
// index.ts // Parse an 'rgb(R, G, B)' (incl. alpha variation) string into numbers const parseRGBA = ( color: string, ): { r: number; g: number; b: number; a: number } => { const rgb = color.match( /rgb(a?)\((?<r>\d+), (?<g>\d+), (?<b>\d+)(, (?<a>\d(.\d+)?))?\)/, )!.groups as any as { r: string; g: string; b: string; a?: string }; return { r: Number(rgb.r) / 255, g: Number(rgb.g) / 255, b: Number(rgb.b) / 255, a: Number(rgb.a ?? 1), }; };
Circling back to our simple shader, we can now see that the colors are derived from the styles (the “black” is not really black anymore but whatever the page uses as primary color, be it a dark grey or a white-ish white; for greater effect try toggling dark mode on/off).
And fortunately for us, parsing the color is pretty fast, meaning we can potentially run this on every render without a huge performance hit:
> console.time("rgba"); > const colPrimary = parseRGBA(getComputedStyle(document.body).color); > console.timeEnd("rgba"); rgba: 0.02001953125 ms
Going beyond color
and document.body
As alluded to earlier, there is one extra benefit: the shader inputs do react to transitions as well! If the stylesheet specifies transition: color 1s linear;
then the shader will progressively change color over the course of a second (if the color
changes).
And we can actually go one step further. Instead of looking up the value on the body, we can directly target our <canvas>
element when looking up the color.
Finally, we can even use more than one value; the value just has to be “color”-like so that the browser actually returns a color in RGB format, that means you could for instance specify an accent-color
or background-color
on your canvas
and use getComputedStyle(canvas).accentColor
if you need more than just one color
/uColor
.
> let canvas = document.querySelector("canvas") > Object.values(getComputedStyle(canvas)).filter(x => x.endsWith("color")) ['accent-color', 'background-color', 'color', ...]
The CSS property (color
, accent-color
, etc) must however be understood by the browser as a color (this is not the case for e.g. the width
or color-scheme
CSS properties); additionally, the HTML element must be attached to the DOM (though it may be display: none;
).
Conclusion
Hopefully this gave you a good overview of how to dynamically integrate CSS styling into WebGL shaders. By reading color values from rendered elements and regularly syncing those values with the shader, you ensure your shaders react in real-time to CSS changes.
This opens up possibilities for seamless design integration, responsive animations, and even advanced interactions like transitioning WebGL visuals alongside CSS. If you have questions or feedback, feel free to reach out or explore quad-shader for inspiration. Enjoy!
Appendix
The getComputedStyle()
method in JavaScript returns the computed styles of an element, including color properties. Traditionally, it has returned colors in the rgb(r, g, b)
or rgba(r, g, b, a)
formats, as mentioned earlier. However, with the introduction of new color models in CSS, such as oklch()
, browsers may now return colors in these newer formats.
One way to add support for those new colorspaces is to actually write the data to a new <canvas>
element that will take care of converting it to RGB for us. This will return the RGBA values regardless of whether the color was originally specified in rgb
, oklch
, etc:
const parseRGBA = ( color: string, ): { r: number; g: number; b: number; a: number } => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); ctx.fillStyle = color; ctx.fillRect(0, 0, 1, 1); const data = ctx.getImageData(0, 0, 1, 1).data; const [r, g, b, a] = Array.from(data).map((x) => x / 255); return { r, g, b, a }; };
This unfortunately takes orders of magnitude longer:
> console.time("rgba"); > parseRGBA("oklch(1 2 3)") > console.timeEnd("rgba") rgb: 3.34716796875 ms
Like WebGL and JavaScript? Here's more on the topic: