The Full Filter Family: Glow, Emboss, Shadows, Pixelate

Part 2 of 2 in our series on Pathogen's custom filter pipeline. Part 1 covered the architecture and ergonomics anchored by NoiseFilter. This post walks through the rest of the family with side-by-side parameter sweeps.

Series:

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

The family at a glance

define ViewBox(0, 0, 600, 400); // Six filters, one shape per cell — the family portrait. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 600, 400); } let f_grain = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; f.amount = 0.45; }; let f_glow = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.20 60); f.radius = 8; f.opacity = 0.85; }; let f_emboss = EmbossFilter() {|f| f.angle = 135deg; f.depth = 3; f.strength = 1; }; let f_elevation = ElevationShadowFilter() {|f| f.elevation = 6; f.color = oklch(20% 0.04 280); }; let f_inner = InnerShadowFilter() {|f| f.offsetY = 3; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.5; }; let f_pixelate = PixelateFilter(12, 12, 6); let d1 = PathLayer('d1') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_grain; }; let d2 = PathLayer('d2') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_glow; }; let d3 = PathLayer('d3') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_emboss; }; let d4 = PathLayer('d4') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_elevation; }; let d5 = PathLayer('d5') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_inner; }; let d6 = PathLayer('d6') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_pixelate; }; d1.apply { circle(100, 100, 50); } d2.apply { circle(300, 100, 50); } d3.apply { circle(500, 100, 50); } d4.apply { circle(100, 270, 50); } d5.apply { circle(300, 270, 50); } d6.apply { circle(500, 270, 50); } 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(100, 175)`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(300, 175)`Glow`; } 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(500, 175)`Emboss`; } 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(100, 360)`Elevation`; } 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(300, 360)`Inner Shadow`; } let l6 = TextLayer('l6') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l6.apply { text(500, 360)`Pixelate`; } Six filters, one shape per cell. Each is a single language-level value: NoiseFilter, GlowFilter, EmbossFilter, ElevationShadowFilter, InnerShadowFilter, PixelateFilter.

The same ergonomic story from Part 1 applies to every filter on this page: each is a first-class value, configured by name in a trailing block, accepts the same auto-wrapping filter: assignment in a style block, exposes read-side property access on every configurable knob, and composes with other filters via GroupLayer stacking.

What changes filter-to-filter is the specific capability gap each one closes against native CSS and the specific knobs each one exposes. Let's walk through them.

GlowFilter — two glow modes in one value

CSS drop-shadow() gives you one kind of glow: a colored outer halo. Pathogen's GlowFilter gives you two — GlowMode.Outer and GlowMode.Inner — packed into one filter value that you can flip with a single property write.

define ViewBox(0, 0, 400, 220); // GlowMode.Outer vs GlowMode.Inner on the same disc. let bg = PathLayer('bg') ${ fill: oklch(25% 0.04 270); stroke: none; }; bg.apply { rect(0, 0, 400, 220); } let f_outer = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 10; f.opacity = 0.85; }; let f_inner = GlowFilter() {|f| f.mode = GlowMode.Inner; f.color = Color('white'); f.radius = 5; f.spread = 1; f.opacity = 0.85; }; let d_outer = PathLayer('outer') ${ fill: oklch(60% 0.20 240); stroke: none; filter: f_outer; }; let d_inner = PathLayer('inner') ${ fill: oklch(60% 0.20 240); stroke: none; filter: f_inner; }; d_outer.apply { circle(110, 90, 50); } d_inner.apply { circle(290, 90, 50); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l1.apply { text(110, 180)`GlowMode.Outer`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l2.apply { text(290, 180)`GlowMode.Inner`; } Outer halo and inner edge light. Same constructor, same color, same radius — only the mode property differs.

The two modes share most of their plumbing — both use feGaussianBlur against SourceAlpha, both apply feFlood for the color, both composite into a feMerge at the end. The difference is one composite operator: outer mode merges the blurred silhouette underneath the source; inner mode inverts the blur against the source alpha so the glow rides the inside edge instead.

A spread parameter is also available in both modes — it dilates the silhouette in Outer mode (fattening the halo before the blur) and erodes it in Inner mode (pushing the inner light further inset). The two knobs that do the most visible work in either mode are radius and opacity.

