Custom Filters in Pathogen: First-Class Visual Effects

Part 1 of 2 in our series on Pathogen's custom filter pipeline. In this post we cover the architecture and ergonomics, anchored by NoiseFilter. In Part 2 we walk through the full family — GlowFilter, EmbossFilter, ElevationShadowFilter, InnerShadowFilter, and PixelateFilter — with side-by-side parameter sweeps.

Series:

  1. Custom Filters in Pathogen: First-Class Visual Effects ← you are here
  2. The Full Filter Family: Glow, Emboss, Shadows, Pixelate

Five primitives, one named value

define ViewBox(0, 0, 400, 200); // Hero: one line of Pathogen — five SVG filter primitives in the output. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 400, 200); } // The one-liner: define a filter, name it, use it. let grain = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; }; let disc = PathLayer('disc') ${ fill: oklch(70% 0.20 30); stroke: none; filter: grain; }; disc.apply { circle(200, 100, 70); } Five SVG filter primitives behind a single named value. A NoiseFilter you can reuse across layers, log, override by name, and pass to any layer's filter: style property.

That snippet does three things. It defines a custom filter — NoiseFilter() with the Grain preset — assigns it to a variable, and applies it to a layer via a style block. The output SVG contains a single <filter> element wrapping feTurbulencefeCompositefeColorMatrixfeComponentTransferfeBlend, plus a filter="url(#pathogen-noise-1)" attribute on the painted <path>.

Doing this in vanilla SVG means hand-authoring the five-primitive chain along with its filter region, primitive subregions, and blend mode — and re-declaring the whole thing inline every time you want to reuse it. In vanilla CSS the option doesn't exist at all: filter: blur(...) brightness(...) covers a fixed set of effects and filter: drop-shadow(...) covers exactly one shadow style. Anything richer demands raw <filter> markup.

Pathogen's custom filters close that gap by making filters first-class language values. Define one, name it, use it like any other variable — the shape of your program stays small, regardless of how rich the effect underneath gets.

What native CSS leaves on the table

The argument for custom filters isn't that CSS filter functions are bad — filter: blur(2px) brightness(1.2); is fine for what it covers — it's that the ergonomic ceiling is low. Specifically:

  • No reuse. A CSS filter chain is inline text. To apply the same look across three layers, you copy the string three times. Change your mind, change three places.
  • No introspection. drop-shadow(2px 4px 6px black) is an opaque token. There's no way to read back the offset, blur, or color from elsewhere in your program.
  • No presets. The blur radius is a number; the color is a color. There's no BlurStyle.Soft you can swap in.
  • No composition with custom recipes. If you want noise blended with shadow blended with grain, you're writing a custom <filter> def either way.
  • No inset shadows. drop-shadow() is outer-only. Pressed buttons, recessed wells, and engraved-text effects need a different path entirely.
  • No layered depth. Material Design's depth shadows are three carefully tuned shadow layers stacked under a single elevation knob. drop-shadow() gives you one.

Raw SVG <filter> solves all of these, but at a steep verbosity cost — you're hand-writing primitive chains, naming intermediate results, and matching in=/in2= pipes. The expressive ceiling is high; the ergonomic floor is the ground.

Pathogen filters: define once, reuse, override, introspect

Here's a NoiseFilter configured by name, applied to a layer, and asked for its id from elsewhere in the program:

let grain = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Speckle;
  f.amount = 0.6;
  f.blend = BlendMode.SoftLight;
};

log("filter id:", grain.id);     // pathogen-noise-1
log("amount:",    grain.amount); // 0.6

define PathLayer('disc') ${ fill: oklch(70% 0.20 30); filter: grain; }

