sfuma
v0.3.0
Published
Animated, grainy gradient canvases from a JSON document. One WebGL2 canvas, no dependencies.
Maintainers
Readme
sfuma
Animated, grainy gradient canvases from a JSON document. One WebGL2 canvas, no dependencies.
Sfuma renders the soft, dense, slowly-drifting colour fields people use behind hero sections — large blurred masses of colour under fine film grain — and it renders them from a plain JSON composition you can store, diff, generate and hand to an agent.
The complete reference is sfuma.dev/llms.txt — every layer, effect, parameter, range and default, generated from the renderer's own metadata. It is plain text and it is the right thing to give a coding agent. This README is the short version.
Install
npm install sfumaESM only, browser only, no runtime dependencies. Types are bundled.
The whole package is 134 kB raw, 34 kB gzipped. Importing only createSfuma and the validator pulls about 24 kB gzipped — the capability metadata and the agent guide are the rest, and both tree-shake away if you do not import them.
Use
import { createSfuma } from "sfuma";
const canvas = document.querySelector<HTMLCanvasElement>("#hero")!;
const sfuma = createSfuma({
canvas,
config: {
version: 1,
seed: 7,
canvas: { background: "#07080d" },
layers: [
{ id: "base", type: "solid", fill: "#07080d" },
{ id: "coral", type: "blob", x: 0.3, y: 0.4, size: 0.9, irregularity: 0.2,
complexity: 3, fill: "#ff5f6d", blend: "screen" },
{ id: "azure", type: "blob", x: 0.7, y: 0.6, size: 1.0, irregularity: 0.2,
complexity: 3, fill: "#2f6dff", blend: "screen" }
],
effects: [
{ id: "soften", type: "blur", radius: 0.2 },
{ id: "texture", type: "grain", kind: "rgb", amount: 0.14, size: 1, animated: true }
],
animations: [
{ target: "layers.coral.x", driver: "sine", from: 0.25, to: 0.45, duration: 11 },
{ target: "layers.azure.y", driver: "sine", from: 0.5, to: 0.7, duration: 13 }
]
}
});
// Sfuma reads the canvas box but does not watch it. Size the canvas with CSS and tell it
// when that box changes, or the composition stays at its first-frame resolution.
const observer = new ResizeObserver(() => sfuma.resize());
observer.observe(canvas);The instance starts playing on its own and pauses while the page is hidden. Give the canvas display: block; width: 100%; height: 100% inside a positioned container.
On teardown, observer.disconnect() and sfuma.destroy(). Destroying releases the WebGL context, so a canvas cannot be reused afterwards — mount a new <canvas> if you need another composition in its place.
Instance API
sfuma.play(); // resume; safe to call when already playing
sfuma.pause(); // stop the frame loop, keep the last frame on screen
sfuma.seek(4.5); // set composition time and draw that frame
sfuma.render(); // draw the current time; render(t) draws exactly t
sfuma.set("layers.coral.x", 0.6); // override one numeric property
sfuma.setConfig(nextConfig); // swap the whole composition
sfuma.resize(); // re-read the canvas box
sfuma.getState(); // { playing, time, duration, quality, width, height }
sfuma.destroy(); // release the contextrender(t) is deterministic: the same time always draws the same frame, without needing the frames before it. That is what makes stills, tests and video capture exact.
Reduced motion
The intended pattern — hold a representative frame rather than the first one, and resume if the preference changes mid-session:
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const applyMotionPreference = () => {
if (query.matches) {
sfuma.pause();
sfuma.seek(6); // any time you like the look of; it is deterministic
} else {
sfuma.play();
}
};
applyMotionPreference();
query.addEventListener("change", applyMotionPreference);React, and any RSC framework
Sfuma touches the DOM and WebGL, so it belongs behind "use client" and inside an effect:
"use client";
import { useEffect, useRef } from "react";
import { SfumaUnsupportedError, createSfuma, type SfumaConfig } from "sfuma";
export function SfumaBackground({ config }: { config: SfumaConfig }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
let sfuma;
try {
sfuma = createSfuma({ canvas, config });
} catch (error) {
if (error instanceof SfumaUnsupportedError) return; // leave the CSS fallback showing
throw error;
}
const observer = new ResizeObserver(() => sfuma.resize());
observer.observe(canvas);
return () => {
observer.disconnect();
sfuma.destroy();
};
}, [config]);
return <canvas aria-hidden="true" ref={ref} style={{ display: "block", width: "100%", height: "100%" }} />;
}Reacting to the page
A composition animates itself, and it can also answer what someone is doing.
A layer declares how it responds to the pointer, and Sfuma attaches the listeners to its own canvas — there is nothing to wire up:
{ "id": "coral", "type": "blob", "x": 0.3, "y": 0.4, "size": 0.9,
"irregularity": 0.2, "complexity": 3, "fill": "#ff5f6d", "blend": "screen",
"pointer": { "mode": "gather", "strength": 0.3, "radius": 0.5 } }Modes: follow tracks it, gather leans toward it, avoid moves away, implode collapses
and reforms, explode scatters into fragments and gathers back, bloom swells, react
brightens. All fall off with distance and rest exactly as authored when there is no pointer,
which is what a touch device sees. sfuma.trackPointer(false) hands the listeners back if you
would rather feed positions yourself through sfuma.pointer(x, y).
Anything else you already have can drive any numeric property:
// An impulse the composition absorbs and lets go of.
sfuma.pulse("layers.coral.size", 1.2, { duration: 0.3 });
// A move to a new resting state.
sfuma.to("effects.warp.amount", 0.2, { duration: 0.8, easing: "ease-out" });Both run even while the composition is paused. to also takes from, which is needed when the
property carries an animation — its current value is then whatever the driver reads at that
instant.
Images, and ASCII from an image
An image layer samples a picture into the composition, and every effect applies to it — which
is how a photograph becomes animated ASCII:
{ "id": "portrait", "type": "image", "src": "/portrait.jpg", "fit": "cover" }Images load with anonymous CORS, so the host must allow it; a data URI always works.
The ascii effect draws real typeset characters. Name a ramp — classic, shade, code,
binary, minimal, terminal — or pass your own set, sparsest first:
{ "id": "glyphs", "type": "ascii", "size": 12, "characters": " .:-=+*#%@" }Blend modes matter
The single thing worth knowing before you start: colour layers are stacked with a blend mode, and the mode has to match the ground.
- Dark background →
blend: "screen". Colours add up into light. - Light background →
blend: "multiply". Colours darken into it.
screen over white stays white, and multiply over black stays black — a canvas that renders as a flat rectangle is almost always this. Set blend explicitly on every colour layer rather than relying on the default.
What you can compose
Layers — solid, linear-gradient, radial-gradient, ellipse, blob, aurora.
A blob, for example, takes x, y, size, irregularity, complexity, fill, and optionally rotation, opacity, blend and its own effects. Rather than list every parameter of every type here, ask the package:
import { getSfumaCapabilities, createSfumaLayer, createSfumaEffect } from "sfuma";
const capabilities = getSfumaCapabilities();
// → layers, effects, blends, noiseTypes, drivers, fits, qualities
// each parameter: { key, label, kind, min, max, step, defaultValue, animatable }
const layer = createSfumaLayer("blob", "coral"); // (type, id) — the id is required
const effect = createSfumaEffect("grain", "texture");Both factories return a valid node with defaults filled in. The ids are yours to invent; they are how animations address things, so they need to be unique and stable.
Effects, per layer or over the whole composition — blur, displace, domain-warp, flow, swirl, ripple, pinch, kaleidoscope, moire, halftone, pixelate, ascii, chromatic-aberration, vignette, grain, hue, saturation, contrast, brightness.
grain takes kind: "mono" | "rgb" (default "rgb"), amount, size (default 1) and animated. mono shifts all channels together; rgb samples each channel independently, which is what reads as film.
Animations address any numeric property by stable ID — layers.coral.x, effects.warp.amount, layers.coral.effects.soft.radius — with sine, ping-pong, noise and keyframes drivers.
Everything procedural derives from seed plus each element's own ID, so the same document always renders the same composition, and inserting an unrelated layer does not change how the others look.
Ranges are guidance, not limits
min and max in the capability metadata are authoring ranges — sensible bounds for a slider, not validation limits. A position outside the unit square is legitimate (a blob half off-canvas is a common effect), so the validator accepts it. Where a value is genuinely constrained the validator does enforce it: opacity must be within 0–1, kaleidoscope.segments at least 2, colours must parse.
Making it cheaper
renderer hints change cost only — the composition renders identically. For a background
behind text, three of them matter:
renderer: { maxPixelRatio: 1.25, resolutionScale: 0.7, fps: 30 }A deliberately blurred gradient has nothing to gain from a 2x drawing buffer or a 120Hz refresh. On this site those three took the homepage from 5.0M buffer pixels at the display refresh to 2.6M at 30fps — about a quarter of the shading work — with no visible difference.
fps caps rendered frames without touching the clock: composition time still advances in real
seconds, so a capped canvas keeps pace with an uncapped one.
Validate before you render
import { parseSfumaConfig, validateSfumaConfig } from "sfuma";
const result = validateSfumaConfig(input);
// → { valid: boolean, errors: [{ path, message }] }
const config = parseSfumaConfig(input);
// → throws SfumaConfigError carrying the same errorsErrors are path-aware: layers[0].fill — must be a #RGB, #RRGGBB, or #RRGGBBAA colour.
Capturing a still or a video
render(t) is deterministic, so a poster frame is exact — seek and read the canvas:
sfuma.pause();
sfuma.seek(6);
sfuma.render();
const poster = canvas.toDataURL("image/png"); // must be the same task as render()Read the pixels in the same task as the render. The context is created without preserveDrawingBuffer, so the drawing buffer is cleared before the next paint and a later read comes back empty.
Headless Chrome has no WebGL2 by default, so screenshot pipelines, OG-image generators and visual-regression suites see nothing at all. Give it a software rasteriser:
chrome --headless --enable-unsafe-swiftshader --screenshot=out.png https://example.comKeep a CSS gradient underneath the canvas as the no-WebGL2 ground, so anything that cannot run WebGL2 — old browsers, headless captures — still shows something reasonable.
Compositions can be captured to a seamless video loop in the editor. Set the composition-level loop field when you capture: animated grain is re-rolled from the time step and has no period of its own, so loop tells it where to wrap. Leave it out for a canvas on a page — that runs indefinitely and has no loop point to hide.
For agents and generators
getSfumaCapabilities() is the machine-readable surface: every layer, effect and parameter with kinds, ranges, defaults and animatability. buildSfumaAgentGuide() emits the full written reference as Markdown — it is what sfuma.dev/llms.txt serves.
Requirements
WebGL2. createSfuma throws SfumaUnsupportedError when the context cannot be created, so the fallback is an instanceof check rather than string matching.
Licence
MIT