define ViewBox(0, 0, 640, 240); // GlowFilter outer — radius sweep. Larger radius = wider, softer halo. let bg = PathLayer('bg') ${ fill: oklch(20% 0.04 270); stroke: none; }; bg.apply { rect(0, 0, 640, 240); } let f1 = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 2; f.opacity = 0.9; }; let f2 = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 6; f.opacity = 0.9; }; let f3 = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 12; f.opacity = 0.9; }; let f4 = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 20; f.opacity = 0.9; }; 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, 40); } d2.apply { circle(240, 100, 40); } d3.apply { circle(400, 100, 40); } d4.apply { circle(560, 100, 40); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l1.apply { text(80, 200)`radius = 2`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l2.apply { text(240, 200)`radius = 6`; } let l3 = TextLayer('l3') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l3.apply { text(400, 200)`radius = 12`; } let l4 = TextLayer('l4') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l4.apply { text(560, 200)`radius = 20`; } Radius controls the blur stdDeviation — wider radius produces a softer, broader halo.

define ViewBox(0, 0, 640, 240); // GlowFilter outer — opacity sweep at constant radius. let bg = PathLayer('bg') ${ fill: oklch(20% 0.04 270); stroke: none; }; bg.apply { rect(0, 0, 640, 240); } let f1 = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 10; f.opacity = 0.3; }; let f2 = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 10; f.opacity = 0.5; }; let f3 = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 10; f.opacity = 0.7; }; let f4 = GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.22 60); f.radius = 10; f.opacity = 0.9; }; 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, 40); } d2.apply { circle(240, 100, 40); } d3.apply { circle(400, 100, 40); } d4.apply { circle(560, 100, 40); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l1.apply { text(80, 200)`opacity = 0.3`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l2.apply { text(240, 200)`opacity = 0.5`; } let l3 = TextLayer('l3') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l3.apply { text(400, 200)`opacity = 0.7`; } let l4 = TextLayer('l4') ${ font-family: sans-serif; font-size: 13; font-weight: 600; fill: oklch(90% 0.02 80); text-anchor: middle; }; l4.apply { text(560, 200)`opacity = 0.9`; } Opacity scales the flood-opacity on the glow color. Subtle ambient lighting at 0.3; full-strength halo at 0.9.

For inner glow, an additional spread parameter erodes the silhouette before blurring so the glow has more inset distance — useful for pressed-glass and bezeled-button effects. See docs/filters § GlowFilter for the full property reference.

EmbossFilter — feSpecularLighting wrapped in named parameters

The SVG primitive for embossed surfaces is feSpecularLighting + feDistantLight. It's powerful and unergonomic: you write XML, you choose which child light type to nest, you spec surfaceScale and specularConstant and specularExponent and lighting-color, you composite the highlight pass back against SourceAlpha, and then you blend it onto SourceGraphic. Five primitives, half a dozen attributes, no semantic shortcuts.

EmbossFilter wraps that whole chain into seven named parameters you can sweep without leaving Pathogen.

let emboss = EmbossFilter() {|f|
  f.angle = 135deg;     // light azimuth — top-left
  f.elevation = 45deg;  // light elevation — overhead-ish
  f.depth = 3;          // surface scale — bevel depth
  f.strength = 1.0;     // specular constant — highlight brightness
  f.shininess = 20;     // specular exponent — highlight tightness
  f.lightColor = Color('white');
  f.smooth = 1;         // pre-blur for softer bevel edges
};

angle — the light direction

The most visible knob. Sweeping angle rotates the simulated light around the surface; the highlight follows.