That snippet exercises four ergonomic wins at once:

  1. Trailing-block named overrides. Inside {|f| ... } you assign properties by name. Order doesn't matter, mismatches throw with a clear message, and the IDE knows the property set.
  2. Preset enums. NoiseFilterStyle.Speckle is one of five presets — Grain, Paper, Speckle, Static, Gradient — each tuned to a specific look. The compiler validates the choice; the IDE autocompletes it.
  3. Auto-wrapping filter: style property. Assigning a FilterValue to filter: inside a style block resolves to filter="url(#pathogen-noise-1)" in the output SVG. No string interpolation, no manual url(#...) plumbing.
  4. Read-side property access. grain.id, grain.amount, grain.blend — every configurable property is also readable. Useful for debug, conditional logic, and downstream computation.

The runtime cost is the same as raw SVG: one <filter> def in <defs>, one url(#id) reference per layer. The difference is everything around the runtime cost — declarable, nameable, reusable, introspectable.

NoiseFilter — five presets, one shape

NoiseFilter ships with five style presets. The primitive chain is the same across all of them; what changes is which feTurbulence type runs, which blend mode mixes the result back into the source, and where the defaults for scale, octaves, amount, monochrome, contrast, and stitch land.

define ViewBox(0, 0, 720, 240); // All five NoiseFilterStyle presets applied to the same disc, side by side. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 720, 240); } let f_grain = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; }; let f_paper = NoiseFilter() {|f| f.style = NoiseFilterStyle.Paper; }; let f_speckle = NoiseFilter() {|f| f.style = NoiseFilterStyle.Speckle; }; let f_static = NoiseFilter() {|f| f.style = NoiseFilterStyle.Static; }; let f_gradient = NoiseFilter() {|f| f.style = NoiseFilterStyle.Gradient; }; let d1 = PathLayer('grain') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_grain; }; let d2 = PathLayer('paper') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_paper; }; let d3 = PathLayer('speckle') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_speckle; }; let d4 = PathLayer('static') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_static; }; let d5 = PathLayer('gradient') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_gradient; }; d1.apply { circle(80, 100, 45); } d2.apply { circle(220, 100, 45); } d3.apply { circle(360, 100, 45); } d4.apply { circle(500, 100, 45); } d5.apply { circle(640, 100, 45); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l1.apply { text(80, 200)`Grain`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l2.apply { text(220, 200)`Paper`; } let l3 = TextLayer('l3') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l3.apply { text(360, 200)`Speckle`; } let l4 = TextLayer('l4') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l4.apply { text(500, 200)`Static`; } let l5 = TextLayer('l5') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l5.apply { text(640, 200)`Gradient`; } The five NoiseFilterStyle presets applied to identical discs. Same shape, same fill. Different chain defaults.

Each preset is a starting point you tune. style = NoiseFilterStyle.Grain gives you filmic grain by default; you can then bump amount to make it heavier, set monochrome = false to keep color variance in the noise, or override blend to switch from color-burn to multiply. Read the filters reference for the full property list.

scale — the noise frequency knob

scale maps directly to SVG's baseFrequency. Higher number means a finer, denser pattern; lower number means larger, coarser features. The NoiseFilterScale enum packages the three common values (Coarse, Medium, Fine → 0.3, 1.0, 5.0) so they're IDE-autocompleted; for anything in between, assign a finite positive number directly.

define ViewBox(0, 0, 640, 240); // NoiseFilter Grain — scale sweep from coarse to fine. // Each disc uses the same preset, only `scale` changes. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 640, 240); } let f1 = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.scale = 0.3; }; let f2 = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.scale = 1; }; let f3 = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.scale = NoiseFilterScale.Medium; }; let f4 = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.scale = NoiseFilterScale.Fine; }; let d1 = PathLayer('d1') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f1; }; let d2 = PathLayer('d2') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f2; }; let d3 = PathLayer('d3') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f3; }; let d4 = PathLayer('d4') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f4; }; d1.apply { circle(80, 100, 55); } d2.apply { circle(240, 100, 55); } d3.apply { circle(400, 100, 55); } d4.apply { circle(560, 100, 55); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l1.apply { text(80, 200)`scale = 0.3 (Coarse)`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l2.apply { text(240, 200)`scale = 1.0`; } let l3 = TextLayer('l3') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l3.apply { text(400, 200)`scale = NoiseFilterScale.Medium`; } let l4 = TextLayer('l4') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l4.apply { text(560, 200)`scale = NoiseFilterScale.Fine`; } let lh = TextLayer('lh') ${ font-family: sans-serif; font-size: 11; font-weight: 400; fill: oklch(45% 0.02 270); text-anchor: middle; }; lh.apply { text(320, 222)`smaller scale → larger noise features larger scale → finer, denser grain`; } Same disc, same Grain preset. Only scale changes — from a coarse 0.3, through Medium (1.0), to Fine (5.0).

amount — visible intensity

amount is a 0–1 multiplier on the alpha of the noise pass before it blends with the source. 0 disables the effect entirely; 1 hits full strength. It interacts predictably with blend: at amount = 0.5 you see roughly half the contribution of the chosen blend mode.

define ViewBox(0, 0, 640, 240); // NoiseFilter Grain — amount sweep. amount = 0 is no effect; 1 is full strength. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 640, 240); } let f1 = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.amount = 0.2; }; let f2 = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.amount = 0.4; }; let f3 = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.amount = 0.6; }; let f4 = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.amount = 0.8; }; let d1 = PathLayer('d1') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f1; }; let d2 = PathLayer('d2') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f2; }; let d3 = PathLayer('d3') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f3; }; let d4 = PathLayer('d4') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f4; }; d1.apply { circle(80, 100, 55); } d2.apply { circle(240, 100, 55); } d3.apply { circle(400, 100, 55); } d4.apply { circle(560, 100, 55); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l1.apply { text(80, 200)`amount = 0.2`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l2.apply { text(240, 200)`amount = 0.4`; } let l3 = TextLayer('l3') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l3.apply { text(400, 200)`amount = 0.6`; } let l4 = TextLayer('l4') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l4.apply { text(560, 200)`amount = 0.8`; } Grain at four amounts. The chain stays the same; the alpha ramp does the heavy lifting.

A note on cost: octaves is the number of layered noise frequencies. The presets stay between 2 and 8; values above 8 compound feTurbulence's render cost noticeably on lower-end devices, so reach for that ceiling deliberately.

monochrome — strip color variance

The feTurbulence primitive produces RGB-valued noise by default — different intensities per channel, which reads as faintly colored static. Setting monochrome = true inserts an feColorMatrix step that maps the noise to a single luminance channel, so the grain reads as pure light-and-dark texture independent of the source fill.

define ViewBox(0, 0, 400, 220); // NoiseFilter Speckle — monochrome on vs off, same seed, same shape. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 400, 220); } let f_mono = NoiseFilter() {|f| f.style = NoiseFilterStyle.Speckle; f.amount = 0.8; f.monochrome = true; f.seed = 17; }; let f_color = NoiseFilter() {|f| f.style = NoiseFilterStyle.Speckle; f.amount = 0.8; f.monochrome = false; f.seed = 17; }; let d_mono = PathLayer('mono') ${ fill: oklch(60% 0.20 30); stroke: none; filter: f_mono; }; let d_color = PathLayer('color') ${ fill: oklch(60% 0.20 30); stroke: none; filter: f_color; }; d_mono.apply { circle(110, 90, 55); } d_color.apply { circle(290, 90, 55); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l1.apply { text(110, 180)`monochrome = true`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l2.apply { text(290, 180)`monochrome = false`; } Same Speckle preset, same seed. monochrome = true (left) reads as flat texture; false (right) preserves the per-channel color variance.

Pairing filters with gradients

Filters apply on top of whatever the layer's fill resolved to. That includes Pathogen's gradient values, which means a noisy gradient is just a layer with a gradient fill and a NoiseFilter filter — no separate plumbing.

define ViewBox(0, 0, 400, 240); // LinearGradient fill + Grain filter on the same layer. // One declarative pairing — the grain rides the gradient. let sky = LinearGradient('sky', 0, 0, 1, 1) {|g| g.stop(0 , oklch(78% 0.20 70)); g.stop(0.55, oklch(58% 0.22 30)); g.stop(1 , oklch(30% 0.18 280)); }; let grainy = NoiseFilter() {|f| f.style = NoiseFilterStyle.Gradient; f.amount = 0.65; }; let panel = PathLayer('panel') ${ fill: sky; stroke: none; filter: grainy; }; panel.apply { rect(0, 0, 400, 240); } LinearGradient fill plus Grain filter. The grain rides the gradient — and the Gradient preset is tuned for exactly this case, pumping contrast on the noise so it reads through saturated stops.

The Gradient preset pumps contrast on the noise before the final blend so the grain reads cleanly against saturated gradient stops — without that, the noise would muddy out against the brightest mid-tones. You can apply the same pattern with Linear, Radial, Conic, Mesh, or Freeform gradients; see the gradients reference for the full set.

One filter, many layers — one <filter> def

Because a NoiseFilter is a value, you can assign it to a let once and reference it from as many layers as you want. The output SVG contains exactly one <filter> element regardless of reference count — every layer points at the same url(#pathogen-noise-1).

define ViewBox(0, 0, 600, 200); // One filter, six shapes — one <filter> def in the output SVG. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 600, 200); } // Define the filter once. The output SVG will contain exactly one <filter> // element regardless of how many layers reference it below. let grain = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.amount = 0.5; }; let c1 = PathLayer('c1') ${ fill: oklch(70% 0.20 30); stroke: none; filter: grain; }; let c2 = PathLayer('c2') ${ fill: oklch(70% 0.20 80); stroke: none; filter: grain; }; let c3 = PathLayer('c3') ${ fill: oklch(70% 0.20 150); stroke: none; filter: grain; }; let c4 = PathLayer('c4') ${ fill: oklch(70% 0.20 220); stroke: none; filter: grain; }; let c5 = PathLayer('c5') ${ fill: oklch(70% 0.20 290); stroke: none; filter: grain; }; let c6 = PathLayer('c6') ${ fill: oklch(70% 0.20 340); stroke: none; filter: grain; }; c1.apply { circle(60, 100, 38); } c2.apply { circle(150, 100, 38); } c3.apply { circle(240, 100, 38); } c4.apply { circle(330, 100, 38); } c5.apply { circle(420, 100, 38); } c6.apply { circle(510, 100, 38); } SVG preview

This is the kind of thing that's tedious to do by hand in raw SVG — you'd be copy-pasting <filter> markup, or hand-managing ids, or referencing the wrong one and getting confused output. Pathogen's auto-id machinery generates pathogen-noise-N for each call site, and the style-block evaluator wraps the value to url(#pathogen-noise-N) at the point of use.

Introspection: filter values you can log and pass around

Read-side property access is a small thing on the surface and a big thing in practice. You can log() a filter's id and seed for debugging, branch on its style in downstream logic, or compose a filter value into another expression.

define ViewBox(0, 0, 400, 200); // Filter values support read-side property access — useful for debug, // computation, and conditional logic. Open the console to see the logs. let grain = NoiseFilter() {|f| f.style = NoiseFilterStyle.Speckle; f.amount = 0.6; }; log('id: ', grain.id); log('style: ', grain.style); log('amount:', grain.amount); log('blend: ', grain.blend); log('seed: ', grain.seed); let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 400, 200); } let disc = PathLayer('disc') ${ fill: oklch(70% 0.20 30); stroke: none; filter: grain; }; disc.apply { circle(200, 100, 70); } Open the console panel: every configurable property is also readable. The seed is auto-derived from the filter's id; set it explicitly to lock the noise pattern across edits.

The auto-derived seed is one place where introspection pays off immediately. NoiseFilter hashes the filter's id (pathogen-noise-1, etc.) into a deterministic seed so the same source program produces the same noise across compiles. Reordering filter declarations shifts the auto-ids — which shifts the seeds — which visibly changes the grain pattern. If you want a specific filter's noise locked down across edits, log the id, then set the seed explicitly:

let signature = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Grain;
  f.seed = 42;   // stable across edits regardless of declaration order
};

What's in Part 2

NoiseFilter is the flagship demonstration of the pipeline, but it's one of six custom filters that ship. The other five close specific gaps native CSS can't:

  • GlowFilter — outer halo or inner edge light, picked by a single mode property.
  • EmbossFilterfeSpecularLighting wrapped into named parameters you can sweep.
  • ElevationShadowFilter — Material-style depth as a single elevation knob, layering three shadows for you.
  • InnerShadowFilter — inset shadow. The capability CSS drop-shadow() cannot express.
  • PixelateFilter(w, h, r) — mosaic / pixelation; positional or block-style configuration.

Part 2 walks through each one with side-by-side parameter sweeps. The ergonomic story stays the same — first-class values, named overrides, presets, introspection, reuse — applied to five more visual languages.

For the full reference, see docs/filters. To experiment in your browser, open the playground.