@frapx/fx-canvas
fx-* — effect runtime The flagship package. Install this directly.
Lightweight WebGL shader background runtime for websites. It is not a scene, camera, mesh, or full rendering-engine abstraction — it creates and manages a WebGL canvas for an existing DOM region, then lets you drive a fragment shader with built-in uniforms, custom uniforms, textures, and an optional previous-frame feedback pass.
pnpm add @frapx/fx-canvasimport { createShaderBackground, glsl } from "@frapx/fx-canvas";
createShaderBackground({ target: ".hero", fragment: glsl` precision highp float; uniform vec2 u_resolution; uniform float u_time;
void main() { vec2 uv = gl_FragCoord.xy / u_resolution; gl_FragColor = vec4(uv, 0.5 + 0.5 * sin(u_time), 1.0); } `});What it handles for you
Section titled “What it handles for you”- Canvas setup & resize — inserts and sizes a canvas over
target, tracksResizeObserver. - Lifecycle & a11y — pauses the render loop offscreen, on hidden tabs, and under
prefers-reduced-motion(all on by default). Always suppliesu_reducedMotionso you can react in-shader too. - Built-in uniforms —
u_time,u_delta,u_resolution,u_viewportSize,u_pixelRatio,u_pointer,u_pointerUv,u_pointerActive,u_reducedMotion. - WebGL1 and WebGL2 — start your fragment with
#version 300 esto opt into GLSL ES 3.00; everything else stays WebGL1 by default. - Textures — image URL /
HTMLImageElement/HTMLCanvasElementsources, with runtime updates viasetTexture()/setTextures(). On WebGL2, a texture can also be a 3D volume from raw data —{ data, size: [nx, ny, nz], format: "r8" | "r16f" | "r32f" }— sampled assampler3Dwith a vec3u_<name>Size. - Feedback —
feedback: truegives you a managed ping-pong framebuffer exposed asu_previousFramefor effects that need to read the previous frame.feedback: { format: "rgba16f" }uses half-float buffers on WebGL2 for accumulation without banding. - Deterministic rendering —
clock: "manual"+setTime()/render()draws exactly when you ask, andresolutionfixes the render size, for frame-accurate output such as video frames. - External time —
timeSourcemakes the realtime loop follow an audio transport or any other clock, keeping the lifecycle gates.
Recommended options
Section titled “Recommended options”Most production sites only need a few options:
createShaderBackground({ target: ".hero", fragment, maxDpr: 1.5, onReady(instance) { instance.canvas?.setAttribute("aria-hidden", "true"); } // pauseWhenOffscreen, pauseWhenHidden and respectReducedMotion: keep the defaults (true)});maxDpr— the fragment shader runs once per drawing-buffer pixel, so the cost grows with the square of the pixel ratio. A full-screen background rarely looks different above 1.5; lower it further for heavy shaders.aria-hiddenon the canvas — a decorative background carries no content, so hide it from assistive technology. Put it on the canvas, not ontarget, which usually holds the page’s real content.pauseWhenOffscreen/pauseWhenHidden— stop drawing (and draining the battery) while nobody can see the canvas.respectReducedMotion— honours the OS “reduce motion” setting by holding a static frame. Turn it off only if the shader handlesu_reducedMotionitself.
Deterministic rendering
Section titled “Deterministic rendering”const fx = createShaderBackground({ target: ".stage", fragment, clock: "manual", resolution: [3840, 2160]});await fx.ready;
fx.setTime(1.5); // sets u_time onlyconst png = await fx.captureFrame(); // draws now and returns a PNG BlobUnder a manual clock nothing renders on its own, offscreen/hidden/reduced-motion
pausing is bypassed, and u_pointer* / u_reducedMotion stay at 0 — the same
time and uniforms always produce the same pixels. setClock() switches between
"realtime" and "manual" at runtime, and setResolution([w, h] | null)
changes or releases the fixed size. With resolution set, the canvas is
letterboxed (object-fit: contain) and u_pixelRatio is derived from the
internal and displayed sizes. Passing neither target nor canvas renders into
a detached canvas.
captureFrame() draws and reads back in the same task, since the drawing buffer
is not preserved. It returns a PNG Blob by default, or raw top-row-first RGBA
with { format: "rgba8" } (Uint8Array) and { format: "rgba16f" }
(Float32Array, read from float feedback buffers).
Following an audio transport
Section titled “Following an audio transport”const fx = createShaderBackground({ target: ".hero", fragment, timeSource: () => transport.getPositionMs() / 1000 // seconds});Every animation frame takes u_time from the source as is. u_delta is the
step from the previous frame, clamped to 0.1 s, and 0 when the source goes back
(a seek or a loop) or on the first frame after a pause. Unlike the manual clock,
the gates keep working: offscreen and hidden stop drawing and read the source
again on resume, reduced motion holds a still frame at the source’s time, and
the pointer uniforms work. render() and captureFrame() redraw at the last
frame’s time. setTimeSource(fn | null) changes or removes the source; a manual
clock ignores it but keeps it.
Do not align a plain realtime clock once with setTime() at play. It counts
animation frame timestamps, not the transport, so it starts 30–40 ms off and
drifts further on every loop when the audio and visual loop lengths differ, and
it cannot follow seeks or pauses.
Float feedback
Section titled “Float feedback”feedback: { format: "rgba16f" } needs WebGL2 with EXT_color_buffer_float
and requests a WebGL2 context even for GLSL ES 1.00 shaders. When float buffers
are unavailable it warns and uses rgba8; formatFallback: false makes that an
UnsupportedError instead. fx.feedbackFormat reports what was allocated, and
fx.resetFeedback() clears the history.
Instance API
Section titled “Instance API”fx.ready;fx.status; // see "Status" belowfx.supported; // false once unsupported, or after destroy()fx.canvas; // HTMLCanvasElement | nullfx.gl; // WebGLRenderingContext | WebGL2RenderingContext | nullfx.clock; // "realtime" | "manual"fx.time; // the time of the last frame drawn, in secondsfx.start();fx.stop();fx.render();fx.resize();fx.destroy();fx.setTime(2.5);fx.setClock("manual");fx.setTimeSource(() => audio.currentTime); // or nullfx.setResolution([1920, 1080]);await fx.captureFrame({ format: "png" });fx.feedbackFormat;fx.resetFeedback();fx.setFragment(nextFragment); // ShaderCompileError | null; see "Hot reloading"fx.setUniform("progress", 0.5);fx.setUniforms({ progress: 0.5, color: [1, 0, 0] });await fx.setTexture("image", "/next-hero.webp");await fx.setTextures({ image: "/next-hero.webp" });const unbind = fx.bindUniforms(source);createShaderBackground() always returns an instance and never throws for the
environment, even on the server or without WebGL. It reports problems through
status, onError and ready. canvas and gl are null until
initialization creates them, and gl is null again while the context is lost
and after destroy(). While drawing, use state.gl from onBeforeRender /
onAfterRender, which is never null.
ready resolves once the first setup finishes. It rejects with DestroyedError
when destroy() runs first, with UnsupportedError when WebGL (or a feature
the instance needs) is unavailable, and with the setup error, such as a
ShaderCompileError, when initialization fails. Leaving ready unawaited after
destroy() or in an unsupported environment does not produce an unhandled
rejection, so a React StrictMode effect that destroys the instance in cleanup
stays quiet. Setup failures and a missing target still surface as unhandled
rejections when ready is left unhandled, even with onError.
onResize(state) runs whenever the render size actually changes (not on setup,
but also after setResolution()), for work that depends on the size, such as
picking a quality level.
onStatusChange(status) runs with the new value each time fx.status changes
(for example "paused" in a hidden tab, or "context-lost"), so a page can
show a fallback without polling. It is never called for the initial "idle",
nor before createShaderBackground() returns. Calling destroy() from it is
safe.
Status
Section titled “Status”| Status | Meaning |
|---|---|
"idle" |
Just created. Initialization starts on a microtask. |
"loading" |
The context exists; shaders, feedback and initial textures are being set up. |
"ready" |
Setup finished. Stays here only with autoStart: false until start(). |
"running" |
The render loop runs (in "demand" mode: it draws on changes). |
"paused" |
After stop(), under a manual clock, or while offscreen, hidden or under reduced motion (unless respectReducedMotion: false). |
"context-lost" |
The GPU dropped the context; it returns to running or paused when restored. |
"error" |
Setup failed, such as a first shader that did not compile. Final. |
"unsupported" |
No browser, no WebGL (or WebGL2 for GLSL ES 3.00), a missing target, or a size or format the GPU cannot provide. Final. |
"destroyed" |
destroy() was called. Final. |
fx.supported is false for "unsupported" and "destroyed" and true
otherwise, including before initialization finishes, so wait for ready or
watch onStatusChange to know whether the effect runs. From 1.0 on, these nine
values are fixed.
Errors
Section titled “Errors”Every error the library creates extends ShaderError: UnsupportedError,
TargetNotFoundError, ShaderCompileError (with stage and lines),
TextureLoadError and DestroyedError. They reach onError, and setup
failures also reject ready.
setFragment()never throws; it returns the compile error ornull.setTexture()/setTextures()reject and callonError.setTime()throwsRangeErrorfor a non-finite value;setResolution()throwsRangeError,UnsupportedError(above the GPU’s limits) orTypeError(nullwithout a target).- After
destroy()every method does nothing, exceptcaptureFrame(), which rejects withDestroyedError.
The package README lists when each error occurs.
Styling the canvas
Section titled “Styling the canvas”Every canvas the library manages carries data-frapx-canvas, a stable selector
for styling and tests that is part of the public API. canvasClass and
canvasStyle add a class and inline styles. layer: "background" (default)
puts a created canvas first in the target with z-index: 0; "overlay" puts
it last with z-index: 1.
.hero [data-frapx-canvas] { mix-blend-mode: screen;}Helpers
Section titled “Helpers”hexToRgb("#7dd3fc") and hexToRgba("#0f172acc") turn hex colors into
vec3 / vec4 uniform values. They are also exported alone from
@frapx/fx-canvas/color.
glslUtils (also from @frapx/fx-canvas/glsl) holds three snippets that work
in GLSL ES 1.00 and 3.00:
coverUv(uv, viewportSize, textureSize)fills the viewport with a texture, likebackground-size: cover.containUv(uv, viewportSize, textureSize)fits the whole texture, likecontain; mask the area outside 0–1.rotate2d(angle)returns amat2:p * rotate2d(a)turnspcounter-clockwise.
For the full option list and feedback details, see the package README and CHANGELOG on GitHub — or try it live on the Live Demo page.
Hot reloading
Section titled “Hot reloading”setFragment(source) swaps the shader without recreating the instance, so the
time, uniforms, textures and feedback history carry over. A compile error is
returned (and sent to onError) instead of thrown, with the located lines in
error.lines, and the previous shader keeps drawing.
@frapx/tool-dev shows those errors on the page
and has the Vite HMR wiring.
Timelines
Section titled “Timelines”fx.bindUniforms(source) drives uniforms from any { get(time) } source,
evaluated with the same time as u_time before every draw. keyframes() is a
dependency-free one:
import { keyframes } from "@frapx/fx-canvas";
fx.bindUniforms(keyframes({ camPos: [[0, [0, 1, 6]], [4, [3, 1.5, 2]]] }));Make get(time) a pure function of time: return the same values for the
same time and keep no state between calls. It can be called more than once per
frame, for times out of order (a seek, a capture, an accumulated sample in
@frapx/fx-passes), or not at all while paused. A pure source makes a manual
clock render the same frame for the same time, so an export matches the
preview. keyframes() is pure.
Importing the package on the server is safe. Calling createShaderBackground()
there still returns an instance: on the next microtask its status becomes
"unsupported", onError receives an UnsupportedError, and ready rejects
(an unawaited ready logs nothing). Create instances in client-only code.
Examples
Section titled “Examples”On GitHub under examples/:
vite-basic— minimal usage and integration checks.vite-feedback— previous-frame feedback.vite-capture— a 4K PNG sequence withclock: "manual"andcaptureFrame().vite-hot-reload—setFragment()on Vite HMR with the@frapx/tool-devoverlay.vite-flowmap,vite-sdf,vite-raymarch— with theglsl-*snippet packages.vite-react—@frapx/react-canvas.vite-passes,vite-timeline,vite-particles,vite-react-particles,vite-liminal-vein,vite-poolrooms— the other runtimes.
Depends on
Section titled “Depends on”@frapx/fx-canvas depends on @frapx/core, the
internal WebGL lifecycle engine. You never import @frapx/core yourself —
it’s pulled in automatically.
Pairs with
Section titled “Pairs with”@frapx/react-canvasif you’re in React.@frapx/glsl-noise/@frapx/glsl-flowmapfor ready-made GLSL snippets inside yourfragment.