define ViewBox(0, 0, 720, 240); // EmbossFilter — light azimuth sweep. The highlight tracks the light source. let bg = PathLayer('bg') ${ fill: oklch(90% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 720, 240); } let f1 = EmbossFilter() {|f| f.angle = 45deg; f.depth = 3; f.strength = 1; }; let f2 = EmbossFilter() {|f| f.angle = 90deg; f.depth = 3; f.strength = 1; }; let f3 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 3; f.strength = 1; }; let f4 = EmbossFilter() {|f| f.angle = 180deg; f.depth = 3; f.strength = 1; }; let f5 = EmbossFilter() {|f| f.angle = 225deg; f.depth = 3; f.strength = 1; }; let f6 = EmbossFilter() {|f| f.angle = 270deg; f.depth = 3; f.strength = 1; }; let d1 = PathLayer('d1') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f1; }; let d2 = PathLayer('d2') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f2; }; let d3 = PathLayer('d3') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f3; }; let d4 = PathLayer('d4') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f4; }; let d5 = PathLayer('d5') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f5; }; let d6 = PathLayer('d6') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f6; }; d1.apply { circle(60, 100, 38); } d2.apply { circle(180, 100, 38); } d3.apply { circle(300, 100, 38); } d4.apply { circle(420, 100, 38); } d5.apply { circle(540, 100, 38); } d6.apply { circle(660, 100, 38); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l1.apply { text(60, 195)`45°`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l2.apply { text(180, 195)`90°`; } let l3 = TextLayer('l3') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l3.apply { text(300, 195)`135°`; } let l4 = TextLayer('l4') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l4.apply { text(420, 195)`180°`; } let l5 = TextLayer('l5') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l5.apply { text(540, 195)`225°`; } let l6 = TextLayer('l6') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l6.apply { text(660, 195)`270°`; } Six light azimuths around the clock. The highlight tracks the light source.

depth — the bevel surface scale

depth maps to surfaceScale on feSpecularLighting — the perceived height of the embossed surface. Higher values produce a more pronounced bevel.

define ViewBox(0, 0, 640, 240); // EmbossFilter — depth sweep at constant angle. Higher depth = more pronounced bevel. let bg = PathLayer('bg') ${ fill: oklch(90% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 640, 240); } let f1 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 1; f.strength = 1; }; let f2 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 2; f.strength = 1; }; let f3 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 4; f.strength = 1; }; let f4 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 8; f.strength = 1; }; let d1 = PathLayer('d1') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f1; }; let d2 = PathLayer('d2') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f2; }; let d3 = PathLayer('d3') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f3; }; let d4 = PathLayer('d4') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f4; }; d1.apply { circle(80, 100, 50); } d2.apply { circle(240, 100, 50); } d3.apply { circle(400, 100, 50); } d4.apply { circle(560, 100, 50); } 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)`depth = 1`; } 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)`depth = 2`; } 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)`depth = 4`; } 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)`depth = 8`; } depth = 1 reads as a flat panel with a hint of light. depth = 8 reads as a dramatically raised tile.

strength — the highlight brightness

strength maps to specularConstant. It controls how much of the simulated light reaches the surface — higher values brighten the highlight, lower values dial it down toward a flat appearance.

define ViewBox(0, 0, 640, 240); // EmbossFilter — strength sweep. Brighter highlights at higher specularConstant. let bg = PathLayer('bg') ${ fill: oklch(90% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 640, 240); } let f1 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 3; f.strength = 0.3; }; let f2 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 3; f.strength = 0.6; }; let f3 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 3; f.strength = 1; }; let f4 = EmbossFilter() {|f| f.angle = 135deg; f.depth = 3; f.strength = 1.5; }; let d1 = PathLayer('d1') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f1; }; let d2 = PathLayer('d2') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f2; }; let d3 = PathLayer('d3') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f3; }; let d4 = PathLayer('d4') ${ fill: oklch(70% 0.18 60); stroke: none; filter: f4; }; d1.apply { circle(80, 100, 50); } d2.apply { circle(240, 100, 50); } d3.apply { circle(400, 100, 50); } d4.apply { circle(560, 100, 50); } 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)`strength = 0.3`; } 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)`strength = 0.6`; } 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)`strength = 1.0`; } 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)`strength = 1.5`; } Same angle and depth. strength = 0.3 is a barely-lit surface; 1.5 is dramatic studio lighting.

The ergonomic win here isn't just that you don't write the XML — it's that you can sweep any parameter live and watch the result, because every knob is a property write you can prototype directly. The raw-SVG equivalent is editing <feSpecularLighting> attribute values by hand and reloading.

ElevationShadowFilter — Material depth in one knob

Material Design's depth shadows aren't single drop-shadows. They're three coordinated drop-shadows stacked under a single elevation concept — a tight shadow for the close-in contact zone, a mid shadow for the bulk of the cast, and a soft shadow for the far falloff. The result reads as physical lift in a way one shadow can't.

You can't express that in CSS as drop-shadow(...) because drop-shadow() is one layer. You'd write three filter: drop-shadow(...) drop-shadow(...) drop-shadow(...) and hand-tune the offset/blur/opacity for each layer. Three times the typing, three times the chance of mistuning the layers, and zero introspection on the result.

