Reactive Color in SVG: From Static Paths to Dynamic Themes
SVG is the web's vector format. It lives in the DOM, responds to CSS, and can be styled with custom properties. Yet most tools treat SVG as a static export — a snapshot frozen at build time. Colors baked in. Themes impossible without regeneration.
Pathogen's Color system changes this. By combining a first-class Color type with CSS custom properties, Pathogen compiles SVG illustrations that are reactive — change a CSS variable at runtime and every derived color updates instantly. No JavaScript. No recompilation. Just CSS doing what CSS does best.
This post walks through the system from first principles, building up from a single color to full light/dark adaptive themes. The demos below are live — pick a color and watch the SVG respond.
Starting with Color
Color in Pathogen is a first-class type backed by the OKLCH color space. OKLCH stands for Okay Lightness, Chroma, Hue — a perceptually uniform color model where equal numeric steps produce equal visual differences. This matters: lightening a red by 0.15 and lightening a blue by 0.15 should look like the same amount of change. In sRGB hex they don't. In OKLCH they do.
Creating a color is straightforward:
let c = Color('#e63946');
// Access OKLCH components
log(c.lightness); // ~0.52
log(c.chroma); // ~0.19
log(c.hue); // ~27
// Output in any format
log(c.hex); // #e63946
log(c.oklch); // oklch(0.52 0.19 27)
log(c.hsl); // hsl(355, 78%, 56%)
Pathogen accepts any CSS color format as input — hex, rgb(), hsl(), named colors — and immediately converts to OKLCH internally. From there, all manipulation happens in perceptual space.
You can also construct colors directly in OKLCH:
let sky = Color(0.75, 0.12, 230); // L, C, H
Color Manipulation
Every Color value exposes a set of manipulation methods. Each returns a new Color — nothing mutates.
let base = Color('#e63946');
let lighter = base.lighten(0.18); // bump lightness
let darker = base.darken(0.18); // reduce lightness
let vivid = base.saturate(1.5); // scale chroma up
let muted = base.desaturate(0.4); // scale chroma down
let shifted = base.hueShift(90); // rotate hue
let comp = base.complement(); // hue + 180
let semi = base.alpha(0.5); // set transparency
let blended = base.mix(Color('#457b9d'), 0.5); // interpolate
All of this operates in OKLCH, so a .lighten(0.18) on a deeply saturated red doesn't accidentally desaturate it — it shifts only lightness while preserving chroma and hue. Try it: pick a color below and watch each method derive its swatch.
define ViewBox(0, 0, 520, 400);
// Color Methods — Direction 2: Radial Wheel
// Eight slim 30° sectors arranged around a central base-color hub.
// Each sector fills with one OKLCH method derivation; labels sit
// outside the outer frame. Dashed fg_auto reference ring at mid-wedge.
// Extended-tier signature (variant-b3 lozenge DNA).
// ─── Core tokens ──────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let base = Color(CSSVar('--demo-color', #e63946));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.60);
let fg_hair = Color('#0d1638').alpha(0.22);
// Auto-contrasting ink against the base color (for hub text)
let hub_ink = Color('#0d1638');
let hub_ink_muted = Color('#0d1638').alpha(0.60);
let font = 'sans-serif';
// ─── Derived method fills ────────────────────────────────────
let m_base = Color(CSSVar('--demo-color', #e63946));
let m_lighten = Color(CSSVar('--demo-color', #e63946)).lighten(0.18);
let m_darken = Color(CSSVar('--demo-color', #e63946)).darken(0.18);
let m_saturate = Color(CSSVar('--demo-color', #e63946)).saturate(1.5);
let m_desaturate = Color(CSSVar('--demo-color', #e63946)).desaturate(0.4);
let m_hue = Color(CSSVar('--demo-color', #e63946)).hueShift(90);
let m_complement = Color(CSSVar('--demo-color', #e63946)).hueShift(180);
let m_alpha = Color(CSSVar('--demo-color', #e63946)).alpha(0.5);
// ─── Group layers ───────────────────────────────────────────
define GroupLayer('diagram') ${}
define GroupLayer('wheel') ${}
define GroupLayer('hub-group') ${}
define GroupLayer('labels') ${}
// ─── Background ─────────────────────────────────────────────
let bg = PathLayer('bg') ${ fill: bg_color; stroke: none; };
bg.apply { rect(0, 0, 520, 400); }
// ─── Eyebrow (top-left) ─────────────────────────────────────
let eyebrow = TextLayer('eyebrow') ${
font-family: font; font-size: 8; font-weight: 700;
letter-spacing: 3; fill: fg_muted; text-anchor: start;
};
eyebrow.apply { text(15, 24)`OKLCH / 01 — MANIPULATION` }
layer('diagram').append(layer('bg'), layer('eyebrow'));
// ─── Wheel geometry ─────────────────────────────────────────
let cx = 260;
let cy = 210;
let r_hub = 40; // hub radius
let r_inner = 52; // inner wedge radius (12px gap from hub)
let r_outer = 128; // outer wedge radius
let r_frame = 140; // outer frame circle
let r_ring = 90; // dashed reference ring radius
let r_label = 165; // label ring radius
let sector_half_span = 0.2618; // π/12 = 15°
let corner_r = 5.5;
// ─── Wedges: 8 methods around the clock ─────────────────────
let methods = [
['BASE', m_base],
['LIGHTEN', m_lighten],
['SATURATE', m_saturate],
['HUE +90°', m_hue],
['COMPLEMENT', m_complement],
['DESATURATE', m_desaturate],
['DARKEN', m_darken],
['ALPHA 0.5', m_alpha],
];
for ([m, i] in methods) {
// Angle convention: 0 = north, step +π/4 clockwise
let theta = calc(-1.5708 + i * 0.7854);
let from_a = calc(theta - sector_half_span);
let to_a = calc(theta + sector_half_span);
let w = PathLayer(`w-${i}`) ${ fill: m[1]; stroke: none; };
w.apply {
M cx cy
radialWedge(r_inner, r_outer, from_a, to_a, corner_r);
}
layer('wheel').append(layer(`w-${i}`));
}
// ─── Outer frame ────────────────────────────────────────────
let frame = PathLayer('frame') ${ fill: none; stroke: fg_hair; stroke-width: 0.5; };
frame.apply { circle(cx, cy, r_frame); }
// ─── Dashed reference ring at mid-wedge radius ──────────────
let ring = PathLayer('ring') ${
fill: none; stroke: fg_auto; stroke-width: 1; stroke-dasharray: 3 4;
};
ring.apply { circle(cx, cy, r_ring); }
// ─── Hub (drawn OVER wedges) ────────────────────────────────
let hub = PathLayer('hub') ${ fill: base; stroke: fg_hair; stroke-width: 0.5; };
hub.apply { circle(cx, cy, r_hub); }
let hub_title = TextLayer('hub-title') ${
font-family: font; font-size: 14; font-weight: 700;
letter-spacing: 4; fill: hub_ink; text-anchor: middle;
};
hub_title.apply { text(cx, calc(cy - 2))`OKLCH` }
let hub_sub = TextLayer('hub-sub') ${
font-family: font; font-size: 8;
letter-spacing: 0.8; fill: hub_ink_muted; text-anchor: middle;
};
hub_sub.apply { text(cx, calc(cy + 12))`MANIPULATION` }
layer('wheel').append(layer('frame'), layer('ring'));
layer('hub-group').append(layer('hub'), layer('hub-title'), layer('hub-sub'));
// ─── Labels beyond the frame ────────────────────────────────
let label = TextLayer('label') ${
font-family: font; font-size: 8; font-weight: 700;
letter-spacing: 2; fill: fg_auto; text-anchor: middle;
};
for ([m, i] in methods) {
let theta = calc(-1.5708 + i * 0.7854);
let lx = calc(cx + r_label * cos(theta));
let ly = calc(cy + r_label * sin(theta) + 3);
label.apply { text(lx, ly)`${m[0]}` }
}
layer('labels').append(layer('label'));
layer('diagram').append(layer('wheel'), layer('hub-group'), layer('labels'));
What makes this reactive? The SVG above was compiled once. There is no JavaScript updating colors. The fill values use CSS relative color syntax:
fill="oklch(from var(--demo-color, #e63946) calc(l + 0.18) c h)"
The browser resolves var(--demo-color), extracts its OKLCH components, applies calc(l + 0.18), and renders. When the color picker updates --demo-color, the browser recalculates everything automatically.
Harmonies and Palettes
Color theory provides recipes for colors that work well together. Pathogen generates these directly:
let base = Color('#e63946');
// Harmony groups
let analog = base.analogous(); // 3 colors: hue -30, 0, +30
let triad = base.triadic(); // 3 colors: hue 0, +120, +240
let tetrad = base.tetradic(); // 4 colors: hue 0, +90, +180, +270
let split = base.splitComplementary(); // 3 colors: hue 0, +150, +210
Each harmony method returns an array of Colors you can iterate:
for ([color, i] in base.triadic()) {
define PathLayer(`swatch-${i}`) ${ fill: color; }
layer(`swatch-${i}`).apply { roundRect(x, y, 40, 40, 6) }
}
Palettes go further. Color.palette() generates lightness ramps or interpolation sequences:
let ramp = Color.palette(base, 5); // 5-step lightness ramp
let blend = Color.palette(base, accent, 5); // 5-step interpolation
The lightness ramp spreads evenly from dark (L=0.15) to light (L=0.95). The interpolation variant uses color-mix() when backed by CSS variables, so the browser handles the blending at render time.
define ViewBox(0, 0, 520, 560);
// Harmonies & Palettes — Direction 2: Hue Wheel
// A 12-sector static hue wheel with harmony-chord overlays (analogous
// arc, triadic triangle, tetradic square, split-comp Y). Harmony
// vertices carry live-derived base chips. Two palette strips below.
// Extended-tier signature.
// ─── Core tokens ──────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let base = Color(CSSVar('--harmony-color', #e63946));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.60);
let fg_hair = Color('#0d1638').alpha(0.22);
let font = 'sans-serif';
// ─── Derived harmony fills ───────────────────────────────────
let c_base = Color(CSSVar('--harmony-color', #e63946));
let c_ana_m30 = Color(CSSVar('--harmony-color', #e63946)).hueShift(-30);
let c_ana_p30 = Color(CSSVar('--harmony-color', #e63946)).hueShift(30);
let c_tri_p120 = Color(CSSVar('--harmony-color', #e63946)).hueShift(120);
let c_tri_m120 = Color(CSSVar('--harmony-color', #e63946)).hueShift(-120);
let c_tet_p90 = Color(CSSVar('--harmony-color', #e63946)).hueShift(90);
let c_tet_p180 = Color(CSSVar('--harmony-color', #e63946)).hueShift(180);
let c_tet_p270 = Color(CSSVar('--harmony-color', #e63946)).hueShift(270);
let c_sc_p150 = Color(CSSVar('--harmony-color', #e63946)).hueShift(150);
let c_sc_p210 = Color(CSSVar('--harmony-color', #e63946)).hueShift(210);
let c_l_15 = Color(CSSVar('--harmony-color', #e63946)).darken(0.475);
let c_l_35 = Color(CSSVar('--harmony-color', #e63946)).darken(0.275);
let c_l_55 = Color(CSSVar('--harmony-color', #e63946)).darken(0.075);
let c_l_75 = Color(CSSVar('--harmony-color', #e63946)).lighten(0.125);
let c_l_95 = Color(CSSVar('--harmony-color', #e63946)).lighten(0.325);
let c_i_0 = Color(CSSVar('--harmony-color', #e63946));
let c_i_25 = Color(CSSVar('--harmony-color', #e63946)).mix(Color('#457b9d'), 0.25);
let c_i_50 = Color(CSSVar('--harmony-color', #e63946)).mix(Color('#457b9d'), 0.50);
let c_i_75 = Color(CSSVar('--harmony-color', #e63946)).mix(Color('#457b9d'), 0.75);
let c_i_100 = '#457b9d';
// ─── Group layers ───────────────────────────────────────────
define GroupLayer('diagram') ${}
define GroupLayer('wheel') ${}
define GroupLayer('chords') ${}
define GroupLayer('strips') ${}
// ─── Background ─────────────────────────────────────────────
let bg = PathLayer('bg') ${ fill: bg_color; stroke: none; };
bg.apply { rect(0, 0, 520, 560); }
// ─── Masthead ───────────────────────────────────────────────
let eyebrow = TextLayer('eyebrow') ${
font-family: font; font-size: 8; font-weight: 700;
letter-spacing: 3; fill: fg_muted; text-anchor: start;
};
eyebrow.apply { text(15, 26)`OKLCH / 02 — HARMONIES & PALETTES` }
layer('diagram').append(layer('bg'), layer('eyebrow'));
// ─── Wheel geometry ─────────────────────────────────────────
let cx = 260;
let cy = 200;
let r_inner = 55;
let r_outer = 135;
let r_frame = 145;
let r_vertex = 115; // chord-overlay dot radius
let r_hub = 50;
// ─── 12 hue sectors ─────────────────────────────────────────
// 12 sectors of 30° each. Each sector fills with a static reference
// hue. Sector 0 at north, advancing clockwise. We enumerate 12
// explicit let bindings so each fill is a literal color string.
let seg_span = 0.5236; // π/6
let w0 = PathLayer('w-0') ${ fill: 'oklch(0.70 0.16 0)'; stroke: fg_hair; stroke-width: 0.5; };
let w1 = PathLayer('w-1') ${ fill: 'oklch(0.70 0.16 30)'; stroke: fg_hair; stroke-width: 0.5; };
let w2 = PathLayer('w-2') ${ fill: 'oklch(0.70 0.16 60)'; stroke: fg_hair; stroke-width: 0.5; };
let w3 = PathLayer('w-3') ${ fill: 'oklch(0.70 0.16 90)'; stroke: fg_hair; stroke-width: 0.5; };
let w4 = PathLayer('w-4') ${ fill: 'oklch(0.70 0.16 120)'; stroke: fg_hair; stroke-width: 0.5; };
let w5 = PathLayer('w-5') ${ fill: 'oklch(0.70 0.16 150)'; stroke: fg_hair; stroke-width: 0.5; };
let w6 = PathLayer('w-6') ${ fill: 'oklch(0.70 0.16 180)'; stroke: fg_hair; stroke-width: 0.5; };
let w7 = PathLayer('w-7') ${ fill: 'oklch(0.70 0.16 210)'; stroke: fg_hair; stroke-width: 0.5; };
let w8 = PathLayer('w-8') ${ fill: 'oklch(0.70 0.16 240)'; stroke: fg_hair; stroke-width: 0.5; };
let w9 = PathLayer('w-9') ${ fill: 'oklch(0.70 0.16 270)'; stroke: fg_hair; stroke-width: 0.5; };
let w10 = PathLayer('w-10') ${ fill: 'oklch(0.70 0.16 300)'; stroke: fg_hair; stroke-width: 0.5; };
let w11 = PathLayer('w-11') ${ fill: 'oklch(0.70 0.16 330)'; stroke: fg_hair; stroke-width: 0.5; };
let wedge_layers = [w0, w1, w2, w3, w4, w5, w6, w7, w8, w9, w10, w11];
for (i in 0..11) {
let from_a = calc(-1.5708 + i * seg_span - seg_span / 2);
let to_a = calc(-1.5708 + i * seg_span + seg_span / 2);
let w_name = `w-${i}`;
layer(w_name).apply {
M cx cy
radialWedge(r_inner, r_outer, from_a, to_a, 0);
}
layer('wheel').append(layer(w_name));
}
// ─── Frame + hub ─────────────────────────────────────────────
let frame = PathLayer('frame') ${ fill: none; stroke: fg_hair; stroke-width: 0.5; };
frame.apply { circle(cx, cy, r_frame); }
let hub = PathLayer('hub') ${ fill: bg_color; stroke: fg_hair; stroke-width: 0.5; };
hub.apply { circle(cx, cy, r_hub); }
let hub_title = TextLayer('hub-title') ${
font-family: font; font-size: 14; font-weight: 700;
letter-spacing: 4; fill: fg_auto; text-anchor: middle;
};
hub_title.apply { text(cx, calc(cy - 2))`OKLCH` }
let hub_sub = TextLayer('hub-sub') ${
font-family: font; font-size: 8;
letter-spacing: 0.8; fill: fg_muted; text-anchor: middle;
};
hub_sub.apply { text(cx, calc(cy + 12))`HARMONY` }
layer('wheel').append(layer('frame'), layer('hub'), layer('hub-title'), layer('hub-sub'));
// ─── Chord overlays (assuming base at north, 12 o'clock) ────
// Each overlay is drawn as path edges between vertex points on
// a circle of radius r_vertex. Then a small base-derived chip
// sits on each vertex.
// Analogous: chord = short line from −30° to +30°
let ana_chord = PathLayer('ana-chord') ${
fill: none; stroke: fg_auto; stroke-width: 1; stroke-dasharray: 3 2;
};
ana_chord.apply {
M calc(cx + r_vertex * cos(-2.0944)) calc(cy + r_vertex * sin(-2.0944))
L calc(cx + r_vertex * cos(-1.0472)) calc(cy + r_vertex * sin(-1.0472))
}
// Triadic: equilateral triangle, base at north
let tri_chord = PathLayer('tri-chord') ${
fill: none; stroke: fg_auto; stroke-width: 0.7; stroke-dasharray: 5 3;
};
tri_chord.apply {
M cx calc(cy - r_vertex)
L calc(cx + r_vertex * cos(0.5236)) calc(cy + r_vertex * sin(0.5236))
L calc(cx + r_vertex * cos(2.6180)) calc(cy + r_vertex * sin(2.6180))
Z
}
// Tetradic: square, base at north
let tet_chord = PathLayer('tet-chord') ${
fill: none; stroke: fg_auto; stroke-width: 0.7; stroke-dasharray: 2 2;
};
tet_chord.apply {
M cx calc(cy - r_vertex)
L calc(cx + r_vertex) cy
L cx calc(cy + r_vertex)
L calc(cx - r_vertex) cy
Z
}
// Split-comp: Y from base to +150° and +210° (= −150°)
let sc_chord = PathLayer('sc-chord') ${
fill: none; stroke: fg_auto; stroke-width: 1; stroke-dasharray: 1 2;
};
sc_chord.apply {
M cx calc(cy - r_vertex)
L calc(cx + r_vertex * cos(1.0472)) calc(cy + r_vertex * sin(1.0472))
M cx calc(cy - r_vertex)
L calc(cx + r_vertex * cos(2.0944)) calc(cy + r_vertex * sin(2.0944))
}
layer('chords').append(layer('ana-chord'), layer('tri-chord'), layer('tet-chord'), layer('sc-chord'));
// ─── Vertex markers (base-derived live chips at harmony positions) ──
// Union of all distinct vertex positions from all 4 overlays.
// Angles: -π/2 (base, used by all), -2π/3 (ana-m30), -π/3 (ana-p30),
// π/6 (tri+120), -7π/6≡5π/6 (tri-120),
// 0 (tet+90), π/2 (tet+180), π (tet+270),
// π/3 (sc+150), 2π/3 (sc+210)
let vertex_base = PathLayer('v-base') ${ fill: c_base; stroke: fg_auto; stroke-width: 1; };
let vertex_am30 = PathLayer('v-ana-m30')${ fill: c_ana_m30; stroke: fg_hair; stroke-width: 0.5; };
let vertex_ap30 = PathLayer('v-ana-p30')${ fill: c_ana_p30; stroke: fg_hair; stroke-width: 0.5; };
let vertex_tp120 = PathLayer('v-tri-p120')${ fill: c_tri_p120; stroke: fg_hair; stroke-width: 0.5; };
let vertex_tm120 = PathLayer('v-tri-m120')${ fill: c_tri_m120; stroke: fg_hair; stroke-width: 0.5; };
let vertex_tep90 = PathLayer('v-tet-p90')${ fill: c_tet_p90; stroke: fg_hair; stroke-width: 0.5; };
let vertex_tep180= PathLayer('v-tet-p180')${ fill: c_tet_p180; stroke: fg_hair; stroke-width: 0.5; };
let vertex_tep270= PathLayer('v-tet-p270')${ fill: c_tet_p270; stroke: fg_hair; stroke-width: 0.5; };
let vertex_scp150= PathLayer('v-sc-p150')${ fill: c_sc_p150; stroke: fg_hair; stroke-width: 0.5; };
let vertex_scp210= PathLayer('v-sc-p210')${ fill: c_sc_p210; stroke: fg_hair; stroke-width: 0.5; };
// Base marker — slightly larger dot (r=5) at north
vertex_base.apply { circle(cx, calc(cy - r_vertex), 5); }
vertex_am30.apply { circle(calc(cx + r_vertex * cos(-2.0944)), calc(cy + r_vertex * sin(-2.0944)), 3.5); }
vertex_ap30.apply { circle(calc(cx + r_vertex * cos(-1.0472)), calc(cy + r_vertex * sin(-1.0472)), 3.5); }
vertex_tp120.apply { circle(calc(cx + r_vertex * cos(0.5236)), calc(cy + r_vertex * sin(0.5236)), 3.5); }
vertex_tm120.apply { circle(calc(cx + r_vertex * cos(2.6180)), calc(cy + r_vertex * sin(2.6180)), 3.5); }
vertex_tep90.apply { circle(calc(cx + r_vertex), cy, 3.5); }
vertex_tep180.apply{ circle(cx, calc(cy + r_vertex), 3.5); }
vertex_tep270.apply{ circle(calc(cx - r_vertex), cy, 3.5); }
vertex_scp150.apply{ circle(calc(cx + r_vertex * cos(1.0472)), calc(cy + r_vertex * sin(1.0472)), 3.5); }
vertex_scp210.apply{ circle(calc(cx + r_vertex * cos(2.0944)), calc(cy + r_vertex * sin(2.0944)), 3.5); }
layer('chords').append(
layer('v-base'), layer('v-ana-m30'), layer('v-ana-p30'),
layer('v-tri-p120'), layer('v-tri-m120'),
layer('v-tet-p90'), layer('v-tet-p180'), layer('v-tet-p270'),
layer('v-sc-p150'), layer('v-sc-p210')
);
// ─── Palette strips below the wheel ─────────────────────────
// Strip 1: lightness ramp at y=365..415
// Strip 2: interpolate at y=445..495
// 5 chips per strip, full-width.
let strip_label_layer = TextLayer('strip-label') ${
font-family: font; font-size: 8; font-weight: 700;
letter-spacing: 3; fill: fg_muted; text-anchor: start;
};
let chip_w = 94;
let chip_gap = 4;
let chip_x0 = 15;
// Strip 1: Lightness
strip_label_layer.apply { text(chip_x0, 365)`LIGHTNESS / L 0.15 → 0.95` }
let l_colors = [c_l_15, c_l_35, c_l_55, c_l_75, c_l_95];
for ([col, i] in l_colors) {
let cx_i = calc(chip_x0 + i * (chip_w + chip_gap));
let sw = PathLayer(`l-${i}`) ${ fill: col; stroke: fg_hair; stroke-width: 0.5; };
sw.apply { roundRect(cx_i, 375, chip_w, 42, 5.5); }
layer('strips').append(layer(`l-${i}`));
}
// Strip 2: Interpolate
strip_label_layer.apply { text(chip_x0, 445)`INTERPOLATE / BASE → #457b9d` }
let i_colors = [c_i_0, c_i_25, c_i_50, c_i_75, c_i_100];
for ([col, i] in i_colors) {
let cx_i = calc(chip_x0 + i * (chip_w + chip_gap));
let sw = PathLayer(`i-${i}`) ${ fill: col; stroke: fg_hair; stroke-width: 0.5; };
sw.apply { roundRect(cx_i, 455, chip_w, 42, 5.5); }
layer('strips').append(layer(`i-${i}`));
}
// Strip sub labels — small value markers at bottom of each strip
let strip_sub_layer = TextLayer('strip-sub') ${
font-family: font; font-size: 6;
letter-spacing: 0.5; fill: fg_muted; text-anchor: middle;
};
let l_subs = ['0.15', '0.35', '0.55', '0.75', '0.95'];
for ([s, i] in l_subs) {
let cx_i = calc(chip_x0 + i * (chip_w + chip_gap) + chip_w / 2);
strip_sub_layer.apply { text(cx_i, 428)`${s}` }
}
let i_subs = ['0%', '25%', '50%', '75%', '100%'];
for ([s, i] in i_subs) {
let cx_i = calc(chip_x0 + i * (chip_w + chip_gap) + chip_w / 2);
strip_sub_layer.apply { text(cx_i, 508)`${s}` }
}
// ─── Chord legend (bottom) ──────────────────────────────────
let legend_y = 535;
let legend = TextLayer('legend') ${
font-family: font; font-size: 6; font-weight: 700;
letter-spacing: 1.4; fill: fg_muted; text-anchor: start;
};
legend.apply { text(15, legend_y)`CHORDS: ANA TRI TET SPLIT-COMP — VERTICES SHOW LIVE BASE HUES` }
layer('strips').append(layer('strip-label'), layer('strip-sub'), layer('legend'));
layer('diagram').append(layer('wheel'), layer('chords'), layer('strips'));
Notice the palette rows. The lightness ramp uses CSS relative color syntax to override the l component at fixed steps:
fill="oklch(from var(--harmony-color) 0.35 c h)"
The interpolation row uses color-mix():
fill="color-mix(in oklch, var(--harmony-color), #457b9d 50%)"
Both are resolved by the browser at render time. Change the base color and five lightness steps and five interpolation steps recalculate instantly.
CSSVar: The Reactive Layer
The magic behind these live demos is CSSVar() — Pathogen's bridge between compile-time computation and runtime CSS.
let base = Color(CSSVar('--base-color', '#e63946'));
This does two things. At compile time, Color('#e63946') resolves to an OKLCH value for use in any computation that needs concrete color data. At render time, the SVG references var(--base-color, #e63946) — a CSS custom property with a fallback.
When you call methods on a CSSVar-backed Color, Pathogen emits CSS relative color expressions instead of baking the result:
let lighter = base.lighten(0.15);
// Compiled output: oklch(from var(--base-color, #e63946) calc(l + 0.15) c h)
let blended = base.mix(accent, 0.5);
// Compiled output: color-mix(in oklch, var(--base-color), var(--accent-color) 50%)
This is the key insight: compile once, theme at runtime. A Pathogen source file is compiled to a static SVG that contains no JavaScript. But because colors are expressed as CSS functions referencing custom properties, any container that sets those properties will see the SVG adapt.
The pattern works for entire illustrations. Define a few CSSVar-backed colors, derive everything from them, and the compiled SVG becomes a themeable asset:
let bg = Color(CSSVar('--bg', '#f5f5f5'));
let primary = Color(CSSVar('--primary', '#e63946'));
let secondary = Color(CSSVar('--secondary', '#457b9d'));
let accent = Color(CSSVar('--accent', '#2a9d8f'));
// Derived colors — all reactive
let primaryLight = primary.lighten(0.2);
let primaryDark = primary.darken(0.15);
let secondaryMuted = secondary.desaturate(0.5);
let accentShift = accent.hueShift(60);
define ViewBox(0, 0, 520, 640);
// Theme Demo — Combined: SYSTEM + STAGE
// Top half is the "theme system" — geometric composition showing how
// four CSS vars (bg, primary, secondary, accent) and five derived
// expressions relate spatially. Bottom half is the "theme stage" —
// presentational triptych of the three named colors as first-class
// design elements, with masthead below.
// ─── Core tokens ──────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let primary = Color(CSSVar('--primary', #e63946));
let secondary = Color(CSSVar('--secondary', #457b9d));
let accent = Color(CSSVar('--accent', #2a9d8f));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.60);
let fg_hair = Color('#0d1638').alpha(0.22);
let fg_faint = Color('#0d1638').alpha(0.10);
// Auto-contrasting ink against each theme color (for text on actor cards)
let primary_ink = Color('#0d1638');
let primary_ink_m = Color('#0d1638').alpha(0.70);
let secondary_ink = Color('#f0e8c8');
let secondary_ink_m = Color('#f0e8c8').alpha(0.70);
let accent_ink = Color('#f0e8c8');
let accent_ink_m = Color('#f0e8c8').alpha(0.70);
// Derived theme colors (shown in the system half + bottom spotlights)
let primary_lighter = Color(CSSVar('--primary', #e63946)).lighten(0.15);
let primary_darker = Color(CSSVar('--primary', #e63946)).darken(0.15);
let secondary_muted = Color(CSSVar('--secondary', #457b9d)).desaturate(0.5);
let accent_shifted = Color(CSSVar('--accent', #2a9d8f)).hueShift(60);
let accent_raw = Color(CSSVar('--accent', #2a9d8f));
let primary_raw = Color(CSSVar('--primary', #e63946));
let font = 'sans-serif';
// ─── Group layers ───────────────────────────────────────────
define GroupLayer('diagram') ${}
define GroupLayer('sys') ${}
define GroupLayer('seam') ${}
define GroupLayer('stage') ${}
// ─── Background ─────────────────────────────────────────────
let bg = PathLayer('bg') ${ fill: bg_color; stroke: none; };
bg.apply { rect(0, 0, 520, 640); }
// ═══════════════════════════════════════════════════════════
// TOP HALF — THEME SYSTEM (y 0..290)
// ═══════════════════════════════════════════════════════════
let sys_eyebrow = TextLayer('sys-eyebrow') ${
font-family: font; font-size: 8; font-weight: 700;
letter-spacing: 3; fill: fg_muted; text-anchor: start;
};
sys_eyebrow.apply { text(15, 26)`OKLCH / 03 — THEME SYSTEM` }
// ─── Accent linking arcs (central connective tissue) ──────
// Four Q curves from mid-sides to star tips. Raw --accent, 1.2 stroke.
let sys_arcs = PathLayer('sys-arcs') ${
fill: none; stroke: accent_raw; stroke-width: 1.2; stroke-linecap: round;
};
sys_arcs.apply {
M 100 160 Q 180 70 260 110
M 420 160 Q 340 70 260 110
M 100 160 Q 180 250 260 210
M 420 160 Q 340 250 260 210
}
// ─── Dashed fg_auto L=0.55 reference ring ─────────────────
let sys_ring = PathLayer('sys-ring') ${
fill: none; stroke: fg_auto; stroke-width: 1; stroke-dasharray: 3 4;
};
sys_ring.apply { circle(260, 160, 95); }
// ─── Accent halo disc behind the star ─────────────────────
let sys_halo = PathLayer('sys-halo') ${
fill: accent_raw; stroke: none; opacity: 0.28;
};
sys_halo.apply { circle(260, 160, 38); }
// ─── Accent tick marks at ring diagonals ──────────────────
// 4 short strokes pointing outward at ±45° from ring center.
// Inner point on ring (r=95), outer point on r=107.
let sys_ticks = PathLayer('sys-ticks') ${
fill: none; stroke: accent_raw; stroke-width: 1.5; stroke-linecap: round;
};
sys_ticks.apply {
M 327.2 92.9 L 335.7 84.4
M 327.2 227.1 L 335.7 235.6
M 192.8 227.1 L 184.3 235.6
M 192.8 92.9 L 184.3 84.4
}
// ─── 4 orbiting circles (desaturated secondary) on reference ring ──
let sys_orbit = PathLayer('sys-orbit') ${
fill: secondary_muted; stroke: fg_hair; stroke-width: 0.5;
};
sys_orbit.apply {
circle(260, 65, 11);
circle(355, 160, 11);
circle(260, 255, 11);
circle(165, 160, 11);
}
// ─── Central 5-point star (lightened primary) ──────────────
let sys_star = PathLayer('sys-star') ${
fill: primary_lighter; stroke: primary; stroke-width: 1; opacity: 0.95;
};
sys_star.apply { star(260, 160, 5, 50, 19); }
// ─── 3 corner diamonds (hue-shifted accent) ─────────────────
// Top-left, top-right, bottom-left. Bottom-right hosts the legend.
let sys_diamonds = PathLayer('sys-diamonds') ${
fill: accent_shifted; stroke: fg_hair; stroke-width: 0.5;
};
sys_diamonds.apply {
M 45 65 L 60 50 L 75 65 L 60 80 Z
M 445 65 L 460 50 L 475 65 L 460 80 Z
M 45 255 L 60 240 L 75 255 L 60 270 Z
}
// ─── Legend card (bottom-right of top half) ────────────────
let legend_x = 365;
let legend_y = 213;
let legend_w = 140;
let legend_h = 62;
let legend_bg = PathLayer('legend-bg') ${
fill: bg_color; stroke: fg_hair; stroke-width: 0.5;
};
legend_bg.apply { roundRect(legend_x, legend_y, legend_w, legend_h, 5.5); }
let legend_sw_layer = PathLayer('legend-sw') ${ fill: none; stroke: fg_hair; stroke-width: 0.5; };
let legend_name_layer = TextLayer('legend-name') ${
font-family: font; font-size: 7; font-weight: 700;
letter-spacing: 0.8; fill: fg_auto; text-anchor: start;
};
let legend_role_layer = TextLayer('legend-role') ${
font-family: font; font-size: 7;
letter-spacing: 0.5; fill: fg_muted; text-anchor: start;
};
// Legend rows
let legend_items = [
[bg_color, '--bg', 'backdrop'],
[primary, '--primary', 'star'],
[secondary, '--secondary', 'orbit'],
[accent, '--accent', 'arcs / halo / ticks'],
];
for ([item, i] in legend_items) {
let row_y = calc(legend_y + 10 + i * 12);
let sw_name = `legend-sw-${i}`;
let sw = PathLayer(sw_name) ${
fill: item[0]; stroke: fg_hair; stroke-width: 0.5;
};
sw.apply { roundRect(calc(legend_x + 8), calc(row_y - 4), 7, 7, 1.5); }
legend_name_layer.apply { text(calc(legend_x + 20), calc(row_y + 1.5))`${item[1]}` }
legend_role_layer.apply { text(calc(legend_x + 74), calc(row_y + 1.5))`${item[2]}` }
}
layer('sys').append(
layer('sys-eyebrow'),
layer('sys-arcs'), layer('sys-ring'), layer('sys-halo'), layer('sys-ticks'),
layer('sys-orbit'), layer('sys-star'), layer('sys-diamonds'),
layer('legend-bg'),
layer('legend-sw-0'), layer('legend-sw-1'), layer('legend-sw-2'), layer('legend-sw-3'),
layer('legend-name'), layer('legend-role')
);
// ═══════════════════════════════════════════════════════════
// SEAM — hairline divider + "SYSTEM" / "STAGE" chapter labels
// ═══════════════════════════════════════════════════════════
let seam_line = PathLayer('seam-line') ${ fill: none; stroke: fg_hair; stroke-width: 0.5; };
seam_line.apply { line(15, 290, 505, 290); }
let seam_top_label = TextLayer('seam-top-label') ${
font-family: font; font-size: 7; font-weight: 700;
letter-spacing: 3; fill: fg_muted; text-anchor: start;
};
seam_top_label.apply { text(15, 283)`SYSTEM` }
let seam_bot_label = TextLayer('seam-bot-label') ${
font-family: font; font-size: 7; font-weight: 700;
letter-spacing: 3; fill: fg_muted; text-anchor: end;
};
seam_bot_label.apply { text(505, 302)`STAGE` }
layer('seam').append(layer('seam-line'), layer('seam-top-label'), layer('seam-bot-label'));
// ═══════════════════════════════════════════════════════════
// BOTTOM HALF — THEME STAGE (y 310..640)
// ═══════════════════════════════════════════════════════════
let stage_eyebrow = TextLayer('stage-eyebrow') ${
font-family: font; font-size: 8; font-weight: 700;
letter-spacing: 3; fill: fg_muted; text-anchor: start;
};
stage_eyebrow.apply { text(15, 328)`OKLCH / 03 — THEME IN USE` }
// ─── Backdrop plate ───────────────────────────────────────
let stage_backdrop = PathLayer('stage-backdrop') ${
fill: fg_faint; stroke: fg_hair; stroke-width: 0.5;
};
stage_backdrop.apply { roundRect(15, 350, 490, 200, 5.5); }
// ─── Three actor cards ────────────────────────────────────
// Width 145, gap 12. Left 30, right 30. Cards at x = 30, 187, 344. y = 366..534 (168 tall).
let actors = [
['01 / PRIMARY', '--primary', 'dominant · action · focus', primary, primary_ink, primary_ink_m, 30],
['02 / SECONDARY', '--secondary', 'supporting · balance', secondary, secondary_ink, secondary_ink_m, 187],
['03 / ACCENT', '--accent', 'highlight · punctuate', accent, accent_ink, accent_ink_m, 344],
];
for ([a, i] in actors) {
let ax = a[6];
let actor_name = `actor-${i}`;
let actor = PathLayer(actor_name) ${ fill: a[3]; stroke: none; };
actor.apply { roundRect(ax, 366, 145, 168, 5.5); }
let role_layer = TextLayer(`actor-role-${i}`) ${
font-family: font; font-size: 6; font-weight: 700;
letter-spacing: 2; fill: a[5]; text-anchor: start;
};
role_layer.apply { text(calc(ax + 12), 386)`${a[0]}` }
let varname_layer = TextLayer(`actor-name-${i}`) ${
font-family: font; font-size: 14; font-weight: 700;
letter-spacing: 3; fill: a[4]; text-anchor: start;
};
varname_layer.apply { text(calc(ax + 12), 500)`${a[1]}` }
let desc_layer = TextLayer(`actor-desc-${i}`) ${
font-family: font; font-size: 7;
letter-spacing: 0.5; fill: a[5]; text-anchor: start;
};
desc_layer.apply { text(calc(ax + 12), 516)`${a[2]}` }
}
// ─── Spotlight halos (simplified — translucent filled discs) ───
// Real CSS radial-gradient doesn't translate to SVG fills; use
// low-opacity discs as approximation of the spotlight effect.
let spotlight_1 = PathLayer('spotlight-1') ${
fill: primary_lighter; stroke: none; opacity: 0.18;
};
spotlight_1.apply { circle(160, 392, 70); }
let spotlight_2 = PathLayer('spotlight-2') ${
fill: accent_shifted; stroke: none; opacity: 0.18;
};
spotlight_2.apply { circle(370, 430, 70); }
// ─── Bottom-left masthead ────────────────────────────────
let masthead_eyebrow = TextLayer('masthead-eyebrow') ${
font-family: font; font-size: 8; font-weight: 700;
letter-spacing: 3; fill: fg_muted; text-anchor: start;
};
masthead_eyebrow.apply { text(15, 574)`THEME / 03 — REACTIVE` }
let masthead_title = TextLayer('masthead-title') ${
font-family: font; font-size: 38; font-weight: 200;
letter-spacing: -1; fill: fg_auto; text-anchor: start;
};
masthead_title.apply { text(15, 612)`Color Theme` }
// ─── Bottom-right credits ────────────────────────────────
let credits = TextLayer('credits') ${
font-family: font; font-size: 6; font-weight: 700;
letter-spacing: 1.4; fill: fg_muted; text-anchor: end;
};
credits.apply {
text(505, 594)`4 CSS VARS`;
text(505, 608)`5 DERIVED`;
text(505, 622)`∞ THEMES`;
}
layer('stage').append(
layer('stage-eyebrow'),
layer('stage-backdrop'),
layer('spotlight-1'), layer('spotlight-2'),
layer('actor-0'), layer('actor-role-0'), layer('actor-name-0'), layer('actor-desc-0'),
layer('actor-1'), layer('actor-role-1'), layer('actor-name-1'), layer('actor-desc-1'),
layer('actor-2'), layer('actor-role-2'), layer('actor-name-2'), layer('actor-desc-2'),
layer('masthead-eyebrow'), layer('masthead-title'),
layer('credits')
);
layer('diagram').append(layer('bg'), layer('sys'), layer('seam'), layer('stage'));
This composition has two halves. The top half is a geometric system — a central star (primary), orbiting circles (secondary), corner diamonds plus linking arcs and a halo (accent), all sitting on a dashed reference ring. The bottom half is the same theme as a presentational triptych: primary, secondary, and accent as first-class design elements. The star fill uses oklch(from var(--primary) calc(l + 0.15) c h) for a lighter shade, the corner diamonds use oklch(from var(--accent) l c calc(h + 60)) for a hue-shifted variation, and each triptych card carries auto-contrasting ink against its own fill. One source file, infinite themes.
@property: Enabling Transitions
When using CSSVar-backed colors, the compiler automatically generates CSS @property declarations:
@property --base-color {
syntax: "<color>";
inherits: true;
initial-value: #e63946;
}
This tells the browser that --base-color is a color, not an arbitrary string. Without @property, CSS custom properties are opaque tokens — the browser can't interpolate between #e63946 and #457b9d because it doesn't know they're colors. With @property, CSS transitions and animations work on custom properties, meaning you can smoothly animate theme changes:
.svg-container {
transition: --primary 0.3s ease;
}
Pathogen collects these declarations during compilation and embeds them in the SVG's <style> block. Each Color(CSSVar('--name', fallback)) call registers one @property rule — first occurrence wins, duplicates are skipped.
Light/Dark: Adaptive SVGs
Modern CSS has light-dark(), a function that resolves to one of two values depending on the document's color scheme. Pathogen exposes this through Color.lightDark():
let fg = Color.lightDark(Color('#333'), Color('#eee'));
let accent = Color.lightDark(accent, darkAccent);
In the compiled SVG, the fill becomes:
fill="light-dark(#333333, #eeeeee)"
At compile time, properties like .hex and .lightness resolve against the light variant so your code can do concrete math. At render time, the browser picks the appropriate value based on the user's prefers-color-scheme setting. You can also combine lightDark() with CSSVar():
let themed = Color.lightDark(
Color(CSSVar('--fg-light', '#333')),
Color(CSSVar('--fg-dark', '#eee'))
);
// Output: light-dark(var(--fg-light, #333), var(--fg-dark, #eee))
This gives you theme-aware SVGs that respond to both system preferences and runtime CSS variable overrides — two axes of customization from a single compiled file.
The Full Picture
Everything comes together in a single composition that tests the whole system against two backgrounds at once. A ConicGradient carries the light→dark backdrop across the canvas; seven radialWedge chips span both plateaus, so every OKLCH manipulation method reads against both bg states in one view.
let bg_light = Color(CSSVar('--bg-light', '#d0d7f0'));
let bg_dark = Color(CSSVar('--bg-dark', '#12131a'));
let base = Color(CSSVar('--base-color', '#e63946'));
// Backdrop: light plateau (0–40%), 15% transition, dark plateau (55–100%)
let ld = ConicGradient('ld-bg', cx, cy) {|g|
g.stop(0, bg_light);
g.stop(0.4, bg_light);
g.stop(0.55, bg_dark);
g.stop(1, bg_dark);
};
ld.from = -50deg; ld.to = 50deg;
ld.interpolation = 'oklch';
// Seven derivations, each a radialWedge spanning both plateaus
let methods = [
base, base.lighten(0.18), base.darken(0.18),
base.saturate(1.5), base.desaturate(0.4),
base.hueShift(90), base.complement(),
];
define ViewBox(0, 0, 520, 520);
// Light/Dark — Conic (off-canvas center, fanned chips)
// Gradient origin sits at x = −260 (−50% of canvas width). The conic
// gradient runs from −90° (north of center = bg_light) to +90°
// (south of center = bg_dark) through the east axis. Seven narrow
// radialWedge chips with deeply rounded corners fan across the canvas.
// ─── Core tokens ──────────────────────────────────────────────
let bg_light = Color(CSSVar('--bg-light', #d0d7f0));
let bg_dark = Color(CSSVar('--bg-dark', #12131a));
let base_color = Color(CSSVar('--base-color', #e63946));
let ink_on_light = Color('#0d1638');
let ink_on_light_m = Color('#0d1638').alpha(0.60);
let ink_on_light_h = Color('#0d1638').alpha(0.22);
let ink_on_dark = Color('#f0e8c8');
let ink_on_dark_m = Color('#f0e8c8').alpha(0.60);
let font = 'sans-serif';
// ─── Seven method fills ──────────────────────────────────────
let m_base = Color(CSSVar('--base-color', #e63946));
let m_lighten = Color(CSSVar('--base-color', #e63946)).lighten(0.18);
let m_darken = Color(CSSVar('--base-color', #e63946)).darken(0.18);
let m_saturate = Color(CSSVar('--base-color', #e63946)).saturate(1.5);
let m_desaturate = Color(CSSVar('--base-color', #e63946)).desaturate(0.4);
let m_hue = Color(CSSVar('--base-color', #e63946)).hueShift(90);
let m_complement = Color(CSSVar('--base-color', #e63946)).hueShift(180);
// Auto-contrasting ink against each method color. Originally these
// computed `oklch(from oklch(from var(--base-color) ...) calc((0.5 - l)
// * 1000) 0 0)` — a chained auto-contrast on each derived swatch.
// Pathogen has no autoContrast() method on a derived Color, so each
// ink is hardcoded to match the canonical dark text against the
// fallback #e63946 swatches.
let ink_base = Color('#0d1638');
let ink_lighten = Color('#0d1638');
let ink_darken = Color('#f0e8c8');
let ink_saturate = Color('#0d1638');
let ink_desaturate = Color('#0d1638');
let ink_hue = Color('#0d1638');
let ink_complement = Color('#0d1638');
// ─── Group layers ───────────────────────────────────────────
define GroupLayer('diagram') ${}
define GroupLayer('chips') ${}
define GroupLayer('labels') ${}
// ─── Gradient origin (off canvas, 50% left of left edge) ──
let cx = -260;
let cy = 260;
// ─── Conic gradient: bg_light at top (north of cx,cy) to bg_dark at bottom ──
let ld_grad = ConicGradient('light-to-dark-bg', cx, cy) {|g|
g.stop(0, bg_light);
g.stop(0.4, bg_light);
g.stop(0.55, bg_dark);
g.stop(1, bg_dark);
};
ld_grad.from = -50deg;
ld_grad.to = 50deg;
ld_grad.innerRadius = 30;
ld_grad.innerFill = 'transparent';
ld_grad.spread = 'transparent';
ld_grad.interpolation = 'oklch';
// ─── Canvas fill ───────────────────────────────────────────
let bg = PathLayer('bg') ${ fill: ld_grad; stroke: none; };
bg.apply { rect(0, 0, 520, 520); }
// ─── Horizon line (at y = cy = 260, the gradient midpoint axis) ──
let horizon_line = PathLayer('horizon-line') ${
fill: none; stroke: ink_on_light_m; stroke-width: 0.5; stroke-dasharray: 3 4;
};
horizon_line.apply { line(0, 260, 520, 260); }
// ─── Chip parameters ──────────────────────────────────────
let chip_span = .08pi;
let corner_r = 20;
let chip_data = [
['chip-1', m_base, 280, 335],
['chip-2', m_lighten, 350, 405],
['chip-3', m_darken, 420, 475],
['chip-4', m_saturate, 490, 545],
['chip-5', m_desaturate, 560, 615],
['chip-6', m_hue, 630, 685],
['chip-7', m_complement, 700, 755],
];
for ([c, i] in chip_data) {
let cname = c[0];
let cfill = c[1];
let cin = c[2];
let cout = c[3];
let chip_layer = PathLayer(cname) ${ fill: cfill; stroke: none; };
chip_layer.apply {
M cx cy
radialWedge(cin, cout, -chip_span, chip_span, corner_r);
}
layer('chips').append(layer(cname));
}
// ─── Method labels along the horizon ──────────────────────
// Each label's fill is the auto-contrast ink against its chip's color,
// placed at the chip's midradius on east of center. No pill bg —
// text alone sits over the chip color.
let pill_data = [
[ink_base, 'BASE', 47.5 ],
[ink_lighten, 'LIGHTEN', 117.5],
[ink_darken, 'DARKEN', 187.5],
[ink_saturate, 'SATURATE', 257.5],
[ink_desaturate, 'DESAT.', 327.5],
[ink_hue, 'HUE +90°', 397.5],
[ink_complement, 'COMP.', 467.5],
];
for ([p, i] in pill_data) {
let pink = p[0];
let ptext = p[1];
let pcx = p[2];
let txt_name = `pt-${i}`;
let txt = TextLayer(txt_name) ${
font-family: font; font-size: 9; font-weight: 700;
letter-spacing: 2; fill: pink; text-anchor: middle;
};
txt.apply { text(pcx, 264)`${ptext}` }
layer('labels').append(layer(txt_name));
}
// ─── Masthead typography ─────────────────────────────────────
let eyebrow = TextLayer('eyebrow') ${
font-family: font; font-size: 8; font-weight: 700;
letter-spacing: 3; fill: ink_on_light_m; text-anchor: start;
};
eyebrow.apply { text(15, 26)`OKLCH / 04 — LIGHT ↔ DARK · CONIC` }
// "Light" display — top-left, reads against light end of gradient
let display_light = TextLayer('display-light') ${
font-family: font; font-size: 56; font-weight: 200;
letter-spacing: -2; fill: ink_on_light; text-anchor: start;
};
display_light.apply { text(15, 82)`Light` }
// "Dark" display — bottom-right, reads against dark end of gradient
let display_dark = TextLayer('display-dark') ${
font-family: font; font-size: 56; font-weight: 200;
letter-spacing: -2; fill: ink_on_dark; text-anchor: end;
};
display_dark.apply { text(505, 500)`Dark` }
// Horizon label — right side at horizon line
let horizon_lbl = TextLayer('horizon-lbl') ${
font-family: font; font-size: 7; font-weight: 700;
letter-spacing: 3; fill: ink_on_light_m; text-anchor: end;
};
horizon_lbl.apply { text(505, 254)`· HORIZON ·` }
// Zone labels — subtle corner markers
let zone_light = TextLayer('zone-light') ${
font-family: font; font-size: 6; font-weight: 700;
letter-spacing: 2; fill: ink_on_light_m; text-anchor: end;
};
zone_light.apply { text(505, 30)`TOP · −90° · --bg-light` }
let zone_dark = TextLayer('zone-dark') ${
font-family: font; font-size: 6; font-weight: 700;
letter-spacing: 2; fill: ink_on_dark_m; text-anchor: start;
};
zone_dark.apply { text(15, 510)`BOTTOM · +90° · --bg-dark` }
// Credit
let credit = TextLayer('credit') ${
font-family: font; font-size: 7; font-weight: 700;
letter-spacing: 2; fill: ink_on_dark_m; text-anchor: start;
};
credit.apply { text(15, 492)`ONE CHIP · FULL GRADIENT · ONE SOURCE` }
layer('labels').append(
layer('eyebrow'),
layer('display-light'), layer('display-dark'),
layer('horizon-lbl'), layer('zone-light'), layer('zone-dark'),
layer('credit')
);
layer('diagram').append(layer('bg'), layer('horizon-line'), layer('chips'), layer('labels'));
The gradient places each chip in a different angular slice of the bg. Chips near the top sit on the light plateau, chips near the bottom on the dark plateau, and every chip crosses the transition band at its middle — a single-color wedge visibly rendered against the full spectrum. Move the --base-color picker and every chip recomputes; move a bg picker and the whole gradient re-interpolates.
Three CSS variables. One gradient. Seven method derivations, each visible against the full light/dark spectrum. Compiled once, reactive forever.
What This Means
The traditional workflow for themeable SVG is painful: generate variants, swap files, or embed JavaScript to manipulate the DOM. Pathogen's approach eliminates all of that. You write color logic at a high level — harmonies, palettes, lightness ramps — and the compiler translates it into CSS that browsers already know how to execute.
The result is SVG illustration that participates in the web platform's theming infrastructure. Set CSS custom properties from your design system. Let prefers-color-scheme drive light and dark variants. Animate color transitions with CSS. No runtime JavaScript needed, no asset pipeline for variants.
SVG was always a dynamic format hiding behind static tooling. The Color system gives it the vocabulary to express what it was designed for.