Skip to content

@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.

Terminal window
pnpm add @frapx/fx-canvas
import { 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);
}
`
});
  • Canvas setup & resize — inserts and sizes a canvas over target, tracks ResizeObserver.
  • Lifecycle & a11y — pauses the render loop offscreen, on hidden tabs, and under prefers-reduced-motion (all on by default). Always supplies u_reducedMotion so 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 es to opt into GLSL ES 3.00; everything else stays WebGL1 by default.
  • Textures — image URL / HTMLImageElement / HTMLCanvasElement sources, with runtime updates via setTexture() / setTextures(). On WebGL2, a texture can also be a 3D volume from raw data — { data, size: [nx, ny, nz], format: "r8" | "r16f" | "r32f" } — sampled as sampler3D with a vec3 u_<name>Size.
  • Feedback — feedback: true gives you a managed ping-pong framebuffer exposed as u_previousFrame for 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, and resolution fixes the render size, for frame-accurate output such as video frames.
  • External time — timeSource makes the realtime loop follow an audio transport or any other clock, keeping the lifecycle gates.

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-hidden on the canvas — a decorative background carries no content, so hide it from assistive technology. Put it on the canvas, not on target, 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 handles u_reducedMotion itself.
const fx = createShaderBackground({
target: ".stage",
fragment,
clock: "manual",
resolution: [3840, 2160]
});
await fx.ready;
fx.setTime(1.5); // sets u_time only
const png = await fx.captureFrame(); // draws now and returns a PNG Blob

Under 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).

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.

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.

fx.ready;
fx.status; // see "Status" below
fx.supported; // false once unsupported, or after destroy()
fx.canvas; // HTMLCanvasElement | null
fx.gl; // WebGLRenderingContext | WebGL2RenderingContext | null
fx.clock; // "realtime" | "manual"
fx.time; // the time of the last frame drawn, in seconds
fx.start();
fx.stop();
fx.render();
fx.resize();
fx.destroy();
fx.setTime(2.5);
fx.setClock("manual");
fx.setTimeSource(() => audio.currentTime); // or null
fx.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 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.

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 or null.
  • setTexture() / setTextures() reject and call onError.
  • setTime() throws RangeError for a non-finite value; setResolution() throws RangeError, UnsupportedError (above the GPU’s limits) or TypeError (null without a target).
  • After destroy() every method does nothing, except captureFrame(), which rejects with DestroyedError.

The package README lists when each error occurs.

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;
}

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, like background-size: cover.
  • containUv(uv, viewportSize, textureSize) fits the whole texture, like contain; mask the area outside 0–1.
  • rotate2d(angle) returns a mat2: p * rotate2d(a) turns p counter-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.

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.

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.

On GitHub under examples/:

  • vite-basic — minimal usage and integration checks.
  • vite-feedback — previous-frame feedback.
  • vite-capture — a 4K PNG sequence with clock: "manual" and captureFrame().
  • vite-hot-reload — setFragment() on Vite HMR with the @frapx/tool-dev overlay.
  • vite-flowmap, vite-sdf, vite-raymarch — with the glsl-* snippet packages.
  • vite-react — @frapx/react-canvas.
  • vite-passes, vite-timeline, vite-particles, vite-react-particles, vite-liminal-vein, vite-poolrooms — the other runtimes.

@frapx/fx-canvas depends on @frapx/core, the internal WebGL lifecycle engine. You never import @frapx/core yourself — it’s pulled in automatically.