ElevationShadowFilter gives you one knob — elevation — and does the layering for you.

define ViewBox(0, 0, 720, 240); // ElevationShadowFilter — one knob, six elevations. // 0 = flat. 2 = resting. 4 = card. 8 = lifted. 16 = floating. 24 = max. let bg = PathLayer('bg') ${ fill: oklch(94% 0.01 80); stroke: none; }; bg.apply { rect(0, 0, 720, 240); } let f0 = ElevationShadowFilter() {|f| f.elevation = 0; f.color = oklch(20% 0.04 280); }; let f2 = ElevationShadowFilter() {|f| f.elevation = 2; f.color = oklch(20% 0.04 280); }; let f4 = ElevationShadowFilter() {|f| f.elevation = 4; f.color = oklch(20% 0.04 280); }; let f8 = ElevationShadowFilter() {|f| f.elevation = 8; f.color = oklch(20% 0.04 280); }; let f16 = ElevationShadowFilter() {|f| f.elevation = 16; f.color = oklch(20% 0.04 280); }; let f24 = ElevationShadowFilter() {|f| f.elevation = 24; f.color = oklch(20% 0.04 280); }; let c0 = PathLayer('c0') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f0; }; let c2 = PathLayer('c2') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f2; }; let c4 = PathLayer('c4') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f4; }; let c8 = PathLayer('c8') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f8; }; let c16 = PathLayer('c16') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f16; }; let c24 = PathLayer('c24') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f24; }; c0.apply { roundRect(25, 60, 75, 60, 8); } c2.apply { roundRect(140, 60, 75, 60, 8); } c4.apply { roundRect(255, 60, 75, 60, 8); } c8.apply { roundRect(370, 60, 75, 60, 8); } c16.apply { roundRect(485, 60, 75, 60, 8); } c24.apply { roundRect(600, 60, 75, 60, 8); } let l0 = TextLayer('l0') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l0.apply { text(62, 200)`elevation = 0`; } let l2t = TextLayer('l2') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l2t.apply { text(177, 200)`elevation = 2`; } let l4t = TextLayer('l4') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l4t.apply { text(292, 200)`elevation = 4`; } let l8t = TextLayer('l8') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l8t.apply { text(407, 200)`elevation = 8`; } let l16 = TextLayer('l16') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l16.apply { text(522, 200)`elevation = 16`; } let l24 = TextLayer('l24') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l24.apply { text(637, 200)`elevation = 24`; } One value, six elevations. elevation = 0 emits no shadow; elevation = 24 produces a deeply lifted floating element.

Internally, elevation parameterizes three sub-shadows with tuned distance, blur, and opacity ratios — 0.3 / 0.5 / 0.30 for the tight layer, 0.6 / 1.0 / 0.18 for the mid, 1.0 / 2.0 / 0.12 for the soft. Multiply each by elevation, project the offset along direction (default 90deg = down), and you get a shadow stack that scales coherently from "barely lifted" to "dramatically floating."

tightness — scaling the ratios

For finer control over the shadow character at a fixed elevation, the tightness property scales the per-layer distance and blur ratios. 0.5 reads as a tighter, crisper depth; 2.0 reads as a wider, hazier cast.

define ViewBox(0, 0, 540, 240); // ElevationShadowFilter — tightness sweep at fixed elevation=6. // 0.5 = tighter, crisper depth. 1.0 = Material default. 2.0 = wider, hazier. let bg = PathLayer('bg') ${ fill: oklch(94% 0.01 80); stroke: none; }; bg.apply { rect(0, 0, 540, 240); } let f1 = ElevationShadowFilter() {|f| f.elevation = 6; f.tightness = 0.5; f.color = oklch(20% 0.04 280); }; let f2 = ElevationShadowFilter() {|f| f.elevation = 6; f.tightness = 1; f.color = oklch(20% 0.04 280); }; let f3 = ElevationShadowFilter() {|f| f.elevation = 6; f.tightness = 2; f.color = oklch(20% 0.04 280); }; let c1 = PathLayer('c1') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f1; }; let c2 = PathLayer('c2') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f2; }; let c3 = PathLayer('c3') ${ fill: oklch(95% 0.01 80); stroke: none; filter: f3; }; c1.apply { roundRect(50, 70, 80, 60, 8); } c2.apply { roundRect(230, 70, 80, 60, 8); } c3.apply { roundRect(410, 70, 80, 60, 8); } 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(90, 200)`tightness = 0.5`; } 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(270, 200)`tightness = 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(450, 200)`tightness = 2.0`; } Same elevation (6), three tightness values. The shadow widens and softens as tightness grows.

