npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sfuma

v0.3.0

Published

Animated, grainy gradient canvases from a JSON document. One WebGL2 canvas, no dependencies.

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.

sfuma.dev · editor · examples

Install

npm install sfuma

ESM 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 context

render(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 errors

Errors 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.com

Keep 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