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:
- Custom Filters in Pathogen: First-Class Visual Effects
- 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`;
}
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`;
}
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`;
}
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`;
}
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°`;
}
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`;
}
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`;
}
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`;
}
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`;
}
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`;
}
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`;
}
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`;
}
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);
widthandheightare the stride between sampled pixels (and therefore the block size in the output)radiusis 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)`;
}
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`;
}
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`;
}
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
letbinding, producing one<filter>def in the output regardless of reference count. - Configured by preset where presets exist (NoiseFilter's
style, GlowFilter'smode) 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
GroupLayerstacking). - 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.