Why three layers beats one

Side by side: the left card uses a single CSS drop-shadow(0 6px 12px black) at 40% opacity. The right card uses ElevationShadowFilter with elevation = 6. Same approximate "depth budget" — the elevation knob and the drop-shadow params land in the same neighborhood — but the three-layer chain reads as something hovering above the page rather than something with a shadow under it.

define ViewBox(0, 0, 400, 240); // Same depth budget, two ways to spend it. // Left: native CSS `drop-shadow(0 6px 12px black)` — a single shadow. // Right: ElevationShadowFilter at elevation=6 — three layered shadows tuned for physical depth. let bg = PathLayer('bg') ${ fill: oklch(94% 0.01 80); stroke: none; }; bg.apply { rect(0, 0, 400, 240); } let elev = ElevationShadowFilter() {|f| f.elevation = 6; f.color = oklch(15% 0.04 280); }; let c_native = PathLayer('native') ${ fill: oklch(95% 0.01 80); stroke: none; filter: drop-shadow(0 6px 12px rgba(20, 21, 31, 0.4)); }; c_native.apply { roundRect(40, 70, 100, 70, 10); } let c_pathogen = PathLayer('pathogen') ${ fill: oklch(95% 0.01 80); stroke: none; filter: elev; }; c_pathogen.apply { roundRect(260, 70, 100, 70, 10); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l1.apply { text(90, 200)`drop-shadow()`; } let l1b = TextLayer('l1b') ${ font-family: sans-serif; font-size: 10; font-weight: 400; fill: oklch(45% 0.02 270); text-anchor: middle; }; l1b.apply { text(90, 215)`single layer`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l2.apply { text(310, 200)`ElevationShadowFilter`; } let l2b = TextLayer('l2b') ${ font-family: sans-serif; font-size: 10; font-weight: 400; fill: oklch(45% 0.02 270); text-anchor: middle; }; l2b.apply { text(310, 215)`three layers, one knob`; } Single CSS drop-shadow on the left; three-layer ElevationShadowFilter on the right. The contact zone, falloff, and outer haze tell different stories.

This isn't a knock on drop-shadow() — it's the right tool for a specific job. It's a demonstration that Material depth is a different visual language than offset shadow, and that having a first-class language value for the former is the difference between expressing it directly and re-inventing the layering by hand each time.

InnerShadowFilter — the inset capability CSS can't reach

CSS drop-shadow() is outer-only. There is no drop-shadow(... inset) keyword and no other CSS filter function that produces an inset shadow. Pressed buttons, recessed wells, engraved-text effects, carved-look artwork — all of these reach for box-shadow: inset ..., which works on box backgrounds but not on SVG paths.

InnerShadowFilter is the capability that closes that gap.

define ViewBox(0, 0, 640, 240); // InnerShadowFilter — blur sweep. Crisper to softer pressed-in edge. let bg = PathLayer('bg') ${ fill: oklch(94% 0.01 80); stroke: none; }; bg.apply { rect(0, 0, 640, 240); } let f1 = InnerShadowFilter() {|f| f.offsetY = 3; f.blur = 2; f.color = oklch(15% 0.02 280); f.opacity = 0.55; }; let f2 = InnerShadowFilter() {|f| f.offsetY = 3; f.blur = 4; f.color = oklch(15% 0.02 280); f.opacity = 0.55; }; let f3 = InnerShadowFilter() {|f| f.offsetY = 3; f.blur = 8; f.color = oklch(15% 0.02 280); f.opacity = 0.55; }; let f4 = InnerShadowFilter() {|f| f.offsetY = 3; f.blur = 16; f.color = oklch(15% 0.02 280); f.opacity = 0.55; }; let c1 = PathLayer('c1') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f1; }; let c2 = PathLayer('c2') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f2; }; let c3 = PathLayer('c3') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f3; }; let c4 = PathLayer('c4') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f4; }; c1.apply { roundRect(30, 60, 110, 80, 10); } c2.apply { roundRect(190, 60, 110, 80, 10); } c3.apply { roundRect(350, 60, 110, 80, 10); } c4.apply { roundRect(510, 60, 110, 80, 10); } 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(85, 200)`blur = 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(245, 200)`blur = 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(405, 200)`blur = 8`; } 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(565, 200)`blur = 16`; } Four blur radii. blur = 2 reads as a hard-edged debossed groove; blur = 16 reads as a soft recessed well.

