Composing a pipeline
A chain is not a list of passes. Every node contributes GLSL to one of five stages, and the stages always run in the same order regardless of how you wrote them:
field → warp → cell → displace → color
So this:
[ { op: "duotone", colors: [...] }, { op: "offset", strength: 10 } ]
behaves the same as the reverse — offset is a warp and runs first either
way. Order matters within a stage, not across stages.
Because everything is emitted into a single shader, a chain costs one pass no matter how long it is.
Fields: mask and noise
mask and noise write a scalar field instead of drawing. Any later node
reads it with masked:
let raster = [
{ op: "mask", as: "centre", from: "radial", at: [0.5, 0.5], radius: 220 },
{ op: "halftone", size: 5, masked: "centre" }
];
Now the halftone only applies inside that circle. as names the field and
defaults to "mask", so masked: true reads the unnamed one:
[ { op: "mask", from: "vertical" },
{ op: "dither", levels: 4, masked: true } ]
A field producer on its own renders nothing — if you see no change, check that something downstream actually consumes it.
merge: two branches from one source
A chain feeds each node the previous node's output. A merge runs two
sub-chains from the same starting state and blends the results, so
neither branch sees the other:
{ op: "merge", mode: "screen", mix: 1.0,
a: [ { op: "halftone", size: 5 } ],
b: [ { op: "duotone", colors: ["#0b1b2b", "#e6e9ed"] } ] }
mix fades the whole merge back toward branch A — mix: 0 is A alone,
mix: 1 the full blend.
mode is one of:
over add screen multiply difference lighten darken
Blending applies to the colour stages only. Scalars and coordinates are interpolated plainly, because "screen" means nothing for a displacement.
The shader work is emitted inline, so a merge costs no extra pass, no extra framebuffer and no second capture of the DOM. For exactly that reason it cannot give the branches different source content — both start from the same pixels. If you need two different sources, you need two elements.
Use a chain when each step should build on the last. Use a merge when two treatments of the same content should be combined.
switch: pick a chain per device
switch is resolved before the shader is compiled, so the branch not taken
costs nothing at all:
{ op: "switch",
when: "coarse",
use: [],
else: [ { op: "stir", force: 0.6, by: "mouse" } ] }
That reads: on touch devices do nothing, everywhere else run the fluid. An empty branch is the idiomatic way to say "no effect here".
when accepts a function, a boolean, a raw media query, or one of these
aliases:
| alias | query |
|---|---|
coarse | (pointer: coarse) |
fine | (pointer: fine) |
hover | (hover: hover) |
no-hover | (hover: none) |
reduced-motion | (prefers-reduced-motion: reduce) |
dark | (prefers-color-scheme: dark) |
light | (prefers-color-scheme: light) |
A predicate that throws resolves to false. Switches nest, up to a depth of
8 — sub-chains are plain data and could be shared by reference, so the cap
is a cycle guard.
reduced-motion deserves a mention on its own: the expensive, moving ops
(stir, echo, anything driven by time) are exactly what that setting
exists to suppress.
You can check which branch a device takes without inspecting pixels:
import { resolveSwitches } from "nodality";
resolveSwitches(raster, 0); // the flat chain this device will actually run
Reference
Generated from the library source on each publish.
Blend modes for merge: over add screen multiply difference lighten darken
switch aliases:
| alias | media query |
|---|---|
coarse | (pointer: coarse) |
fine | (pointer: fine) |
hover | (hover: hover) |
no-hover | (hover: none) |
reduced-motion | (prefers-reduced-motion: reduce) |
dark | (prefers-color-scheme: dark) |
light | (prefers-color-scheme: light) |
Drivers
import { DRIVER_NAMES } from "nodality"; // static mouse hover scroll time
by chooses what moves the effect, and radius sets how far its influence
reaches. static applies the effect evenly and never animates — useful when
you want the look without the interaction, and the cheapest option since
nothing needs re-uploading per frame.