The primitive chain is the inverse of an outer shadow: blur SourceAlpha, offset it, composite against the original silhouette using operator="out" (which keeps only the part NOT covered by the offset blur), color-fill, clip to the source, then merge under the source graphic.

The offset compass

offsetX and offsetY together control where the shadow falls inside the shape — and therefore where the perceived light source sits outside it. A positive offsetY (the default of 2) pushes the shadow down, which reads as light coming from above. Negative offsetY flips that. Diagonal offsets simulate raking light.

define ViewBox(0, 0, 480, 480); // InnerShadowFilter — 8 offset directions around a clock face. // The shadow falls toward (offsetX, offsetY); light comes from the opposite side. let bg = PathLayer('bg') ${ fill: oklch(94% 0.01 80); stroke: none; }; bg.apply { rect(0, 0, 480, 480); } // Center coordinates for the 3×3 grid (skipping the middle cell) let f_n = InnerShadowFilter() {|f| f.offsetX = 0; f.offsetY = -5; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.6; }; let f_ne = InnerShadowFilter() {|f| f.offsetX = 4; f.offsetY = -4; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.6; }; let f_e = InnerShadowFilter() {|f| f.offsetX = 5; f.offsetY = 0; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.6; }; let f_se = InnerShadowFilter() {|f| f.offsetX = 4; f.offsetY = 4; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.6; }; let f_s = InnerShadowFilter() {|f| f.offsetX = 0; f.offsetY = 5; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.6; }; let f_sw = InnerShadowFilter() {|f| f.offsetX = -4; f.offsetY = 4; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.6; }; let f_w = InnerShadowFilter() {|f| f.offsetX = -5; f.offsetY = 0; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.6; }; let f_nw = InnerShadowFilter() {|f| f.offsetX = -4; f.offsetY = -4; f.blur = 5; f.color = oklch(15% 0.02 280); f.opacity = 0.6; }; let mk_n = PathLayer('n') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f_n; }; let mk_ne = PathLayer('ne') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f_ne; }; let mk_e = PathLayer('e') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f_e; }; let mk_se = PathLayer('se') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f_se; }; let mk_s = PathLayer('s') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f_s; }; let mk_sw = PathLayer('sw') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f_sw; }; let mk_w = PathLayer('w') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f_w; }; let mk_nw = PathLayer('nw') ${ fill: oklch(80% 0.06 230); stroke: none; filter: f_nw; }; mk_nw.apply { circle(120, 120, 50); } mk_n.apply { circle(240, 120, 50); } mk_ne.apply { circle(360, 120, 50); } mk_w.apply { circle(120, 240, 50); } mk_e.apply { circle(360, 240, 50); } mk_sw.apply { circle(120, 360, 50); } mk_s.apply { circle(240, 360, 50); } mk_se.apply { circle(360, 360, 50); } let label_nw = TextLayer('lnw') ${ font-family: sans-serif; font-size: 11; font-weight: 600; fill: oklch(35% 0.02 270); text-anchor: middle; }; label_nw.apply { text(120, 183)`(-4, -4)`; } let label_n = TextLayer('ln') ${ font-family: sans-serif; font-size: 11; font-weight: 600; fill: oklch(35% 0.02 270); text-anchor: middle; }; label_n.apply { text(240, 183)`(0, -5)`; } let label_ne = TextLayer('lne') ${ font-family: sans-serif; font-size: 11; font-weight: 600; fill: oklch(35% 0.02 270); text-anchor: middle; }; label_ne.apply { text(360, 183)`(4, -4)`; } let label_w = TextLayer('lw') ${ font-family: sans-serif; font-size: 11; font-weight: 600; fill: oklch(35% 0.02 270); text-anchor: middle; }; label_w.apply { text(120, 303)`(-5, 0)`; } let label_e = TextLayer('le') ${ font-family: sans-serif; font-size: 11; font-weight: 600; fill: oklch(35% 0.02 270); text-anchor: middle; }; label_e.apply { text(360, 303)`(5, 0)`; } let label_sw = TextLayer('lsw') ${ font-family: sans-serif; font-size: 11; font-weight: 600; fill: oklch(35% 0.02 270); text-anchor: middle; }; label_sw.apply { text(120, 435)`(-4, 4)`; } let label_s = TextLayer('ls') ${ font-family: sans-serif; font-size: 11; font-weight: 600; fill: oklch(35% 0.02 270); text-anchor: middle; }; label_s.apply { text(240, 435)`(0, 5)`; } let label_se = TextLayer('lse') ${ font-family: sans-serif; font-size: 11; font-weight: 600; fill: oklch(35% 0.02 270); text-anchor: middle; }; label_se.apply { text(360, 435)`(4, 4)`; } let caption = TextLayer('caption') ${ font-family: sans-serif; font-size: 12; font-weight: 700; fill: oklch(20% 0.02 270); text-anchor: middle; }; caption.apply { text(240, 252)`offsetX, offsetY`; } Eight offset directions on the same disc. Each label is the (offsetX, offsetY) pair; the shadow lands opposite the implied light source.

The compass view also illustrates something the other parameter sweeps can't: the inner shadow technique works on any shape, not just rectangles. Roundrects, circles, stars, freeform paths — InnerShadowFilter clips to whatever silhouette you painted.

PixelateFilter — a four-primitive recipe collapsed into one constructor

Pixelation in raw SVG is a sample-flood-tile-composite-dilate technique: flood a small region with a sample color, expand the region into one tile cell, tile that cell across the filter region, composite against the source to keep only the pixels at sample positions, then dilate each sample into a block. Four primitives, three intermediate result names to thread together, and a filterUnits attribute you have to get right or the whole thing renders blank.

PixelateFilter collapses all of that into one constructor with three numeric knobs and gives you the choice of positional or block-style configuration:

let pix = PixelateFilter(width, height, radius);
  • width and height are the stride between sampled pixels (and therefore the block size in the output)
  • radius is the dilation distance — how far each sample expands

The constructor accepts both positional arguments (as above) and the trailing-block form for consistency with the other filters:

let pix = PixelateFilter() {|f|
  f.width = 12;
  f.height = 12;
  f.radius = 6;
};

Block size sweep

Three positional values, four block sizes. Larger width produces coarser pixelation.

define ViewBox(0, 0, 640, 240); // PixelateFilter — block size sweep. radius = width / 2 keeps blocks touching. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 640, 240); } let f1 = PixelateFilter(4, 4, 2); let f2 = PixelateFilter(8, 8, 4); let f3 = PixelateFilter(16, 16, 8); let f4 = PixelateFilter(32, 32, 16); 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)`(4, 4, 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)`(8, 8, 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)`(16, 16, 8)`; } 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)`(32, 32, 16)`; } let lcap = TextLayer('lcap') ${ font-family: sans-serif; font-size: 11; font-weight: 400; fill: oklch(45% 0.02 270); text-anchor: middle; }; lcap.apply { text(320, 222)`PixelateFilter(width, height, radius)`; } PixelateFilter(4, 4, 2) through PixelateFilter(32, 32, 16). radius = width / 2 keeps blocks touching with no gap or overlap.

Radius regime

At fixed width and height, radius controls three visually distinct regimes:

define ViewBox(0, 0, 540, 240); // PixelateFilter — three radius regimes at fixed block size width=height=16. // radius < 8: gaps between blocks. radius = 8: blocks touch. radius > 8: overlap. let bg = PathLayer('bg') ${ fill: oklch(95% 0.02 80); stroke: none; }; bg.apply { rect(0, 0, 540, 240); } let f_gap = PixelateFilter(16, 16, 4); let f_touch = PixelateFilter(16, 16, 8); let f_overlap = PixelateFilter(16, 16, 12); let d1 = PathLayer('d1') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_gap; }; let d2 = PathLayer('d2') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_touch; }; let d3 = PathLayer('d3') ${ fill: oklch(70% 0.20 30); stroke: none; filter: f_overlap; }; d1.apply { circle(90, 100, 55); } d2.apply { circle(270, 100, 55); } d3.apply { circle(450, 100, 55); } let l1 = TextLayer('l1') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l1.apply { text(90, 195)`radius = 4`; } let l1b = TextLayer('l1b') ${ font-family: sans-serif; font-size: 10; font-weight: 400; fill: oklch(45% 0.02 270); text-anchor: middle; }; l1b.apply { text(90, 212)`gaps between blocks`; } let l2 = TextLayer('l2') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l2.apply { text(270, 195)`radius = 8`; } let l2b = TextLayer('l2b') ${ font-family: sans-serif; font-size: 10; font-weight: 400; fill: oklch(45% 0.02 270); text-anchor: middle; }; l2b.apply { text(270, 212)`blocks just touch (= width / 2)`; } let l3 = TextLayer('l3') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; l3.apply { text(450, 195)`radius = 12`; } let l3b = TextLayer('l3b') ${ font-family: sans-serif; font-size: 10; font-weight: 400; fill: oklch(45% 0.02 270); text-anchor: middle; }; l3b.apply { text(450, 212)`blocks overlap`; } Width = height = 16 in all three. Radius below width/2 leaves gaps; equal to width/2 makes blocks touch; above width/2 makes them overlap.

The gap regime gives you a halftone-print look. The touching regime is the canonical "8-bit pixel" appearance. The overlap regime softens the block edges by letting adjacent samples merge — useful when you want pixelation without the harsh grid.

Stacking filters via GroupLayer

A single filter: declaration in a Pathogen style block accepts either a custom filter value or a chain of native CSS filter functions — not both at once. That's the v1 limit on filter chaining.

The composition workaround is straightforward and ergonomic: nest the layer in a GroupLayer carrying one filter, and put the second filter on the inner layer.

define ViewBox(0, 0, 400, 240); // Stacking custom filters via GroupLayer: outer Elevation + inner Inner Shadow. // Two filters, one card — raised above its surface, recessed on its top edge. let bg = PathLayer('bg') ${ fill: oklch(94% 0.01 80); stroke: none; }; bg.apply { rect(0, 0, 400, 240); } let lift = ElevationShadowFilter() {|f| f.elevation = 6; f.color = oklch(20% 0.04 280); }; let press = InnerShadowFilter() {|f| f.offsetY = 3; f.blur = 4; f.color = oklch(15% 0.02 280); f.opacity = 0.5; }; // Inner layer carries the inner shadow. let inner = PathLayer('inner') ${ fill: oklch(80% 0.06 230); stroke: none; filter: press; }; inner.apply { roundRect(100, 80, 200, 60, 14); } // Outer group carries the elevation shadow. let chip = GroupLayer('chip') ${ filter: lift; }; chip.append(inner); let label = TextLayer('label') ${ font-family: sans-serif; font-size: 12; font-weight: 600; fill: oklch(25% 0.02 270); text-anchor: middle; }; label.apply { text(200, 200)`ElevationShadow on GroupLayer + InnerShadow on PathLayer`; } A card raised above its surface (ElevationShadow on the outer GroupLayer) and visually recessed on its top edge (InnerShadow on the inner PathLayer).

Two filters, one rectangle. The card reads as physically lifted above its surface AND as having a pressed-in lip along its top edge — a combination you can't express in a single CSS filter: declaration, and that you wouldn't want to write by hand as a raw <filter> def either.

Ergonomic recap

Looking back across both posts, the six custom filters fit the same shape:

  • Defined once with a trailing block of named property assignments.
  • Referenced many times via a single let binding, producing one <filter> def in the output regardless of reference count.
  • Configured by preset where presets exist (NoiseFilter's style, GlowFilter's mode) and by named numeric parameters everywhere else (no positional ambiguity).
  • Introspectable — every configurable property is also readable from the value.
  • Composable with gradients (filters apply on top of any fill, including gradients) and with each other (via GroupLayer stacking).
  • Cross-surface consistent — the same source compiles to identical SVG in the CLI, the playground, and the VS Code preview.

That's the ergonomic story we wanted to tell. The visual capabilities are real — Material elevation, inset shadows, the full noise family, the rest — but they're available in raw SVG too. What custom filters add is making those capabilities easy to reach for, easy to reuse, and easy to compose.

For the full reference, see docs/filters. For the architecture story and the NoiseFilter deep-dive, see Part 1. To experiment with any of these in your browser, open the playground.