Getting Started
svg-path-extended is a language that extends SVG path syntax with variables, expressions, control flow, and functions. It compiles to standard SVG path data that works in any browser or graphics application.
Your First Path
Try this simple example in the playground:
// A simple rectangle using variables
let size = 50
let x = 10
let y = 10
M x y
h size
v size
h calc(-size)
Z
This creates a rectangle by:
- Moving to position (10, 10)
- Drawing a horizontal line of length 50
- Drawing a vertical line of length 50
- Drawing a horizontal line back
- Closing the path
Why svg-path-extended?
SVG paths are powerful but writing them by hand is tedious:
// Standard SVG - repetitive coordinates
M 20 20 L 80 20 L 80 80 L 20 80 Z
M 100 20 L 160 20 L 160 80 L 100 80 Z
M 180 20 L 240 20 L 240 80 L 180 80 Z
With svg-path-extended, you can use variables and loops:
// svg-path-extended - DRY and readable
let size = 60
for (i in 0..3) {
rect(calc(20 + i * 80), 20, size, size)
}
Key Features
Variables
Store and reuse values:
let width = 200
let height = 100
let centerX = calc(width / 2)
Expressions with calc()
Use math in path commands:
let r = 50
M calc(100 - r) 100
L calc(100 + r) 100
Loops
Repeat patterns easily:
for (i in 0..10) {
circle(calc(20 + i * 30), 100, 10)
}
Functions
Define reusable shapes:
fn square(x, y, size) {
rect(x, y, size, size)
}
square(10, 10, 50)
square(70, 10, 50)
Built-in Shapes
Common shapes are included:
circle(100, 100, 50)
rect(10, 10, 80, 60)
polygon(100, 100, 40, 6) // hexagon
star(100, 100, 50, 25, 5)
Next Steps
- Syntax Reference - Learn all the language features
- Standard Library - Explore built-in functions
- Examples - See practical patterns and recipes
Syntax Reference
svg-path-extended is a superset of SVG path syntax that adds variables, expressions, control flow, functions, and path blocks.
Path Commands
All standard SVG path commands are supported:
| Command | Name | Parameters |
|---|---|---|
M / m |
Move to | x y |
L / l |
Line to | x y |
H / h |
Horizontal line | x |
V / v |
Vertical line | y |
C / c |
Cubic bezier | x1 y1 x2 y2 x y |
S / s |
Smooth cubic | x2 y2 x y |
Q / q |
Quadratic bezier | x1 y1 x y |
T / t |
Smooth quadratic | x y |
A / a |
Arc | rx ry rotation large-arc sweep x y |
Z / z |
Close path | (none) |
Uppercase commands use absolute coordinates; lowercase use relative coordinates.
M 0 0 L 100 100 Z
Variables
Declare variables with let:
let width = 200;
let height = 100;
let centerX = 100;
Use variables directly in path commands:
let x = 50;
let y = 75;
M x y L 100 100
Note: Single letters that are path commands (M, L, C, etc.) cannot be used as variable names.
Strings and Template Literals
String values use double quotes:
let name = "World";
Template literals use backticks with ${expression} interpolation:
let greeting = `Hello ${name}!`; // "Hello World!"
let msg = `Score: ${2 + 3}`; // "Score: 5"
let pos = `(${ctx.position.x}, ${ctx.position.y})`;
Template literals are the sole string construction mechanism — the + operator stays strictly numeric. String equality works with == and !=:
let mode = "dark";
if (mode == "dark") { /* ... */ }
if (mode != "light") { /* ... */ }
.length
Returns the number of characters in the string:
let str = `Hello`;
log(str.length); // 5
.empty()
Returns 1 (truthy) if the string has no characters, 0 (falsy) otherwise:
let str = ``;
if (str.empty()) {
// string is empty
}
Index Access
Access individual characters by zero-based index using [expr]:
let str = `Hello`;
let first = str[0]; // "H"
let last = str[4]; // "o"
Out-of-bounds access throws an error.
.split()
Splits a string into an array of individual characters:
let str = `abc`;
let chars = str.split(); // ["a", "b", "c"]
for (ch in chars) {
log(ch);
}
.append(value)
Returns a new string with the given value appended to the end:
let str = `Hello`;
let result = str.append(` World`); // "Hello World"
.prepend(value)
Returns a new string with the given value prepended to the beginning:
let str = `World`;
let result = str.prepend(`Hello `); // "Hello World"
.includes(substring)
Returns 1 (truthy) if the string contains the given substring, 0 (falsy) otherwise:
let str = `Hello World`;
if (str.includes(`World`)) {
// found it
}
.slice(start, end)
Returns a substring from start (inclusive) to end (exclusive). Negative indices count from the end:
let str = `Hello World`;
let sub = str.slice(0, 5); // "Hello"
let end = str.slice(6, 11); // "World"
let last3 = str.slice(-3, 11); // "rld"
Color Literals
Hex color codes and CSS color functions are first-class expressions:
let c = #cc0000; // 6-digit hex
let c = #f00; // 3-digit shorthand
let c = #cc000080; // 8-digit with alpha
let c = rgb(255, 0, 0); // CSS color function
let c = hsl(0, 100%, 50%); // % is literal inside parens
let c = oklch(0.6 0.15 30); // any CSS color space
let lighter = (#cc0000).lighten(20%); // method chaining via parens
See the Color documentation for full details.
Percent Suffix
The % suffix converts a number to a fraction: 50% becomes 0.5.
let half = 50%; // 0.5
let third = 33.3%; // 0.333
let c = (#ff0000).lighten(20%); // lighten by 0.2
Disambiguation: 20% (no space) is a percent literal (= 0.2). 20 % 5 (with spaces) is the modulus operator (= 0).
Expressions with calc()
For mathematical expressions, wrap them in calc():
let r = 50;
M calc(100 - r) 100
L calc(100 + r) 100
Supported Operators
| Operator | Description |
|---|---|
+ |
Addition |
- |
Subtraction |
* |
Multiplication |
/ |
Division |
% |
Modulo (use spaces: a % b) |
< |
Less than |
> |
Greater than |
<= |
Less than or equal |
>= |
Greater than or equal |
== |
Equal |
!= |
Not equal |
&& |
Logical AND |
|| |
Logical OR |
! |
Logical NOT (unary) |
- |
Negation (unary) |
<< |
Merge (objects, style blocks, path blocks, text blocks) |
Operator precedence follows standard mathematical conventions.
Style Blocks
Style blocks are CSS-like key-value maps wrapped in ${ }. They're used for layer styles but are also first-class values — you can store them in variables, merge them, and read their properties.
Literals
let styles = ${
stroke: #cc0000;
stroke-width: 3;
fill: none;
};
Each property is a name: value; declaration. Values are try-evaluated as expressions — if the value parses as a valid expression (like a variable reference or calc()), its result is used. Otherwise the raw string is kept (e.g., rgb(...), #hex).
Merge (<<)
The << operator merges two values of the same type. The right side overrides the left on key conflicts:
// Style blocks
let base = ${ stroke: red; stroke-width: 2; };
let merged = base << ${ stroke-width: 4; fill: blue; };
// Result: stroke: red, stroke-width: 4, fill: blue
// Objects
let a = { x: 1, y: 2 };
let b = a << { y: 99, z: 3 };
// Result: {x: 1, y: 99, z: 3}
Multiple merges can be chained: a << b << c. See also Objects — Merging.
Property Access
Use dot notation with camelCase names to read kebab-case properties:
let s = ${ stroke-width: 4; };
let sw = s.strokeWidth; // "4" (reads 'stroke-width')
Property values are always strings.
Usage in Layers
Style blocks are used in layer definitions and can be passed as per-element styles on text() and tspan(). See Layers for full details.
Null
The null literal represents the absence of a value. It is returned by pop() and shift() on empty arrays, and can be used in variable assignments and conditionals.
let x = null;
Truthiness
null is falsy in conditionals:
let x = null;
if (x) {
// not reached
} else {
M 0 0 // this branch runs
}
Equality
null is only equal to itself:
if (x == null) { /* x is null */ }
if (x != null) { /* x has a value */ }
null == 0 evaluates to 0 (false) — null is distinct from zero.
Error Behavior
Using null in arithmetic or as a path argument throws a descriptive error:
let x = null;
let y = x + 1; // Error: Cannot use null in arithmetic expression
M x 0 // Error: Cannot use null as a path argument
Booleans
The true and false keywords represent boolean values. They are a semantic subtype of number — true is 1, false is 0 — but display as true/false in logs and template literals.
let flag = true;
let check = false;
Numeric Equivalence
Booleans participate in arithmetic as their numeric values:
true + 1 // 2
true + true // 2
false + 1 // 1
true == 1 // true
false == 0 // true
Display
Booleans display as true or false, and comparisons return booleans:
log(true); // true
log(5 > 3); // true
log(1 > 5); // false
log(`${true}`); // true
Truthiness
false is falsy (like 0 and null); true is truthy:
if (true) { /* runs */ }
if (false) { /* skipped */ }
let result = 5 > 3; // true (BooleanValue)
if (result) { /* runs */ }
Logical Operators
!true // false
!false // true
true && false // false
false || true // true
Arc Flags
Booleans can be used directly as arc flag arguments, converting to 1/0 in the SVG output:
let largeArc = true;
let sweep = false;
M 0 0 A 50 50 0 largeArc sweep 100 0
// → M 0 0 A 50 50 0 1 0 100 0
Enums
Built-in Enums
Pathogen provides built-in enums for gradient and geometry properties. Enum members resolve to the string values accepted by these properties:
| Enum | Members |
|---|---|
Easing |
Linear, Smoothstep, EaseIn, EaseOut, EaseInOut |
Interpolation |
SRGB, OKLCH, LinearRGB |
SpreadMethod |
Pad, Reflect, Repeat |
GradientUnits |
ObjectBoundingBox, UserSpaceOnUse |
Direction |
CW, CCW |
ConicSpread |
Clamp, Repeat, Transparent |
InnerFill |
Transparent, TransparentBlend, Center |
TopoMethod |
Distance, Laplace |
topo.easing = Easing.Smoothstep; // equivalent to 'smoothstep'
grad.interpolation = Interpolation.OKLCH;
Enum values are interchangeable with their string equivalents:
Easing.Linear == 'linear' // true
User-Defined Enums
Define custom enums with enum:
// Auto-valued — member name lowercased to a string
enum Symmetry { None, Bilateral, Radial, Rotational }
log(Symmetry.Bilateral); // bilateral
// Explicit string values
enum Season { Spring = 'vernal', Summer = 'estival' }
// Explicit typed values — number, angle, color, boolean
enum Angle { Quarter = 90deg, Half = 180deg, Full = 360deg }
enum Palette { Primary = #0066ff, Accent = #ff6600, Muted = #999 }
enum Weight { Thin = 1, Normal = 2, Bold = 4 }
enum Toggle { On = true, Off = false }
Auto-valued members always produce the lowercase string of the member name. Other types require an explicit = value.
Enum members are accessed with dot notation and can be used in conditionals:
let d = Dir.Up;
if (d == 'up') { M 10 20 }
Points
Points represent 2D coordinates and provide geometric operations for SVG path construction.
Constructor
Create a point with Point(x, y):
let center = Point(200, 200);
let origin = Point(0, 0);
Properties
| Property | Returns | Description |
|---|---|---|
.x |
number | X coordinate |
.y |
number | Y coordinate |
let pt = Point(100, 200);
M pt.x pt.y // M 100 200
L calc(pt.x + 10) pt.y // L 110 200
Methods
All angles are in radians, consistent with the standard library.
.translate(dx, dy)
Returns a new point offset by the given deltas:
let pt = Point(100, 100);
let moved = pt.translate(10, -20); // Point(110, 80)
.polarTranslate(angle, distance)
Returns a new point offset by angle and distance:
let pt = Point(100, 100);
let moved = pt.polarTranslate(0, 50); // Point(150, 100)
let up = pt.polarTranslate(-0.5pi, 30); // 30 units upward
.midpoint(other)
Returns the midpoint between two points:
let a = Point(0, 0);
let b = Point(100, 100);
let mid = a.midpoint(b); // Point(50, 50)
.lerp(other, t)
Linear interpolation between two points. t=0 returns this point, t=1 returns the other:
let a = Point(0, 0);
let b = Point(100, 200);
let quarter = a.lerp(b, 0.25); // Point(25, 50)
.rotate(angle, origin)
Rotates this point around a center point:
let pt = Point(100, 0);
let center = Point(0, 0);
let rotated = pt.rotate(90deg, center); // Point(0, 100) approximately
.distanceTo(other)
Returns the Euclidean distance between two points:
let a = Point(0, 0);
let b = Point(3, 4);
log(a.distanceTo(b)); // 5
.angleTo(other)
Returns the angle in radians from this point to another:
let a = Point(0, 0);
let b = Point(1, 0);
log(a.angleTo(b)); // 0 (pointing right)
.offset(other)
Returns an object with dx and dy properties representing the vector from this point to other. Useful for applying the same relative displacement to multiple points:
let ref = Point(200, 200);
let target = Point(100, 300);
let off = ref.offset(target);
// off.dx = -100, off.dy = 100
// Apply the same offset to a different point
let other = Point(50, 75);
M calc(other.x + off.dx) calc(other.y + off.dy)
Display
log() shows points in a readable format:
let pt = Point(100, 200);
log(pt); // Point(100, 200)
Template Literals
Points display as Point(x, y) when interpolated in template literals:
let pt = Point(42, 99);
let msg = `position: ${pt}`; // "position: Point(42, 99)"
Arrays
Arrays hold ordered collections of values. Elements can be numbers, strings, style blocks, other arrays, or null.
Literals
let empty = [];
let nums = [1, 2, 3];
let mixed = [10, "hello", [4, 5]];
Index Access
Access elements by zero-based index using [expr]:
let list = [10, 20, 30];
let first = list[0]; // 10
let second = list[1]; // 20
M list[0] list[1] // M 10 20
Out-of-bounds access throws an error.
.length
Returns the number of elements:
let list = [1, 2, 3];
log(list.length); // 3
.empty()
Returns 1 (truthy) if the array has no elements, 0 (falsy) otherwise:
let list = [];
if (list.empty()) {
// list is empty
}
Methods
.push(value)
Appends a value to the end. Returns the new length.
let list = [1, 2];
let len = list.push(3); // list is now [1, 2, 3], len is 3
.pop()
Removes and returns the last element. Returns null if the array is empty.
let list = [1, 2, 3];
let last = list.pop(); // last is 3, list is now [1, 2]
let empty = [];
let x = empty.pop(); // x is null
.unshift(value)
Prepends a value to the start. Returns the new length.
let list = [2, 3];
list.unshift(1); // list is now [1, 2, 3]
.shift()
Removes and returns the first element. Returns null if the array is empty.
let list = [1, 2, 3];
let first = list.shift(); // first is 1, list is now [2, 3]
.slice(start, end?)
Returns a new array containing elements from start to end (inclusive). Negative indexes count from the end. If end is omitted, returns from start to the end of the array.
Note: Array
.slice()uses inclusive end indexes, while string.slice()uses exclusive end indexes (matching JavaScript string behavior).
let arr = [10, 20, 30, 40, 50];
let mid = arr.slice(1, 3); // [20, 30, 40] — indices 1, 2, 3
let tail = arr.slice(3); // [40, 50] — from index 3 to end
let last2 = arr.slice(-2); // [40, 50] — last 2 elements
let head = arr.slice(0, -2); // [10, 20, 30, 40] — up to second-to-last
.map {|item| ... } / .map {|item, index, arrayRef| ... }
Transforms each element using a trailing block, returning a new array. Use return to specify the mapped value. If no return is executed, the element maps to null.
The block receives up to three parameters:
item— the current elementindex(optional) — the zero-based indexarrayRef(optional) — a reference to the original array
let prices = [10, 25, 50];
let doubled = prices.map {|price|
return calc(price * 2);
};
// doubled is [20, 50, 100]
// Block body supports full language features
let labels = [1, 2, 3].map {|n|
let prefix = `item-`;
return `${prefix}${n}`;
};
// labels is ["item-1", "item-2", "item-3"]
Use the index parameter for position-aware transforms:
let items = [10, 20, 30];
let indexed = items.map {|val, i|
return calc(val + i);
};
// indexed is [10, 21, 32]
Use the array reference for look-ahead or look-behind:
let arr = [1, 2, 3, 4];
let pairs = arr.map {|item, idx, ref|
if (idx < ref.length - 1) {
return calc(item + ref[idx + 1]);
}
return item;
};
// pairs is [3, 5, 7, 4]
The block has access to variables from the enclosing scope:
let offset = 100;
let shifted = [1, 2, 3].map {|x|
return calc(x + offset);
};
// shifted is [101, 102, 103]
.reduce(initialValue) {|accumulator, item, index, arrayRef| ... }
Iterates the array, threading an accumulator through each step. The initialValue argument sets the starting accumulator. The block must return the new accumulator value; if no return is executed, the accumulator becomes null.
The block receives up to four parameters:
accumulator— the current accumulated valueitem(optional) — the current elementindex(optional) — the zero-based indexarrayRef(optional) — a reference to the original array
let sum = [1, 2, 3, 4].reduce(0) {|acc, n|
return calc(acc + n);
};
// sum is 10
let csv = ['a', 'b', 'c'].reduce('') {|acc, s, i|
if (i == 0) { return s; }
return `${acc},${s}`;
};
// csv is "a,b,c"
On an empty array, reduce returns initialValue unchanged:
let result = [].reduce(42) {|acc, n| return calc(acc + n); };
// result is 42
.mapSlice(length)
Returns a new array where each element is a sub-array (slice) of length elements starting at that element's index. Near the end of the array, slices are shorter as they extend past the bounds.
let arr = [1, 2, 3, 4];
let slices = arr.mapSlice(2);
// slices is [[1, 2], [2, 3], [3, 4], [4]]
let triples = [10, 20, 30, 40, 50].mapSlice(3);
// triples is [[10, 20, 30], [20, 30, 40], [30, 40, 50], [40, 50], [50]]
Reference Semantics
Arrays are passed by reference. Mutations through one binding are visible through all others:
let a = [1, 2, 3];
let b = a;
b.push(4);
log(a.length); // 4 — same underlying array
For-Each Iteration
Iterate over array elements with for (item in list):
let points = [10, 20, 30];
for (p in points) {
M p 0
}
// Produces: M 10 0 M 20 0 M 30 0
Destructure to get both item and index with for ([item, index] in list):
let sizes = [5, 10, 15];
for ([size, i] in sizes) {
circle(calc(i * 40 + 20), 50, size)
}
Iterating over an empty array produces no output.
Angle Units
Numbers can have angle unit suffixes for convenience:
| Suffix | Description |
|---|---|
45deg |
Degrees (converted to radians internally) |
1.5rad |
Radians (no conversion) |
0.25pi |
Multiplied by π (i.e. 0.25 * π) |
let angle = 90deg;
M sin(45deg) cos(45deg)
// Equivalent to:
let angle = rad(90);
M sin(rad(45)) cos(rad(45))
The pi suffix multiplies the number by π. This is especially convenient for polar coordinates and angles expressed as fractions of π:
let quarter = 0.25pi; // π/4
let half = 0.5pi; // π/2
let full = 2pi; // 2π
M sin(0.25pi) cos(0.25pi)
The pi suffix participates in angle unit mismatch checking: calc(0.25pi + 5) throws an error, while calc(90deg + 0.5pi) is allowed (both have angle units).
Note: The pi suffix only works on numeric literals. For expressions or variables, use mpi(x) (see Standard Library).
For Loops
Repeat path commands with for:
for (i in 0..10) {
L calc(i * 20) calc(i * 10)
}
The range 0..10 includes both endpoints (0 through 10, giving 11 iterations).
Descending Ranges
Ranges automatically count down when start > end:
// Countdown from 5 to 1
for (i in 5..1) {
M calc(i * 20) 0
}
// Produces: M 100 0 M 80 0 M 60 0 M 40 0 M 20 0
Nested Loops
for (row in 0..2) {
for (col in 0..2) {
circle(calc(col * 50 + 25), calc(row * 50 + 25), 10)
}
}
This creates a 3x3 grid (rows 0, 1, 2 and cols 0, 1, 2).
Conditionals
Use if, else if, and else for conditional path generation:
let size = 100;
if (size > 75) {
M 0 0 L 100 100
} else if (size > 50) {
M 0 0 L 75 75
} else {
M 0 0 L 50 50
}
You can chain as many else if blocks as needed. Comparison results are numeric: 1 for true, 0 for false.
Functions
Defining Functions
Create reusable path generators with fn:
fn square(x, y, size) {
rect(x, y, size, size)
}
Calling Functions
square(10, 10, 50)
square(70, 10, 50)
Functions can call other functions and use all language features.
Comments
Line comments start with //:
// This is a comment
let x = 50; // inline comment
M x 0
Path Context (ctx)
When using compileWithContext(), a ctx object tracks the current drawing state:
M 10 20
L 30 40
L calc(ctx.position.x + 10) ctx.position.y // L 40 40
ctx Properties
| Property | Type | Description |
|---|---|---|
ctx.position.x |
number | Current X coordinate |
ctx.position.y |
number | Current Y coordinate |
ctx.start.x |
number | Subpath start X (set by M, used by Z) |
ctx.start.y |
number | Subpath start Y |
ctx.commands |
array | History of executed commands |
How Position Updates
- M/m: Sets position and subpath start
- L/l, H/h, V/v: Updates position to endpoint
- C/c, S/s, Q/q, T/t: Updates position to curve endpoint
- A/a: Updates position to arc endpoint
- Z/z: Returns to subpath start
Lowercase (relative) commands add to current position; uppercase (absolute) set it directly.
log() Function
Use log() to inspect the context during evaluation:
M 10 20
log(ctx) // Logs full context as JSON
log(ctx.position) // Logs just position object
log(ctx.position.x) // Logs just the x value
L 30 40
The logs are captured in the logs array returned by compileWithContext().
Example: Drawing Relative to Current Position
M 100 100
L 150 150
// Continue from current position
L calc(ctx.position.x + 50) ctx.position.y
L ctx.position.x calc(ctx.position.y + 50)
Z
Complete Example
// Draw a grid of circles with varying sizes
let cols = 5;
let rows = 5;
let spacing = 40;
for (row in 0..rows) {
for (col in 0..cols) {
let x = calc(col * spacing + 20);
let y = calc(row * spacing + 20);
let r = calc(5 + col + row);
circle(x, y, r)
}
}
Standard Library Reference
svg-path-extended includes built-in functions for math operations and common SVG shapes.
Math Functions
Trigonometry
All trigonometric functions use radians.
| Function | Description |
|---|---|
sin(x) |
Sine |
cos(x) |
Cosine |
tan(x) |
Tangent |
asin(x) |
Arc sine |
acos(x) |
Arc cosine |
atan(x) |
Arc tangent |
atan2(y, x) |
Two-argument arc tangent |
// Draw a point on a circle
let angle = 0.5;
let r = 50;
M calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)
Angle Conversion
| Function | Description |
|---|---|
rad(degrees) |
Convert degrees to radians |
deg(radians) |
Convert radians to degrees |
// Use degrees instead of radians
let angle = rad(45);
M calc(cos(angle) * 50) calc(sin(angle) * 50)
Exponential & Logarithmic
| Function | Description |
|---|---|
exp(x) |
e raised to power x |
log(x) |
Natural logarithm |
log10(x) |
Base-10 logarithm |
log2(x) |
Base-2 logarithm |
pow(x, y) |
x raised to power y |
sqrt(x) |
Square root |
cbrt(x) |
Cube root |
Rounding
| Function | Description |
|---|---|
floor(x) |
Round down |
ceil(x) |
Round up |
round(x) |
Round to nearest integer |
trunc(x) |
Truncate decimal part |
Utility
| Function | Description |
|---|---|
abs(x) |
Absolute value |
sign(x) |
Sign (-1, 0, or 1) |
min(a, b, ...) |
Minimum value |
max(a, b, ...) |
Maximum value |
Interpolation & Clamping
| Function | Description |
|---|---|
lerp(a, b, t) |
Linear interpolation: a + (b - a) * t |
clamp(value, min, max) |
Constrain value to range |
map(value, inMin, inMax, outMin, outMax) |
Map value from one range to another |
// Interpolate between two positions
let t = 0.5;
M calc(lerp(0, 100, t)) calc(lerp(0, 50, t))
// Clamp a value
let x = clamp(150, 0, 100); // Result: 100
Constants
| Function | Returns |
|---|---|
PI() |
3.14159... |
E() |
2.71828... |
TAU() |
6.28318... (2π) |
mpi(x) |
x * π (multiply by π) |
// Draw a semicircle
let r = 50;
for (i in 0..20) {
let angle = calc(i / 20 * PI());
L calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)
}
Random
| Function | Description |
|---|---|
random() |
Random number between 0 and 1 |
randomRange(min, max) |
Random number in range |
Note: Random functions are not deterministic. Each call produces a different value.
Cycler
A Cycler wraps an array and cycles through it sequentially via .pick(), returning to the beginning after reaching the end. Useful for deterministic round-robin assignment of colors, layer names, styles, etc.
Cycler(array, shuffle?)
Creates a cycler from an array. If the optional shuffle argument is truthy, the array is shuffled once at construction (the shuffled order is stable across all cycles).
let c = Cycler(['red', 'green', 'blue']);
c.pick() // 'red'
c.pick() // 'green'
c.pick() // 'blue'
c.pick() // 'red' (wraps around)
// Shuffled cycler — stable order across wraps
let r = Cycler(['a', 'b', 'c'], true);
.pick()
Returns the next element in the cycle, advancing the internal index. Wraps around to the beginning after the last element.
.length
Returns the number of items in the cycler.
let c = Cycler([1, 2, 3]);
log(c.length); // 3
PolarVector
A PolarVector represents a direction and distance in polar coordinates. It is used to define bezier control point positions relative to anchor points — you specify "which direction and how far" rather than computing absolute x, y coordinates.
PolarVector(angle, distance)
Creates a polar vector. Angle is in radians (use rad() or deg suffix for degrees).
let pv = PolarVector(0.25 * PI(), 30);
let pv2 = PolarVector(rad(45), 30); // equivalent
.angle
Returns the angle in radians.
.distance
Returns the distance.
.turn(deltaAngle)
Returns a new PolarVector with the angle rotated by deltaAngle. Distance is unchanged.
let pv = PolarVector(0, 20);
let turned = pv.turn(0.5 * PI()); // angle is now π/2, distance still 20
.scale(factor)
Returns a new PolarVector with the distance multiplied by factor. Angle is unchanged.
let pv = PolarVector(0, 20);
let wider = pv.scale(1.5); // angle still 0, distance is now 30
.mirror()
Returns a new PolarVector with the angle rotated by π (180°). Distance is unchanged. This is the key operation for achieving C1 (smooth) continuity when chaining bezier curves — the outgoing handle mirrors the incoming handle.
let pv = PolarVector(0.25 * PI(), 20);
let mirrored = pv.mirror(); // angle is now 1.25π, distance still 20
Path Functions
These functions generate complete path segments.
circle(cx, cy, r)
Draws a circle centered at (cx, cy) with radius r.
circle(100, 100, 50)
Output: A full circle using two arc commands.
rect(x, y, width, height)
Draws a rectangle.
rect(10, 10, 80, 60)
roundRect(x, y, width, height, radius)
Draws a rectangle with rounded corners.
roundRect(10, 10, 80, 60, 10)
polygon(cx, cy, radius, sides)
Draws a regular polygon.
polygon(100, 100, 50, 6) // Hexagon
polygon(100, 100, 50, 8) // Octagon
star(cx, cy, outerRadius, innerRadius, points)
Draws a star shape.
star(100, 100, 50, 25, 5) // 5-pointed star
line(x1, y1, x2, y2)
Draws a line segment.
line(0, 0, 100, 100)
arc(rx, ry, rotation, largeArc, sweep, x, y)
Draws an arc to (x, y). This is a direct wrapper around the SVG A command.
M 50 100
arc(50, 50, 0, 1, 1, 150, 100)
quadratic(x1, y1, cx, cy, x2, y2)
Draws a quadratic bezier curve from (x1, y1) to (x2, y2) with control point (cx, cy).
quadratic(0, 100, 50, 0, 100, 100)
cubic(x1, y1, c1x, c1y, c2x, c2y, x2, y2)
Draws a cubic bezier curve.
cubic(0, 100, 25, 0, 75, 0, 100, 100)
polarCubicBezier(start, pv1, pv2, end)
Draws a cubic bezier curve where control points are defined as polar vectors relative to the start and end points. start and end are Point values; pv1 and pv2 are PolarVector values.
- pv1 — direction and distance from
startto the first control point - pv2 — direction and distance from
endto the second control point
let a = Point(0, 100);
let b = Point(100, 100);
polarCubicBezier(a, PolarVector(rad(-60), 40), PolarVector(rad(-120), 40), b)
Output: m (relative move) followed by c (relative cubic) — matches the spline function convention.
PolarVector methods compose naturally for handle manipulation:
let handle = PolarVector(rad(-45), 30);
// Symmetric curve: mirror the handle for the other end
polarCubicBezier(a, handle, handle.mirror(), b)
// Wider version: scale the handle distance
polarCubicBezier(a, handle.scale(1.5), handle.mirror().scale(1.5), b)
moveTo(x, y)
Returns a move command. Useful inside functions.
moveTo(50, 50)
lineTo(x, y)
Returns a line command.
lineTo(100, 100)
closePath()
Returns a close path command.
closePath()
cubicSpline(points)
Draws a chain of cubic bezier curves with explicit tangent angle and handle length at each point. Adjacent curves share a common tangent direction at join points, guaranteeing G1 (smooth) continuity.
Point schema:
| Property | Type | Description |
|---|---|---|
x |
number | X coordinate |
y |
number | Y coordinate |
angle |
number | Tangent angle (radians; use rad() or deg suffix for degrees) |
exit |
number | Distance from point along tangent to outgoing control point (omit on last point) |
entry |
number | Distance backward along tangent to incoming control point (omit on first point) |
cubicSpline([
{ x: 0, y: 100, angle: 0, exit: 30 },
{ x: 50, y: 0, angle: 0, entry: 20, exit: 25 },
{ x: 100, y: 100, angle: 0, entry: 30 }
])
Output: m (relative move) followed by one c (relative cubic) command per segment. A single-point array emits only m. All spline functions use relative commands so they work naturally inside path blocks.
quadSpline(start, points, end)
Draws a chain of quadratic bezier curves with implicit angle derivation. Only the start point specifies an explicit angle; intermediate points derive their tangent angle from the geometry of the previous control point.
Start: { x, y, angle, exit }
Intermediate: { x, y, exit }
End: { x, y }
quadSpline(
{ x: 0, y: 0, angle: 0, exit: 30 },
[{ x: 60, y: 0, exit: 30 }],
{ x: 120, y: 0 }
)
Output: m followed by one q (relative quadratic) command per segment.
clippedQuadSpline(start, points, end)
Extends quadSpline by splitting the implicit shared control point into two cubic control points using time-based fractions (exitTime/entryTime). This allows dampening curve eccentricity while preserving the quadratic geometry.
Start: { x, y, angle, exit, exitTime }
Intermediate: { x, y, exit, exitTime, entryTime }
End: { x, y, entryTime }
exitTime = 1,entryTime = 1: mathematically equivalent to quadraticexitTime = 0.5,entryTime = 0.5: control points at half arm length (moderate dampening)exitTime = 0,entryTime = 0: linear segments
clippedQuadSpline(
{ x: 0, y: 0, angle: 0, exit: 100, exitTime: 0.5 },
[],
{ x: 200, y: 0, entryTime: 0.5 }
)
Output: m followed by one c (relative cubic) command per segment — not q.
Grid Functions
These functions generate complete grid patterns as path segments. Each accepts a GridPatternType enum (or string) that controls the visual style:
| Pattern | Description |
|---|---|
GridPatternType.Shape ('shape') |
Cell outlines — full grid lines |
GridPatternType.Dot ('dot') |
Small circles at grid vertices |
GridPatternType.Intersection ('intersection') |
Small cross marks at grid vertices |
GridPatternType.Partial ('partial') |
Centered partial segments on each edge |
squareGrid(type, x, y, width, height, cellSize)
Generates a square grid pattern within the bounding rectangle starting at (x, y).
type—GridPatternTypeenum value or string ('shape','dot','intersection','partial')x, y— Top-left origin of the gridwidth, height— Bounding dimensionscellSize— Side length of each square cell
The grid contains floor(width / cellSize) columns and floor(height / cellSize) rows. Extra space is ignored.
gridLayer.apply {
squareGrid(GridPatternType.Shape, 0, 0, 200, 200, 20);
}
triangleGrid(type, x, y, width, height, cellSize)
Generates an equilateral triangle grid. cellSize is the triangle height (altitude). Triangles have flat bases with alternating up/down orientation.
gridLayer.apply {
triangleGrid(GridPatternType.Shape, 0, 0, 200, 200, 20);
}
The triangle side length is derived from the height: side = 2 * cellSize / sqrt(3).
hexagonGrid(type, x, y, width, height, cellSize, orientation?)
Generates a hexagonal grid. cellSize is the flat-to-flat height of each hexagon.
orientation— Optional.HexagonOrientation.Edge(default, flat-top) orHexagonOrientation.Vertex(pointy-top)
// Flat-top hexagons (default)
gridLayer.apply {
hexagonGrid(GridPatternType.Shape, 0, 0, 200, 200, 20);
}
// Pointy-top hexagons
gridLayer.apply {
hexagonGrid(GridPatternType.Shape, 0, 0, 200, 200, 20, HexagonOrientation.Vertex);
}
Usage with Layers and Transforms
Grid functions return path data and are typically used inside layer.apply {} blocks. Rotation and styling are handled via the layer:
let gridStyles = ${ stroke: #88f; stroke-width: 0.25; fill: none; };
let gridLayer = PathLayer('grid') << gridStyles;
gridLayer.ctx.transform.rotate.set(0.125pi);
gridLayer.apply {
squareGrid(GridPatternType.Partial, 0, 0, 400, 400, 20);
}
A convenience wrapper for one-line grid drawing:
fn drawGridToLayer(layer, gridFn, type, angle, x, y, w, h, s) {
layer.ctx.transform.rotate.set(angle);
layer.apply { gridFn(type, x, y, w, h, s); }
}
Context-Aware Functions
These functions use the current path context (position, tangent direction) to generate path segments. They maintain path continuity and are ideal for building complex shapes programmatically.
Polar Movement
polarPoint(angle, distance)
Returns a point at a polar offset from current position. Does not emit any path commands.
M 100 100
let p = polarPoint(0, 50);
L p.x p.y // Line to (150, 100)
polarOffset(angle, distance)
Returns {x, y} coordinates at a polar offset. Similar to polarPoint.
polarMove(angle, distance)
Emits a line command (L) moving in the specified direction. Updates position but draws a visible line.
M 100 100
polarMove(0, 50) // Draws line to (150, 100)
polarLine(angle, distance)
Emits a line command (L) in the specified direction. Same as polarMove.
M 100 100
polarLine(45deg, 70.7) // Draws line diagonally
Arc Functions
arcFromCenter(dcx, dcy, radius, startAngle, endAngle, clockwise)
Draws an arc defined by center offset and angles. Returns {point, angle} with endpoint and tangent.
dcx, dcy: Offset from current position to arc centerradius: Arc radiusstartAngle, endAngle: Start and end angles in radiansclockwise: 1 for clockwise, 0 for counter-clockwise
Warning: If current position doesn't match the calculated arc start point, a line segment (L) will be drawn to the arc start. For guaranteed continuous arcs, use arcFromPolarOffset.
M 50 50
arcFromCenter(50, 0, 50, 180deg, 270deg, 1)
// Center at (100, 50), arc from (50, 50) to (100, 100)
arcFromPolarOffset(angle, radius, angleOfArc)
Draws an arc where the center is at a polar offset from current position. The current position is guaranteed to be on the circle, so only an A command is emitted (no M or L). Returns {point, angle} with endpoint and tangent.
angle: Direction from current position to arc center (radians)radius: Arc radiusangleOfArc: Sweep angle (positive = clockwise, negative = counter-clockwise)
This function is ideal for creating continuous curved paths because it never emits extra line segments.
M 100 100
arcFromPolarOffset(0, 50, 90deg)
// Center at (150, 100), sweeps 90° clockwise
// Ends at (150, 50)
Comparison with arcFromCenter:
| Aspect | arcFromCenter | arcFromPolarOffset |
|---|---|---|
| Center defined by | Offset from current position | Polar direction from current position |
| Start point | Calculated from startAngle | Current position (guaranteed) |
| May emit L command | Yes, if position doesn't match | Never |
| Best for | Arcs with known center offset | Continuous curved paths |
Heading Control
Angles follow SVG coordinate conventions: 0 is rightward, positive angles rotate clockwise (toward the positive y-axis, which points down in SVG).
heading(angle)
Sets the heading to an absolute angle. No command is emitted and the cursor does not move. This enables tangentArc and tangentLine immediately after M without needing a dummy segment like h 0.01.
M 50 100
heading(0) // Set heading to rightward
tangentArc(20, 90deg) // Works immediately — no dummy segment needed
Inside path blocks, heading() avoids the offset artifacts that h 0.01 causes with z closePath:
let cLike = @{
heading(0)
tangentArc(20, 90deg)
tangentArc(20, -90deg)
z // Closes cleanly to start — no tiny offset
};
turn(delta)
Adds delta to the current heading (relative change). Requires an existing heading — either from heading() or from a previous drawing command. Negative deltas turn counter-clockwise.
M 50 100
heading(0) // Start heading rightward
turn(90deg) // Now heading downward
tangentLine(30) // Draws 30px down
After drawing commands:
M 0 0 L 50 0 // Heading is 0 (rightward)
turn(45deg) // Heading is now 45°
tangentLine(20) // Continues at 45°
ctx.heading
The current heading (read-only), readable via the context object. Set by heading(), turn(), or any drawing command that establishes direction. M (moveTo) clears the heading.
M 0 0 L 50 0
log(ctx.heading) // 0 (rightward)
heading(90deg)
log(ctx.heading) // π/2 (downward)
M 200 200
log(ctx.heading) // undefined (M clears the heading)
Tangent Functions
These functions continue from the current heading. Any path command that establishes a direction — including native SVG commands (L, H, V, C, S, Q, T, A, Z) and stdlib path functions — sets a heading that tangentLine and tangentArc can follow.
You can also set the heading explicitly with heading(), adjust it with turn(), or read it via ctx.heading.
M (moveTo) clears the heading since a move does not establish a direction.
tangentLine(length)
Draws a line continuing in the tangent direction from the previous command.
arcFromPolarOffset(0, 50, 90deg)
tangentLine(30) // Continues in the arc's exit direction
After native SVG commands:
M 50 100 L 150 100
tangentLine(30) // Continues rightward to (180, 100)
tangentArc(radius, sweepAngle)
Draws an arc continuing tangent to the previous command.
arcFromPolarOffset(0, 50, 90deg)
tangentArc(30, 45deg) // Smooth continuation with a smaller arc
After native SVG commands:
M 50 100 L 150 100
tangentArc(30, 90deg) // Smooth arc curving down from the line's endpoint
Color
The Color type provides first-class color manipulation in OKLCH color space. See the full Color documentation for constructor forms, methods, properties, and examples.
let c = Color('#e63946');
let lighter = c.lighten(0.2);
let comp = c.complement();
CSSVar
The CSSVar type creates CSS custom property references (var()) for use in style blocks. See the full CSSVar documentation for constructor forms, properties, and examples.
let fg = CSSVar('--foreground', '#333');
define PathLayer('main') ${ stroke: fg; }
Using Functions Inside calc()
Math functions can be used inside calc():
M calc(sin(0.5) * 100) calc(cos(0.5) * 100)
L calc(lerp(0, 100, 0.5)) calc(clamp(150, 0, 100))
Path functions are called at the statement level:
circle(100, 100, calc(25 + 25)) // calc() inside arguments
Layers
Layers let you output multiple <path> elements from a single program, each with its own styles and independent pen tracking.
Defining Layers
Use define to create a named layer with a style block:
define PathLayer('outline') ${
stroke: #cc0000;
stroke-width: 3;
fill: none;
}
Layer names must be unique strings. The style block uses CSS/SVG property syntax — any SVG presentation attribute works (stroke, fill, opacity, stroke-dasharray, etc.).
Breaking change: Style blocks now use
${ }syntax instead of{ }. Update existing layer definitions:{ stroke: red; }→${ stroke: red; }.
Default Layer
Mark one layer as default to receive all bare path commands (commands outside any layer().apply block):
define default PathLayer('main') ${
stroke: #333;
stroke-width: 2;
fill: none;
}
// These commands go to 'main' automatically
M 10 10
L 90 10
L 90 90
Z
Without a default layer, bare commands go to an implicit unnamed layer.
Writing to Layers
Use layer('name').apply { ... } to send commands to a specific layer:
define PathLayer('grid') ${
stroke: #ddd;
stroke-width: 0.5;
}
define default PathLayer('shape') ${
stroke: #333;
stroke-width: 2;
fill: none;
}
// Draw a grid on the 'grid' layer
layer('grid').apply {
for (i in 0..10) {
M calc(i * 20) 0
V 200
M 0 calc(i * 20)
H 200
}
}
// These go to 'shape' (the default)
M 40 40
L 160 40
L 100 160
Z
Context Isolation
Each layer has its own pen position. Commands in one layer don't affect another layer's ctx:
define default PathLayer('a') ${ stroke: red; }
define PathLayer('b') ${ stroke: blue; }
M 100 100 // layer 'a' position: (100, 100)
layer('b').apply {
M 50 50 // layer 'b' position: (50, 50)
}
// Back in layer 'a', position is still (100, 100)
L 200 200
Accessing Layer Context
Use layer('name').ctx to read a layer's pen state from anywhere:
define default PathLayer('main') ${ stroke: #333; }
define PathLayer('markers') ${ stroke: red; fill: red; }
M 50 50
L 150 80
L 100 150
// Draw markers at the main layer's current position
layer('markers').apply {
let px = layer('main').ctx.position.x
let py = layer('main').ctx.position.y
circle(px, py, 4)
}
Available context properties:
| Expression | Description |
|---|---|
layer('name').ctx.position.x |
Current X position |
layer('name').ctx.position.y |
Current Y position |
layer('name').ctx.start.x |
Subpath start X |
layer('name').ctx.start.y |
Subpath start Y |
layer('name').name |
Layer name string |
Dynamic Layer Names
Layer names can be expressions, including variables:
let target = 'overlay'
define PathLayer(target) ${ stroke: blue; }
layer(target).apply {
M 0 0 L 100 100
}
Dynamic Layer Creation
Layers can also be created as first-class values using PathLayer() and TextLayer() constructor expressions. This allows storing layers in variables, appending styles after creation, and using .apply { } directly on the variable.
Constructor Expression
let myLayer = PathLayer('unique-name') ${ stroke: red; fill: none; };
myLayer.apply { M 0 0 L 100 100 }
The style block is optional:
let myLayer = PathLayer('unique-name');
myLayer.apply { M 0 0 }
Style Mutation with <<
The << operator on a layer reference merges styles in place and returns the reference for chaining:
let l = PathLayer('outline');
l << ${ stroke: red; } << ${ fill: blue; };
l.apply { M 0 0 L 100 100 }
// l.styles: stroke: red, fill: blue
Explicit .styles Property
Read or replace a layer's styles via the .styles property:
let l = PathLayer('outline') ${ stroke: red; };
// Read: returns a StyleBlockValue copy
let s = l.styles;
log(s.stroke) // "red"
// Write: replaces all styles
l.styles = l.styles << ${ fill: blue; };
TextLayer Constructor
let labels = TextLayer('labels') ${ font-size: 14; font-family: monospace; };
labels.apply { text(50, 45)`Start` }
Accessing Layer Properties
Dynamic layers support the same properties as layer() references:
| Expression | Description |
|---|---|
myLayer.name |
Layer name string |
myLayer.ctx |
Path context (PathLayer only) |
myLayer.styles |
Style block (read/write) |
Coexistence with define
Both approaches work together. The define syntax supports the default modifier; dynamic constructors do not:
define default PathLayer('main') ${ stroke: #333; fill: none; }
let overlay = PathLayer('overlay') ${ stroke: red; };
M 10 10 L 90 90 // goes to 'main' (default)
overlay.apply { M 50 50 L 60 60 }
// layer() function works for both:
layer('overlay').apply { M 70 70 }
Layers render in definition order regardless of how they were created.
Style Properties
Style properties map directly to SVG presentation attributes. Common properties:
| Property | Example | Description |
|---|---|---|
stroke |
#cc0000 |
Stroke color |
stroke-width |
3 |
Stroke width |
stroke-linecap |
round |
Line cap style |
stroke-linejoin |
round |
Line join style |
stroke-dasharray |
4 2 |
Dash pattern |
stroke-dashoffset |
1 |
Dash offset |
stroke-opacity |
0.5 |
Stroke opacity |
fill |
none |
Fill color |
fill-opacity |
0.3 |
Fill opacity |
opacity |
0.8 |
Overall opacity |
Each property is a semicolon-terminated declaration:
define PathLayer('dashed') ${
stroke: #0066cc;
stroke-width: 2;
stroke-dasharray: 8 4;
fill: none;
}
Output Format
When using the JavaScript API, compile() returns a structured result:
import { compile } from 'svg-path-extended';
const result = compile(`
define default PathLayer('bg') ${
stroke: #ddd;
fill: none;
}
define PathLayer('fg') ${
stroke: #333;
stroke-width: 2;
fill: none;
}
M 0 0 H 100 V 100 H 0 Z
layer('fg').apply {
M 20 20 L 80 80
}
`);
// result.layers is an array of LayerOutput:
// [
// {
// name: 'bg',
// type: 'path',
// data: 'M 0 0 H 100 V 100 H 0 Z',
// styles: { stroke: '#ddd', fill: 'none' },
// isDefault: true
// },
// {
// name: 'fg',
// type: 'path',
// data: 'M 20 20 L 80 80',
// styles: { stroke: '#333', 'stroke-width': '2', fill: 'none' },
// isDefault: false
// }
// ]
Programs without any define statements produce a single implicit layer:
compile('M 0 0 L 100 100').layers
// [{ name: 'default', type: 'path', data: 'M 0 0 L 100 100', styles: {}, isDefault: true }]
Full Example
A multi-layer illustration with a background grid, main shape, and annotation markers:
// Layer definitions
define PathLayer('grid') ${
stroke: #e0e0e0;
stroke-width: 0.5;
}
define default PathLayer('shape') ${
stroke: #333333;
stroke-width: 2;
fill: none;
stroke-linejoin: round;
}
define PathLayer('points') ${
stroke: #cc0000;
fill: #cc0000;
}
// Grid
layer('grid').apply {
for (i in 0..10) {
M calc(i * 20) 0 V 200
M 0 calc(i * 20) H 200
}
}
// Shape (goes to default layer)
let cx = 100
let cy = 100
let r = 60
let sides = 6
for (i in 0..sides) {
let angle = calc(i * 360 / sides - 90)
let x = calc(cx + r * cos(radians(angle)))
let y = calc(cy + r * sin(radians(angle)))
if (i == 0) { M x y } else { L x y }
}
Z
// Mark each vertex
layer('points').apply {
for (i in 0..sides) {
let angle = calc(i * 360 / sides - 90)
let x = calc(cx + r * cos(radians(angle)))
let y = calc(cy + r * sin(radians(angle)))
circle(x, y, 3)
}
}
TextLayer
TextLayers produce SVG <text> elements instead of <path> elements.
Defining a TextLayer
define TextLayer('labels') ${
font-size: 14;
font-family: monospace;
fill: #333;
}
text() — Two Forms
Inline form — simple text content:
layer('labels').apply {
text(50, 45)`Start`
text(150, 75, 30deg)`End` // rotation uses angle units (deg/rad/pi)
}
Block form — mixed text runs and tspan children:
layer('labels').apply {
text(10, 180) {
`Hello `
tspan(0, 0, 30deg)`world`
` and more`
}
}
The block form maps to SVG's mixed content model:
<text x="10" y="180">Hello <tspan rotate="30">world</tspan> and more</text>
Note: 30deg in the source becomes rotate="30" (degrees) in SVG output.
tspan() — Only Inside text() Blocks
tspan()`content` // no offset
tspan(dx, dy)`content` // with offsets
tspan(dx, dy, 45deg)`content` // with offsets and rotation
Position arguments (x, y, dx, dy) are plain numbers. Rotation follows the standard angle unit convention — bare numbers are radians, use deg/rad/pi suffixes for explicit units. Content is always a template literal.
Template Literals
Template literals use backtick syntax with ${expression} interpolation. They work everywhere — text content, log messages, variable values:
let name = "World"
let x = `Hello ${name}!` // "Hello World!"
let msg = `Score: ${2 + 3}` // "Score: 5"
log(`Position: ${ctx.position.x}`) // in log messages
Template literals are the sole string construction mechanism — + stays strictly numeric. String equality (==/!=) works for conditionals:
let mode = "dark"
if (mode == "dark") { /* ... */ }
if (mode != "light") { /* ... */ }
TextLayer Output Format
const result = compile(`
define TextLayer('labels') ${ font-size: 14; fill: #333; }
layer('labels').apply {
text(50, 45)\`Start\`
text(10, 180) {
tspan()\`Multi-\`
tspan(0, 16)\`line\`
}
}
`);
// result.layers[0]:
// {
// name: 'labels',
// type: 'text',
// data: 'Start Multi-line',
// textElements: [
// { x: 50, y: 45, children: [{ type: 'run', text: 'Start' }] },
// { x: 10, y: 180, children: [
// { type: 'tspan', text: 'Multi-' },
// { type: 'tspan', text: 'line', dx: 0, dy: 16 },
// ]},
// ],
// styles: { 'font-size': '14', fill: '#333' },
// isDefault: false,
// }
Restrictions
text()can only be used inside alayer().applyblock targeting a TextLayertspan()can only appear inside atext() { }block- Path commands (
M,L, etc.) cannot be used inside a TextLayer apply block - If a TextLayer is the default layer, bare path commands will throw an error
Style Blocks
Style blocks are first-class values that can be stored in variables, merged, and accessed via dot notation.
Style Block Literals
let styles = ${
stroke-dasharray: 0.01 20;
stroke-linecap: round;
stroke-width: 8.4;
};
Merge Operator (<<)
The << operator merges two style blocks, with the right side overriding the left:
let base = ${ stroke: red; stroke-width: 2; };
let merged = base << ${ stroke-width: 4; fill: blue; };
// merged has: stroke: red, stroke-width: 4, fill: blue
Property Access
Use dot notation with camelCase to read kebab-case properties:
let styles = ${ stroke-width: 4; };
let sw = styles.strokeWidth; // reads 'stroke-width' → "4"
Expression Evaluation in Values
Style block values are try-evaluated: if a value parses and evaluates as an expression, its result is used. Otherwise the raw string is kept:
let dynamic = ${
font-size: calc(12 + 15); // evaluates to "27"
stroke-width: randomRange(2, 8); // evaluates to a random number
stroke: rgb(232, 74, 166); // kept as raw string
fill: #996633; // kept as raw string
};
Layer Definitions with Style Expressions
Layer definitions accept any expression that evaluates to a style block:
let baseStyles = ${ stroke: red; stroke-width: 2; };
define PathLayer('main') baseStyles << ${ fill: none; }
Per-Element Styles on Text and Tspan
Pass style blocks as the 4th argument to text() or tspan():
let bold = ${ font-weight: bold; };
layer('labels').apply {
text(10, 20, 0, bold)`Hello`
text(50, 80) {
tspan(0, 0, 0, ${ fill: red; })`colored`
}
}
Transforms
Apply SVG matrix transformations (translate, rotate, scale) at the layer level. Transforms are set via method calls on ctx.transform and rendered as SVG transform attributes on the output elements.
Translate
define PathLayer('shape') ${ stroke: #333; fill: none; }
layer('shape').ctx.transform.translate.set(50, 50)
layer('shape').apply {
M 0 0 L 100 0 L 100 100 Z
}
// Output: <path d="..." transform="translate(50, 50)"/>
Rotate
Angles are in radians (consistent with polar commands). Use deg suffix for degrees:
layer('shape').ctx.transform.rotate.set(45deg) // around origin
layer('shape').ctx.transform.rotate.set(45deg, 50, 50) // around (50, 50)
Scale
layer('shape').ctx.transform.scale.set(2, 2) // uniform scale
layer('shape').ctx.transform.scale.set(2, 2, 50, 50) // scale around (50, 50)
Reset
layer('shape').ctx.transform.translate.reset() // clear translate only
layer('shape').ctx.transform.rotate.reset() // clear rotate only
layer('shape').ctx.transform.scale.reset() // clear scale only
layer('shape').ctx.transform.reset() // clear all transforms
Read Access
layer('shape').ctx.transform.translate.x // 0 if not set
layer('shape').ctx.transform.translate.y
layer('shape').ctx.transform.rotate.angle // 0 if not set
layer('shape').ctx.transform.scale.x // 1 if not set (default scale)
layer('shape').ctx.transform.scale.y // 1 if not set
Default Context (No Layers)
When no layers are defined, use ctx.transform directly:
ctx.transform.translate.set(25, 25)
ctx.transform.rotate.set(45deg)
M 0 0 L 100 0
Inside Apply Blocks
Inside a layer().apply block, ctx refers to the active layer's context:
layer('shape').apply {
ctx.transform.translate.set(10, 20)
M 0 0 L 50 50
}
Combined Transforms
When multiple transforms are set, they are applied in SVG order: translate → rotate → scale (translate applied last visually):
layer('shape').ctx.transform.translate.set(10, 20)
layer('shape').ctx.transform.rotate.set(90deg)
layer('shape').ctx.transform.scale.set(2, 2)
// Output: transform="translate(10, 20) rotate(90) scale(2, 2)"
Transform Convenience Properties
Style blocks support individual transform properties as an alternative to transform: ... or the imperative API. These work on PathLayer, GroupLayer, and TextLayer:
define PathLayer('p') ${
translate-x: 50;
translate-y: 100;
scale-x: 2;
scale-y: 2;
rotate: 0.25pi;
}
// Output: transform="translate(50, 100) rotate(45) scale(2, 2)"
Shorthands for translate and scale accept comma-separated values:
define PathLayer('p') ${ translate: 50, 100; scale: 2, 3; }
// Output: transform="translate(50, 100) scale(2, 3)"
Single-value scale uses the same value for both axes:
define PathLayer('p') ${ scale: 2; }
// Output: transform="scale(2, 2)"
The rotate value is an expression in radians (angle units like deg and pi work normally):
define PathLayer('p') ${ rotate: 45deg; }
// Output: transform="rotate(45)"
Precedence: An explicit transform property overrides convenience properties. Convenience properties override imperative ctx.transform calls. The individual translate-x/translate-y properties override the translate shorthand (and similarly for scale).
Convenience properties are removed from the output styles — they only affect the transform attribute.
Per-Layer Isolation
Each layer has independent transforms — setting a transform on one layer does not affect others:
define PathLayer('a') ${ stroke: red; }
define PathLayer('b') ${ stroke: blue; }
layer('a').ctx.transform.translate.set(10, 10)
layer('b').ctx.transform.scale.set(2, 2)
// Layer 'a' gets translate(10, 10), layer 'b' gets scale(2, 2)
GroupLayer
GroupLayers map to SVG <g> elements and organize child layers via .append(). They support transforms through style blocks and the imperative ctx.transform API, but do not support apply blocks.
Definition
// Define a group with styles
let panel = GroupLayer('panel') ${ opacity: 0.8; };
// Or with define (cannot be default)
define GroupLayer('panel') ${ opacity: 0.8; }
GroupLayers cannot be the default layer — define default GroupLayer(...) is an error.
Adding Children with .append()
Use .append(ref1, ref2, ...) to add layers as children of a group. All arguments must be layer references:
let panel = GroupLayer('panel') ${};
let bg = PathLayer('bg') ${ fill: #eee; };
bg.apply { rect(0, 0, 200, 200) }
let label = TextLayer('label') ${ font-size: 14; fill: #333; };
label.apply { text(10, 20)`Panel Title` }
// Append children to group
panel.append(bg, label)
Output SVG:
<g>
<path d="..." fill="#eee" .../>
<text x="10" y="20" font-size="14" fill="#333">Panel Title</text>
</g>
Appended layers are removed from the top-level output and rendered inside the group.
Nesting Groups
Groups can contain other groups, up to a maximum nesting depth of 10:
let inner = GroupLayer('inner') ${};
let child = PathLayer('child') ${};
child.apply { M 5 5 }
inner.append(child)
let outer = GroupLayer('outer') ${};
outer.append(inner)
Transforms
GroupLayers support both style block transforms and imperative transforms:
// Style block transform
let panel = GroupLayer('panel') ${ transform: translate(50, 100); };
// Imperative transform
panel.ctx.transform.rotate.set(0.785)
panel.ctx.transform.scale.set(2, 2)
When both are present, the style block transform takes precedence.
Moving Layers Between Groups
Appending a layer that already belongs to another group moves it. A warning log is emitted:
let g1 = GroupLayer('g1') ${};
let g2 = GroupLayer('g2') ${};
let child = PathLayer('child') ${};
g1.append(child) // child is in g1
g2.append(child) // child moves to g2, warning logged
No Apply Blocks
GroupLayers do not support .apply blocks. Use .append() to add children:
// This is an error:
// g.apply { M 0 0 }
// Use .append() instead:
g.append(myPath)
Limitations
- No nesting apply blocks —
layer().applyblocks cannot be nested inside each other - Layer order — layers render in definition order (first defined = bottom)
- GroupLayer nesting — maximum depth of 10 levels
- PathLayer transforms only — transforms are currently available on PathLayers and GroupLayers via
ctx.transform; TextLayer transform support can be added later
Path Blocks
Path Blocks let you define reusable, introspectable paths without immediately drawing them. A PathBlock captures relative path commands and exposes metadata (length, vertices, endpoints) for positioning other elements relative to the path.
Syntax
let myPath = @{
v 20
h 30
v -20
};
@{ opens a Path Block, } closes it. The body contains relative path commands, control flow, variables, and function calls. The result is a PathBlock value — no path commands are emitted.
Drawing a Path Block
Use .draw() to emit the path's commands at the current cursor position:
let shape = @{ v 20 h 20 v -20 z };
M 10 10
shape.draw() // emits: v 20 h 20 v -20 z
M 50 50
shape.draw() // reuse at a different position
draw() advances the cursor to the path's endpoint and returns a ProjectedPath with absolute coordinates.
Assigning the draw result
let shape = @{ v 20 h 20 };
M 10 10
let proj = shape.draw();
// proj.startPoint = Point(10, 10)
// proj.endPoint = Point(30, 30)
Drawing at a specific position
Use .drawTo(x, y) to emit M x y followed by the path's commands in a single call. This combines positioning and drawing — no separate M command needed.
let shape = @{ v 20 h 20 v -20 z };
shape.drawTo(10, 10) // emits: M 10 10 v 20 h 20 v -20 z
shape.drawTo(50, 50) // reuse at a different position
drawTo() returns a ProjectedPath with absolute coordinates, just like draw():
let shape = @{ v 20 h 30 };
let proj = shape.drawTo(10, 10);
// proj.startPoint = Point(10, 10)
// proj.endPoint = Point(40, 30)
drawTo() also works on ProjectedPath values — it re-positions the projected path to the new origin:
let shape = @{ h 50 v 30 };
let proj = shape.project(0, 0);
proj.drawTo(100, 100) // emits: M 100 100 h 50 v 30
Projecting Without Drawing
Use .project(x, y) to compute absolute coordinates without emitting commands or moving the cursor:
let shape = @{ v 20 h 30 };
let proj = shape.project(10, 10);
// proj.startPoint = Point(10, 10)
// proj.endPoint = Point(40, 30)
// No path commands emitted, cursor unchanged
Properties
PathBlock
| Property | Type | Description |
|---|---|---|
length |
number |
Total arc-length of the path |
vertices |
Point[] |
Unique start/end points of each command segment |
subPathCount |
number |
Number of subpaths (separated by m commands) |
subPathCommands |
object[] |
Structured command list (see below) |
startPoint |
Point |
Always Point(0, 0) |
endPoint |
Point |
Final cursor position (relative to origin) |
ProjectedPath
Same properties as PathBlock but with absolute coordinates.
subPathCommands entries
Each entry in subPathCommands is an object with:
{
command: "v", // lowercase command letter
args: [20], // numeric arguments
start: Point(0, 0), // cursor before command
end: Point(0, 20) // cursor after command
}
Control Flow Inside Path Blocks
Variables, for loops, foreach loops, if statements, and function calls all work inside path blocks:
let zigzag = @{
for (i in 0..4) {
v 10
h calc(i % 2 == 0 ? 10 : -10)
}
};
Context-aware functions like arcFromPolarOffset, tangentLine, and tangentArc work against the block's temporary path context.
Accessing Outer Variables
Path blocks can read variables from enclosing scope:
let size = 20;
let box = @{ v size h size v calc(-size) z };
First-Class Values
PathBlocks can be passed as function arguments and returned from functions:
fn makeStep(dx, dy) {
return @{ h dx v dy };
}
let step = makeStep(10, 5);
M 0 0
step.draw() // emits: h 10 v 5
Using Path Metadata
Access path properties for layout calculations:
let segment = @{ v 20 h 30 };
// Use length to create a matching horizontal line
let total = segment.length; // 50
// Use endpoint for positioning
let end = segment.endPoint; // Point(30, 20)
M end.x end.y // Position at path endpoint
Restrictions
Path blocks enforce these rules at runtime:
- Relative commands only — All path commands must be lowercase (
m,l,h,v, etc.). Uppercase (absolute) commands throw an error. - No layer definitions —
define PathLayer/TextLayeris not allowed - No layer apply blocks —
layer().apply { }is not allowed - No text statements —
text()/tspan()are not allowed - No nesting — Path blocks cannot contain other
@{ }expressions - No draw/project inside blocks — Calling
.draw()or.project()inside a path block throws an error
Parametric Sampling
Parametric sampling lets you query points, tangent directions, and normal directions at any position along a path. The parameter t is a fraction from 0 (start) to 1 (end) measured by arc length.
These methods work on both PathBlock values and ProjectedPath values.
get(t) → Point
Returns the point at arc-length fraction t along the path.
let p = @{ v 50 h 100 };
let mid = p.get(0.5); // Point roughly at distance 75 along path
M mid.x mid.y // position at midpoint
tangent(t) → { point, angle }
Returns the point and tangent angle (radians) at fraction t. The angle is the direction of travel.
let p = @{ v 50 h 100 };
let tan = p.tangent(0.0);
log(tan.point); // Point(0, 0)
log(tan.angle); // ~1.5708 (π/2, pointing down)
normal(t) → { point, angle }
Returns the point and left-hand normal angle at fraction t. The normal angle equals the tangent angle minus π/2.
let p = @{ h 100 };
let n = p.normal(0.5);
log(n.point); // Point(50, 0)
log(n.angle); // ~-1.5708 (pointing up — left-hand normal of rightward path)
partition(n) → OrientedPoint[]
Divides the path into n equal-length segments, returning n + 1 oriented points (endpoints inclusive). Each oriented point has point, angle, and t properties.
| Property | Type | Description |
|---|---|---|
point |
Point |
Position at this sample |
angle |
number |
Tangent angle (radians) |
t |
number |
Arc-length fraction (i / n) |
let p = @{ h 100 };
let pts = p.partition(4); // 5 points at x = 0, 25, 50, 75, 100
for (op in pts) {
log(op.point.x, op.angle, op.t);
}
// t values: 0, 0.25, 0.5, 0.75, 1
Sampling on ProjectedPath
Projected paths return absolute coordinates:
let p = @{ h 100 };
let proj = p.project(10, 20);
let mid = proj.get(0.5); // Point(60, 20) — offset by projection origin
Curve Support
Sampling works on all command types including cubic/quadratic Bézier curves and arcs. Curves use arc-length parameterization so that t = 0.5 always represents the geometric midpoint, not the parametric midpoint.
Transforms
Transforms create new paths from existing ones — reversing direction, computing bounding boxes, and constructing parallel paths. These methods work on both PathBlock values and ProjectedPath values.
reverse() → PathBlock / ProjectedPath
Returns a new path with reversed direction of travel. The reversed path starts where the original ended and ends where the original started.
let p = @{ h 50 v 30 };
let r = p.reverse();
log(r.endPoint); // Point(-50, -30) — reversed from original
M 100 100
r.draw() // draws the path in reverse
Smooth commands (S/T) are automatically converted to their explicit forms (C/Q) before reversal. Closed paths (ending with z) preserve closure.
let closed = @{ h 30 v 30 h -30 z };
let rev = closed.reverse(); // reversed, still ends with z
boundingBox() → { x, y, width, height }
Returns the axis-aligned bounding box of the path. Accounts for Bézier curve extrema and arc extrema — not just endpoints.
let p = @{ c 0 -40 50 -40 50 0 };
let bb = p.boundingBox();
log(bb.y); // negative — curve extends above endpoints
log(bb.width, bb.height); // full extent of the curve
For a straight-line path the bounding box matches the endpoint coordinates:
let line = @{ h 100 };
let bb = line.boundingBox();
// bb = { x: 0, y: 0, width: 100, height: 0 }
intersects(geometry) → Boolean
AABB overlap test — returns true if this path's bounding box overlaps the argument's bounding box. Works on both PathBlock and ProjectedPath values.
Accepted arguments:
| Argument type | Comparison |
|---|---|
| PathBlock or ProjectedPath | Bounding box vs bounding box |
| ProjectedText | Path bbox vs text bbox |
{x, y, width, height} object |
Path bbox vs rectangle |
let a = @{ h 60 v 40 h -60 z };
let b = @{ h 40 v 30 h -40 z };
// Overlapping — both start at origin
let projA = a.project(0, 0);
let projB = b.project(10, 10);
log(projA.intersects(projB)); // true
// Non-overlapping
let projC = b.project(200, 200);
log(projA.intersects(projC)); // false
Testing against a rectangle object:
let shape = @{ h 50 v 50 h -50 z };
let proj = shape.project(10, 10);
log(proj.intersects({x: 0, y: 0, width: 100, height: 100})); // true
log(proj.intersects({x: 200, y: 200, width: 10, height: 10})); // false
Works on unprojected PathBlocks too (bounding box computed from relative coordinates):
let a = @{ h 60 v 40 h -60 z };
let b = @{ h 40 v 30 h -40 z };
log(a.intersects(b)); // true (both at origin)
intersectionPoints(geometry) → Array<Point>
Returns the intersection points between this path's bounding box edges and the geometry's line segments. Works on both PathBlock and ProjectedPath values.
Accepted arguments:
| Argument type | Returns |
|---|---|
| PathBlock or ProjectedPath | Points where bbox edges cross path segments |
| ProjectedText | Corners of the overlap rectangle (4 points), or empty array if no overlap |
let box = @{ h 100 v 100 h -100 z };
let line = @{ m -10 50 h 120 };
let projBox = box.project(0, 0);
let projLine = line.project(0, 0);
let pts = projBox.intersectionPoints(projLine);
// pts contains the points where the line crosses the box's bounding box edges
Non-overlapping paths return an empty array:
let a = @{ h 50 v 50 h -50 z };
let b = @{ h 10 v 10 h -10 z };
let projA = a.project(0, 0);
let projB = b.project(200, 200);
let pts = projA.intersectionPoints(projB);
log(pts.length); // 0
offset(distance) → PathBlock / ProjectedPath
Creates a parallel path offset by distance units. Positive values offset to the left of the travel direction, negative to the right.
let p = @{ h 60 v 40 };
let outer = p.offset(5); // 5 units left of travel
let inner = p.offset(-5); // 5 units right of travel
Offset preserves curve types — cubic Béziers produce offset cubics, arcs produce offset arcs with adjusted radii. Segment joins use miter joins with a limit of 4× the offset distance.
let curve = @{ c 0 -40 50 -40 50 0 };
let parallel = curve.offset(3);
M 0 50
curve.draw()
M 0 50
parallel.draw() // parallel curve 3 units to the left
mirror(angle) → PathBlock / ProjectedPath
Reflects the path across a line through the start point at the given angle. The angle uses standard language units (radians).
let p = @{ h 60 v 40 };
let m = p.mirror(0.5pi); // reflect across vertical axis → goes left
M 100 100
m.draw()
Common angles:
mirror(0)— horizontal axis (y → -y)mirror(0.5pi)— vertical axis (x → -x)mirror(0.25pi)— diagonal (swaps x and y)
Mirror preserves path length and curve types. Arc commands have their sweep flag flipped (reflection reverses chirality) and their rotation parameter adjusted.
let curve = @{ c 0 -40 50 -40 50 0 };
let flipped = curve.mirror(0);
M 0 50
curve.draw()
M 0 50
flipped.draw() // curve reflected below the axis
rotateAtVertexIndex(index, angle) → PathBlock / ProjectedPath
Rotates the path around the vertex at index (from the .vertices array) by angle radians. PathBlockValue results are normalized to (0, 0) start.
let p = @{ h 50 v 50 };
// p.vertices = [Point(0,0), Point(50,0), Point(50,50)]
let r = p.rotateAtVertexIndex(1, 0.5pi); // rotate around corner
M 10 10
r.draw()
The index must be a non-negative integer within range. The rotation preserves path length and curve types. Arc commands have their rotation parameter adjusted.
// Create a radial pattern by rotating around the first vertex
let arm = @{ h 50 v 10 };
for (i in 0..5) {
let angle = calc(i * 2 * 3.14159265358979 / 6);
let r = arm.rotateAtVertexIndex(0, angle);
M 100 100
r.draw()
}
scale(sx, sy) → PathBlock / ProjectedPath
Scales the path from its start point. sx scales x-coordinates, sy scales y-coordinates.
let p = @{ h 50 v 30 };
let doubled = p.scale(2, 2); // endPoint (100, 60)
let wide = p.scale(3, 1); // endPoint (150, 30)
let flipped = p.scale(-1, 1); // mirror across y-axis
Uniform scaling (sx == sy) preserves shape and scales arc radii proportionally. Non-uniform scaling (sx != sy) performs full ellipse eigendecomposition to compute new arc radii and rotation. Negative scale values flip the arc sweep flag (reflection reverses chirality).
let arc = @{ a 25 25 0 0 1 50 0 };
let wide = arc.scale(2, 1); // stretched elliptical arc
let big = arc.scale(3, 3); // uniform: radii tripled
subPath(startT, endT) → PathBlock
Extracts the geometric portion of a path between two arc-length fractions. Both startT and endT must be between 0 and 1. Always returns a PathBlock (normalized to (0, 0) origin), even when called on a ProjectedPath.
let p = @{ h 100 v 100 };
let first = p.subPath(0, 0.5); // first half of the path
let second = p.subPath(0.5, 1); // second half of the path
M 10 10
first.draw()
M 10 10
second.draw() // visually reconstructs the original
If startT > endT, the result is reversed (equivalent to .subPath(endT, startT).reverse()):
let p = @{ h 100 };
let rev = p.subPath(1, 0); // full path, reversed direction
Use .get() on the ProjectedPath to find the absolute position, then .draw() the extracted PathBlock:
let p = @{ h 100 v 50 };
let proj = p.project(10, 20);
let start = proj.get(0.2);
let sub = proj.subPath(0.2, 0.8); // PathBlock, normalized to (0,0)
M start.x start.y
sub.draw() // draws the middle 60% at the right position
Edge cases:
subPath(0, 1)returns approximately the original pathsubPath(t, t)returns an empty PathBlock (not an error)- Works with all command types including curves and arcs
let curve = @{ c 0 -40 50 -40 50 0 };
let front = curve.subPath(0, 0.5);
M 0 50
curve.draw()
M 0 80
front.draw() // first half of the Bézier curve
Transforms on ProjectedPath
Projected paths return results in absolute coordinates:
let p = @{ h 100 };
let proj = p.project(10, 20);
let bb = proj.boundingBox();
// bb.x = 10, bb.y = 20 — absolute coordinates
let rev = proj.reverse();
log(rev.startPoint); // Point(110, 20) — starts at original end
For mirror() on a ProjectedPath, the mirror line passes through the projection's start point. For rotateAtVertexIndex(), the rotation center is the absolute vertex position.
let p = @{ h 50 };
let proj = p.project(100, 100);
let m = proj.mirror(0.5pi);
// Mirrors across vertical line through (100, 100)
// startPoint stays at (100, 100), endPoint moves to (50, 100)
For scale() on a ProjectedPath, the scale center is the projection's start point:
let p = @{ h 50 v 30 };
let proj = p.project(10, 20);
let s = proj.scale(2, 2);
// startPoint stays at (10, 20), endPoint moves to (110, 80)
Concatenation (<<)
The << operator joins two PathBlocks end-to-end. The right path's relative commands continue from where the left path ends.
let a = @{ h 50 };
let b = @{ v 30 };
let c = calc(a << b); // endPoint (50, 30)
M 10 10
c.draw() // draws "h 50 v 30"
Chaining works naturally since << is left-associative and the result is a PathBlock:
let a = @{ h 50 };
let b = @{ v 30 };
let d = calc(a << b << a); // endPoint (100, 30)
Self-concatenation repeats the path:
let p = @{ h 50 };
let doubled = calc(p << p); // endPoint (100, 0)
Concatenated paths support all PathBlock methods — draw, project, sampling, and transforms:
let combined = calc(a << b);
let rev = combined.reverse();
let mid = combined.get(0.5);
The << operator also works for style block merging. The operand types must match — mixing PathBlocks and style blocks throws an error.
Chamfers
Chamfers cut corners by replacing a vertex with a straight line segment. The incoming and outgoing edges are trimmed by the specified distance, and a line connects the two trim points.
chamfer(distance) → PathBlock / ProjectedPath
Chamfers all corner vertices with equal distance on both sides:
let box = @{ h 60 v 40 h -60 z };
let chamfered = box.chamfer(8);
M 10 10
chamfered.draw()
chamfer(d1, d2) → PathBlock / ProjectedPath
Asymmetric chamfer — d1 is the trim distance on the incoming edge, d2 on the outgoing edge:
let box = @{ h 60 v 40 h -60 z };
let asym = box.chamfer(5, 15);
M 10 10
asym.draw()
chamferAtVertex(index, distance) → PathBlock / ProjectedPath
Chamfers a single vertex by index (from the .vertices array):
let box = @{ h 60 v 40 h -60 z };
// box.vertices: Point(0,0), Point(60,0), Point(60,40), Point(0,40)
let oneCorner = box.chamferAtVertex(1, 10);
M 10 10
oneCorner.draw()
chamferAtVertex(index, d1, d2) → PathBlock / ProjectedPath
Asymmetric chamfer at a single vertex:
let box = @{ h 60 v 40 h -60 z };
let asym = box.chamferAtVertex(2, 5, 15);
M 10 10
asym.draw()
Edge cases
If the chamfer distance exceeds the available edge length, it is clamped to the edge length and a warning is logged. If the vertex index is out of range, an error is thrown.
Chamfers work with all command types — lines, curves, and arcs. For curves, the trim operation uses arc-length parameterization to find the exact split point.
Fillets
Fillets round corners by replacing a vertex with a circular arc. The incoming and outgoing edges are trimmed, and an arc tangent to both edges is inserted.
Scope: Line-line junctions only. At curve junctions, the fillet is skipped and a warning is logged.
fillet(radius) → PathBlock / ProjectedPath
Fillets all corner vertices with the given radius:
let box = @{ h 60 v 40 h -60 z };
let rounded = box.fillet(8);
M 10 10
rounded.draw()
filletAtVertex(index, radius) → PathBlock / ProjectedPath
Fillets a single vertex:
let box = @{ h 60 v 40 h -60 z };
let oneRound = box.filletAtVertex(1, 12);
M 10 10
oneRound.draw()
If the radius is too large for the available edge length, it is clamped and a warning is logged. If the vertex index is out of range, an error is thrown.
Elliptical Fillets
Elliptical fillets replace a corner with an elliptical arc instead of a circular one, allowing for more expressive corner shapes.
Scope: Line-line junctions only (same as circular fillets).
ellipticalFillet(rx, ry) → PathBlock / ProjectedPath
Fillets all corners with an elliptical arc of radii rx and ry:
let box = @{ h 60 v 40 h -60 z };
let eFilleted = box.ellipticalFillet(12, 6);
M 10 10
eFilleted.draw()
ellipticalFillet(rx, ry, rotation) → PathBlock / ProjectedPath
Elliptical fillet with a rotated ellipse (rotation in radians, default 0):
let box = @{ h 60 v 40 h -60 z };
let rotated = box.ellipticalFillet(12, 6, 0.3);
M 10 10
rotated.draw()
ellipticalFilletAtVertex(index, rx, ry) → PathBlock / ProjectedPath
Elliptical fillet at a single vertex:
let box = @{ h 60 v 40 h -60 z };
let one = box.ellipticalFilletAtVertex(1, 15, 8);
M 10 10
one.draw()
ellipticalFilletAtVertex(index, rx, ry, rotation) → PathBlock / ProjectedPath
Elliptical fillet at a single vertex with rotation:
let box = @{ h 60 v 40 h -60 z };
let one = box.ellipticalFilletAtVertex(2, 15, 8, 0.5);
M 10 10
one.draw()
Boolean Operations
Boolean operations combine two closed paths using set operations. Both paths must be closed (end with z or have coincident start and end points). The result preserves original curve types — no linearization.
See also: Standard Library path functions for creating shapes to use with boolean operations.
union(other) → PathBlock
Combines two paths into their union (outer boundary):
let a = @{ circle(30) };
let b = @{ circle(30) };
let combined = a.project(50, 50).union(b.project(70, 50));
difference(other) → PathBlock
Subtracts other from the path:
let plate = @{ circle(40) };
let hole = @{ circle(15) };
let result = plate.project(50, 50).difference(hole.project(50, 50));
intersection(other) → PathBlock
Returns only the overlapping region:
let a = @{ circle(30) };
let b = @{ circle(30) };
let overlap = a.project(50, 50).intersection(b.project(70, 50));
xor(other) → PathBlock
Returns the symmetric difference — everything in either path but not both:
let a = @{ circle(30) };
let b = @{ circle(30) };
let exclusive = a.project(50, 50).xor(b.project(70, 50));
Requirements and behavior
- Both paths must be closed. Open paths throw an error.
- The
otherargument can be a PathBlock or ProjectedPath. - Multi-component results produce multiple subpaths (
M...z M...z). - All curve types (lines, cubics, quadratics, arcs) are preserved through the operation.
- Results are always returned as PathBlock values (normalized to
(0, 0)origin).
Font Integration
Font integration lets you convert text characters into PathBlock values — turning each glyph into vector paths you can draw, transform, sample, and combine with boolean operations.
@font Directive
The @font directive declares a font for use in the program. It must appear at the top level (not inside a function or block).
@font "Inter";
@font "Roboto Mono" 700;
@font "./fonts/CustomFont.ttf";
Syntax:
@font "family-or-path" [weight];
| Part | Required | Description |
|---|---|---|
| Source | Yes | Font family name (e.g., "Inter") or file path (e.g., "./fonts/Custom.ttf") |
| Weight | No | Numeric weight 100–900 (default: 400) |
Font loading by environment:
- CLI: Loads from local file paths (relative to source file) or searches system font directories (
/Library/Fonts,/System/Library/Fonts,~/Library/Fontson macOS; equivalent paths on Linux/Windows) - Playground: Fetches from Google Fonts CDN automatically
The directive is declarative metadata — the host environment loads fonts before compilation begins. If a font cannot be found, a warning is logged and compilation continues.
PathBlock.fromGlyph(text, styles)
Converts text into an array of PathBlock values — one per character. Each PathBlock contains the glyph's vector outline as relative path commands.
@font "Inter";
let glyphs = PathBlock.fromGlyph("A", ${ font-family: Inter; font-size: 48; });
M 50 100
glyphs[0].draw()
Arguments:
| Argument | Type | Description |
|---|---|---|
text |
string | Characters to convert (each becomes a separate PathBlock) |
styles |
style block | Must contain font-family; optionally font-size (default 16) and font-weight (default 400) |
Returns: Array of PathBlock values. Each element has all standard PathBlock properties and methods (draw(), project(), get(), tangent(), boundingBox(), scale(), boolean operations, etc.).
@font "Inter";
let styles = ${ font-family: Inter; font-size: 48; };
let glyphs = PathBlock.fromGlyph("Hi", styles);
log(glyphs.length); // 2
advanceWidth
Each glyph PathBlock has an .advanceWidth property — the horizontal distance to advance the cursor after drawing the glyph. This enables manual text layout:
@font "Inter";
let styles = ${ font-family: Inter; font-size: 48; };
let glyphs = PathBlock.fromGlyph("Hello", styles);
let x = 10;
let y = 100;
for (g in glyphs) {
M x y
g.draw()
let x = calc(x + g.advanceWidth);
}
Space characters return an empty PathBlock (no path commands) but still have a non-zero advanceWidth.
contours
Glyphs with multiple contours (e.g., "O" has an outer ring and inner hole) can be decomposed with the .contours property. This returns an array of PathBlock values, one per contour:
@font "Inter";
let styles = ${ font-family: Inter; font-size: 48; };
let glyphs = PathBlock.fromGlyph("O", styles);
let contours = glyphs[0].contours;
log(contours.length); // 2 (outer + inner)
for (c in contours) {
c.drawTo(100, 100)
}
Each contour is a closed PathBlock with all standard properties and methods.
Error cases
| Condition | Error message |
|---|---|
| Wrong number of arguments | PathBlock.fromGlyph() expects 2 arguments (text, styles) |
| First argument not a string | PathBlock.fromGlyph() first argument must be a string |
| Second argument not a style block | PathBlock.fromGlyph() second argument must be a style block |
| Style block missing font-family | PathBlock.fromGlyph() requires font-family in style block |
| No fonts loaded | PathBlock.fromGlyph() requires font data. Use @font directive to load a font. |
| Font not in registry | Font 'X' not found in font registry. Available fonts: [list] |
TextBlock
TextBlock is a composition-first text primitive that lets you compose, measure, and position text before drawing it. This is essential for diagrams and schematics where labels must be positioned relative to geometry without overlapping.
TextBlock parallels PathBlock: both follow the pattern compose -> measure -> position -> draw.
Quick Overview
// Compose text at relative coordinates
let label = &{
text(0, 14)`Title`
text(0, 30)`Subtitle`
} << ${ font-size: 14; fill: #333; };
// Measure before placing
let bb = label.boundingBox();
// Project into absolute coordinates
let placed = label.project(50, 100);
// Draw to a TextLayer
define TextLayer('labels') ${}
layer('labels').apply {
placed.draw();
}
Syntax
TextBlock uses the &{ } sigil:
let t = &{
text(x, y)`content`
text(x, y) {
tspan()`first`
tspan(0, 16)`second`
}
};
Inside a text block you can use:
text()statements — the core text elementslet,for,if— control flow for dynamic content- User-defined functions — called as expressions
Not allowed inside text blocks:
- Path commands (
M,L, etc.) - Layer definitions or apply blocks
- Nested text blocks
Types
TextBlockValue
Created by the &{ } expression. All coordinates are relative to origin (0, 0).
ProjectedTextValue
Created by .project(), .drawTo(), .polarProject(), or .translate(). Contains text elements with absolute coordinates and tracks the projection origin.
Methods
TextBlockValue
| Method | Returns | Description |
|---|---|---|
.project(x, y) |
ProjectedTextValue | Offset all elements to absolute coordinates |
.drawTo(x, y [, rotation]) |
ProjectedTextValue | Emit to active TextLayer at position |
.boundingBox() |
Object {x, y, width, height} |
Estimated bounding box |
.polarProject(px, py, angle, distance, anchor) |
ProjectedTextValue | Project along polar vector with anchor alignment |
.toPathBlock() |
PathBlockValue | Flatten glyph outlines into a single PathBlock (requires @font) |
.toCodeSnippetBlock(name [, fontSize, padding]) |
LayerReference | Generate a syntax-highlighted code snippet GroupLayer |
ProjectedTextValue
| Method | Returns | Description |
|---|---|---|
.draw() |
ProjectedTextValue | Emit to active TextLayer at projected position |
.drawTo(x, y [, rotation]) |
ProjectedTextValue | Re-project and emit at new position |
.translate(dx, dy) |
ProjectedTextValue | Return new value with shifted origin |
.boundingBox() |
Object {x, y, width, height} |
Estimated bounding box |
.paddedBoundingBox(blockPad, inlinePad) |
Object {x, y, width, height} |
Bbox expanded by padding |
.anchor(BBoxAnchor) |
PointValue | Point at named position on bbox |
.intersects(geometry) |
Boolean | AABB overlap test |
.intersectionPoints(geometry) |
Array<PointValue> | Intersection points between bbox and geometry |
Properties
TextBlockValue
| Property | Type | Description |
|---|---|---|
.elementCount |
number | Number of text elements |
.styles |
StyleBlockValue | Block-level styles |
ProjectedTextValue
| Property | Type | Description |
|---|---|---|
.elementCount |
number | Number of text elements |
.styles |
StyleBlockValue | Block-level styles |
.origin |
PointValue | Projection origin |
Style Merging
Use the << operator to merge styles into a TextBlock:
let t = &{ text(0, 16)`Hello` } << ${ font-size: 24; fill: #333; };
This sets block-level styles that apply to all elements unless overridden by element-level styles.
BBoxAnchor Enum
The BBoxAnchor enum provides named positions on a bounding box:
BBoxAnchor.TopLeft BBoxAnchor.Top BBoxAnchor.TopRight
BBoxAnchor.Left BBoxAnchor.Center BBoxAnchor.Right
BBoxAnchor.BottomLeft BBoxAnchor.Bottom BBoxAnchor.BottomRight
Used with .anchor() and .polarProject().
Font Metrics
TextBlock uses built-in character width tables for bounding box estimation:
- Sans-serif (default): per-character widths approximating Arial/Helvetica
- Serif: per-character widths approximating Times New Roman
- Monospace: uniform character width approximating Courier New
Set the font category via the font-family style property. Accuracy is ~85-90% for Latin text, sufficient for layout decisions.
Font metrics respect:
font-size(default 16)font-family(category detection)font-weight(bold applies ~6% width increase)letter-spacing- tspan
dx/dyoffsets
Polar Projection
Place text along a polar vector with anchor alignment:
let label = &{ text(0, 14)`Node A` } << ${ font-size: 14; };
// Place label 80px from center at 45 degrees, anchored at center-left
let placed = label.polarProject(100, 100, 45deg, 80, BBoxAnchor.Left);
The anchor determines which point of the text's bounding box is placed at the target location. For example, BBoxAnchor.Left means the left-center of the text bbox lands on the polar target point.
Intersection Detection
Check if text bounding boxes overlap to avoid label collisions:
let label1 = (&{ text(0, 14)`First` } << ${ font-size: 14; }).project(50, 50);
let label2 = (&{ text(0, 14)`Second` } << ${ font-size: 14; }).project(55, 55);
if (label1.intersects(label2)) {
// Labels overlap — adjust position
label2 = label2.translate(0, 20);
}
.intersects() accepts:
- Another
ProjectedTextValue(AABB overlap test) - A
ProjectedPathValue(bbox-edge vs path-segment intersection) - An object with
{x, y, width, height}(AABB overlap test)
Text to Path Conversion
When you need text that renders identically without requiring fonts — or when you want to apply path transforms and boolean operations to text — .toPathBlock() converts glyph outlines into vector geometry. After conversion, the text is no longer a text element: it's path geometry that can be filled, stroked, scaled, mirrored, and combined with boolean operations like any other PathBlock.
This is different from PathBlock.fromGlyph(), which returns an array of per-character PathBlocks. .toPathBlock() returns a single PathBlock containing all glyphs from the entire TextBlock, already laid out according to element positions, tspan offsets, and letter-spacing.
Requirements:
- Fonts must be loaded via
@fontdirective or compile options font-familymust be set in the TextBlock's styles- Only available on TextBlockValue (not ProjectedTextValue)
@font "./fonts/Baumans-Regular.ttf";
let tb = &{
text(0, 20)`Hello`
text(0, 40)`World`
} << ${ font-family: Baumans-Regular; font-size: 24; };
let pb = tb.toPathBlock();
define PathLayer('text-as-path') ${ fill: #333; stroke: none; }
layer('text-as-path').apply {
pb.drawTo(20, 20);
}
The resulting PathBlock is normalized to a (0, 0) origin, so .drawTo(x, y) places the text geometry at absolute coordinates (x, y). Space characters advance the cursor without generating outline commands.
Since the result is a standard PathBlock, you can chain any PathBlock operation:
// Scale the text geometry down to 60%
let small = pb.scale(0.6, 0.6);
// Mirror the text horizontally
let flipped = pb.mirror(0);
// Use text as a boolean punch — cut text out of a rectangle
let plate = @{ h 200 v 60 h -200 z }.project(10, 10);
let cutout = plate.difference(pb.project(20, 20));
Per-tspan style overrides (font-family, font-size) are respected, allowing mixed fonts within a single PathBlock output.
Code Snippet Blocks
For diagrams that need to show source code alongside visual output — tutorials, blog schematics, API documentation — .toCodeSnippetBlock() generates a self-contained code block as SVG layers with Pathogen-aware syntax highlighting.
.toCodeSnippetBlock(name [, fontSize, padding]) transforms a TextBlock containing code text into a styled GroupLayer.
Arguments:
name(string) — name for the GroupLayerfontSize(number, optional) — code font size, default 10padding(number, optional) — padding around code, default 12
Returns: LayerReference to a GroupLayer containing:
{name}-bg— PathLayer with dark background (#1e293b), border (#334155), and rounded corners{name}-code— TextLayer with per-token syntax-highlighted tspan elements
The name must not collide with existing layer names (including the -bg and -code suffixed names).
let code = &{
text(0, 0)`// Shape layer with styles
define PathLayer('main') \${ fill: #3b82f6; }
let shape = rect(0, 0, 80, 60);
layer('main').apply {
shape.drawTo(50, 50);
}`
};
let snippet = code.toCodeSnippetBlock('my-snippet', 10, 12);
snippet << ${ translate-x: 400; translate-y: 100; };
Escaping ${ in Code Text
Template literals in Pathogen treat ${ as a string interpolation sequence. If your code text contains literal ${ (e.g., style blocks), escape the dollar sign with a backslash:
// ✗ This fails — ${ triggers interpolation
let code = &{ text(0,0)`let s = ${ fill: red; }` };
// ✓ Escape the dollar sign
let code = &{ text(0,0)`let s = \${ fill: red; }` };
@{ and &{ do not need escaping — they pass through template literals as plain text. Only ${ requires the \$ escape.
Syntax Highlighting Palette
Keywords and builtins share the same color (#c084fc) — both represent language-level constructs.
| Token | Color | Examples |
|---|---|---|
| Keyword | #c084fc (purple) |
let, for, if, define, fn |
| Builtin | #c084fc (purple) |
PathLayer, Color, circle, log |
| Function | #f59e0b (amber) |
any identifier followed by ( |
| Number | #f59e0b (amber) |
42, 3.14, 45deg |
| String | #22c55e (green) |
`hello`, "world" |
| Comment | #64748b (slate) |
// comment |
| Operator | #94a3b8 (gray) |
=, <<, +, - |
| Punctuation | #64748b (slate) |
{ } ( ) ; , |
| Text | #e2e8f0 (light) |
identifiers, whitespace |
The code text is automatically normalized: common leading whitespace is removed (dedent), blank leading/trailing lines are trimmed, and tabs are converted to 2 spaces. Indentation within the code (for blocks, loops, conditionals) is preserved and rendered via x-coordinate offsets.
Examples
Label placement around a shape
define PathLayer('shape') ${ stroke: #333; fill: none; }
define TextLayer('labels') ${ font-size: 12; fill: #666; }
let shape = @{ l 80 0 l 0 60 l -80 0 z };
// Place labels at compass positions around the shape
let top = &{ text(0, 12)`Top` } << ${ font-size: 12; };
let right = &{ text(0, 12)`Right` } << ${ font-size: 12; };
layer('shape').apply { shape.drawTo(60, 70); }
layer('labels').apply {
top.polarProject(100, 100, -90deg, 50, BBoxAnchor.Bottom).draw();
right.polarProject(100, 100, 0, 60, BBoxAnchor.Left).draw();
}
Dynamic labels with collision avoidance
define TextLayer('labels') ${ font-size: 11; }
let points = [
{ x: 50, y: 50, name: "A" },
{ x: 55, y: 65, name: "B" },
{ x: 120, y: 50, name: "C" },
];
let placed = [];
layer('labels').apply {
for (pt in points) {
let label = &{ text(0, 11)`${pt.name}` } << ${ font-size: 11; };
let proj = label.project(pt.x + 5, pt.y);
// Check against all previously placed labels
let ok = true;
for (prev in placed) {
if (proj.intersects(prev)) {
ok = false;
}
}
if (ok) {
proj.draw();
placed.push(proj);
} else {
// Try below instead
let alt = label.project(pt.x + 5, calc(pt.y + 15));
alt.draw();
placed.push(alt);
}
}
}
Color Type
The Color type provides first-class color manipulation in OKLCH color space. Colors are resolved at compile time to concrete CSS values.
Color Literals
Hex color codes are first-class expressions — no quotes or Color() wrapper needed:
let c = #cc0000; // 6-digit hex → ColorValue
let c = #f00; // 3-digit shorthand
let c = #cc000080; // 8-digit with alpha
let c = #f008; // 4-digit with alpha
Color literals support method chaining via parentheses:
let lighter = (#cc0000).lighten(20%); // 20% → 0.2
let faded = (#0066ff).alpha(50%); // 50% → 0.5
Color() accepts color literals as pass-through (no-op for backwards compatibility):
let c = Color(#cc0000); // same as: let c = #cc0000;
CSS Color Function Literals
CSS color functions are first-class expressions with raw capture (content between parens is captured as-is):
let c = rgb(255, 0, 0);
let c = rgba(255, 0, 0, 0.5);
let c = hsl(0, 100%, 50%); // % inside parens is literal
let c = hsla(0, 100%, 50%, 0.5);
let c = oklch(0.6 0.15 30);
let c = oklch(0.6 0.15 30 / 0.5); // / for alpha is literal
let c = oklab(0.6 -0.1 0.15);
let c = hwb(0 0% 0%);
let c = lab(50 40 59.5);
let c = lch(50 64 30);
Method chaining works directly:
let lighter = rgb(255, 0, 0).lighten(20%);
Note: CSS color function names (
rgb,hsl,oklch, etc.) are effectively reserved — they always produce color literals, even if a user-defined function of the same name exists.
Constructor
The Color() wrapper is still available for string-based construction and named colors:
let c = Color('#e63946'); // hex (3, 6, or 8 digit)
let c = Color('red'); // named CSS color (all 148)
let c = Color('rgb(255, 0, 0)'); // rgb/rgba
let c = Color('hsl(0, 100%, 50%)'); // hsl/hsla
let c = Color('oklch(0.6 0.15 30)'); // oklch
let c = Color(0.6, 0.15, 30); // direct OKLCH (L, C, H)
let c = Color(0.6, 0.15, 30, 0.5); // OKLCH + alpha
let c = Color(#cc0000); // pass-through (accepts ColorValue)
All input formats are converted to OKLCH internally for perceptually uniform manipulation.
Properties
Read-only properties for inspecting color values:
| Property | Type | Description |
|---|---|---|
.css |
string | Hex if opaque, rgba() if transparent |
.hex |
string | #rrggbb (ignores alpha) |
.oklch |
string | oklch(L C H) or oklch(L C H / a) |
.hsl |
string | hsl(H, S%, L%) |
.rgb |
string | rgb(R, G, B) |
.lightness |
number | OKLCH lightness (0–1) |
.chroma |
number | OKLCH chroma (0–~0.4) |
.hue |
number | OKLCH hue (0–360) |
.a |
number | Alpha (0–1) |
let c = Color('#e63946');
log(c.hex); // #e63946
log(c.lightness); // ~0.52
log(c.hue); // ~27
log(c.a); // 1
Methods
All methods return a new Color — they never mutate the original.
Lightness
let c = Color('#e63946');
let lighter = c.lighten(0.2); // increase L by 0.2
let darker = c.darken(0.15); // decrease L by 0.15
log(lighter.hex); // lighter red
log(darker.hex); // darker red
Saturation
let c = Color('#e63946');
let vivid = c.saturate(1.5); // multiply chroma by 1.5
let muted = c.desaturate(0.5); // multiply chroma by 0.5
Alpha
let c = Color('#e63946');
let semi = c.alpha(0.5);
log(semi.css); // rgba(230, 57, 70, 0.5)
Hue
let c = Color('#e63946');
let shifted = c.hueShift(180); // shift hue by 180°
let comp = c.complement(); // shorthand for hueShift(180)
Mixing
Mix two colors in OKLCH space:
let a = Color('#e63946');
let b = Color('#457b9d');
let mid = a.mix(b, 0.5); // 50/50 mix
let mostly_a = a.mix(b, 0.2); // 80% a, 20% b
Method Chaining
Methods return new Colors, so they chain naturally:
let c = Color('#e63946')
.lighten(0.1)
.desaturate(0.8)
.alpha(0.9);
Color Harmonies
Generate sets of harmonious colors based on color theory. All harmony methods return an array of Colors, preserving lightness, chroma, and alpha.
.analogous(angle?)
Returns 3 colors: [hue - angle, self, hue + angle]. Default angle: 30.
let c = Color('#e63946');
let colors = c.analogous(); // 3 colors at -30°, 0°, +30°
let wide = c.analogous(45); // wider spread at ±45°
.triadic()
Returns 3 colors evenly spaced at 120° intervals: [self, hue + 120, hue + 240].
let c = Color('#e63946');
let colors = c.triadic();
.tetradic()
Returns 4 colors evenly spaced at 90° intervals: [self, hue + 90, hue + 180, hue + 270].
let c = Color('#e63946');
let colors = c.tetradic();
.splitComplementary(angle?)
Returns 3 colors: [self, hue + 180 - angle, hue + 180 + angle]. Default angle: 30.
let c = Color('#e63946');
let colors = c.splitComplementary(); // flanks of complement at ±30°
let narrow = c.splitComplementary(15); // tighter split
Using Harmonies
Harmony methods return arrays, so use for-each to iterate:
let c = Color('#e63946');
for ([color, i] in c.triadic()) {
define PathLayer(`p${i}`) ${ fill: color; stroke: none; }
layer(`p${i}`).apply { circle(calc(50 + i * 60), 100, 25) }
}
Complete Example
A full color swatch showcase demonstrating base methods, harmonies, palettes, and derived colors across multiple tiers. Uses CSSVar-backed Colors so that changing --base-color or --accent-color in the playground's CSS var panel reactively updates every swatch. Connecting lines show how colors flow from a single base color through transformations.
Canvas: 600 × 700 viewBox with four sections flowing top-to-bottom.
// ═══════════════════════════════════════════════════════════
// Color Swatch Showcase — full demo of Color manipulation,
// harmonies, palettes, and derived colors
// ═══════════════════════════════════════════════════════════
let base = Color(CSSVar('--base-color', '#e63946'));
let accent = Color(CSSVar('--accent-color', '#457b9d'));
// ── Tier 0: Base Methods ──────────────────────────────────
let lighter = base.lighten(0.15);
let darker = base.darken(0.15);
let vivid = base.saturate(1.4);
let muted = base.desaturate(0.5);
let shifted = base.hueShift(60);
let comp = base.complement();
let semi = base.alpha(0.6);
let mixed = base.mix(accent, 0.5);
// ── Tier 1: Harmonies ────────────────────────────────────
let analog = base.analogous();
let triad = base.triadic();
let tetrad = base.tetradic();
let split = base.splitComplementary();
// ── Tier 1b: Palettes ────────────────────────────────────
let ramp = Color.palette(base, 5);
let interp = Color.palette(base, accent, 5);
// ── Tier 2: Derived Colors ───────────────────────────────
let tri1 = triad[1];
let tri1Light = tri1.lighten(0.15);
let tri1Dark = tri1.darken(0.15);
let tri1Vivid = tri1.saturate(1.4);
let rampMid = ramp[2];
let rampShifted = rampMid.hueShift(60);
let rampComp = rampMid.complement();
let rampAlpha = rampMid.alpha(0.5);
// ═══════════════════════════════════════════════════════════
// Layers
// ═══════════════════════════════════════════════════════════
define PathLayer('connectors') ${
stroke: #999;
stroke-width: 1;
fill: none;
}
define TextLayer('section-labels') ${
font-family: system-ui, sans-serif;
font-size: 13;
font-weight: bold;
fill: #555;
}
define TextLayer('labels') ${
font-family: system-ui, sans-serif;
font-size: 9;
fill: #777;
text-anchor: middle;
}
// ── Swatch sizing ────────────────────────────────────────
let sx = 64;
let sp = 96;
let sw = 36;
let sh = 36;
let sr = 6;
// ═══════════════════════════════════════════════════════════
// Tier 0: Base Method Swatches
// ═══════════════════════════════════════════════════════════
// Row 1: base, lighten, darken, saturate, desaturate
let row1 = [base, lighter, darker, vivid, muted];
let row1names = ['base', 'lighten', 'darken', 'saturate', 'desat'];
for ([color, i] in row1) {
let x = calc(sx + i * sp);
define PathLayer(`t0r1_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`t0r1_${i}`).apply { roundRect(calc(x - sw / 2), calc(50 - sh / 2), sw, sh, sr) }
}
// Row 2: hueShift, complement, alpha, mix, accent
let row2 = [shifted, comp, semi, mixed, accent];
let row2names = ['hueShift', 'compl.', 'alpha', 'mix', 'accent'];
for ([color, i] in row2) {
let x = calc(sx + i * sp);
define PathLayer(`t0r2_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`t0r2_${i}`).apply { roundRect(calc(x - sw / 2), calc(115 - sh / 2), sw, sh, sr) }
}
// Section header
layer('section-labels').apply {
text(10, 20)`Base Methods`
}
// Row 1 labels
layer('labels').apply {
for ([name, i] in row1names) {
text(calc(sx + i * sp), calc(50 + sh / 2 + 12))`${name}`
}
}
// Row 2 labels
layer('labels').apply {
for ([name, i] in row2names) {
text(calc(sx + i * sp), calc(115 + sh / 2 + 12))`${name}`
}
}
// Section divider
layer('connectors').apply {
M 10 170
L 590 170
}
// ═══════════════════════════════════════════════════════════
// Tier 1: Harmonies
// ═══════════════════════════════════════════════════════════
layer('section-labels').apply {
text(10, 195)`Harmonies`
}
let hsx = 160;
let hsp = 55;
let hsw = 30;
let hsh = 30;
let hsr = 5;
// Row 3: analogous
for ([color, i] in analog) {
let x = calc(hsx + i * hsp);
define PathLayer(`analog_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`analog_${i}`).apply {
roundRect(calc(x - hsw / 2), calc(220 - hsh / 2), hsw, hsh, hsr)
}
}
// Row 4: triadic
for ([color, i] in triad) {
let x = calc(hsx + i * hsp);
define PathLayer(`triad_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`triad_${i}`).apply {
roundRect(calc(x - hsw / 2), calc(275 - hsh / 2), hsw, hsh, hsr)
}
}
// Row 5: tetradic
for ([color, i] in tetrad) {
let x = calc(hsx + i * hsp);
define PathLayer(`tetrad_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`tetrad_${i}`).apply {
roundRect(calc(x - hsw / 2), calc(330 - hsh / 2), hsw, hsh, hsr)
}
}
// Row 6: splitComplementary
for ([color, i] in split) {
let x = calc(hsx + i * hsp);
define PathLayer(`split_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`split_${i}`).apply {
roundRect(calc(x - hsw / 2), calc(385 - hsh / 2), hsw, hsh, hsr)
}
}
// Harmony row labels
define TextLayer('hlabels') ${
font-family: system-ui, sans-serif;
font-size: 10;
fill: #888;
}
layer('hlabels').apply {
text(30, 224)`analogous`
text(30, 279)`triadic`
text(30, 334)`tetradic`
text(30, 389)`splitComp.`
}
// Section divider
layer('connectors').apply {
M 10 420
L 590 420
}
// ═══════════════════════════════════════════════════════════
// Tier 1b: Palettes
// ═══════════════════════════════════════════════════════════
layer('section-labels').apply {
text(10, 445)`Palettes`
}
// Row 7: lightness ramp
for ([color, i] in ramp) {
let x = calc(hsx + i * hsp);
define PathLayer(`ramp_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`ramp_${i}`).apply {
roundRect(calc(x - hsw / 2), calc(470 - hsh / 2), hsw, hsh, hsr)
}
}
// Row 8: interpolation
for ([color, i] in interp) {
let x = calc(hsx + i * hsp);
define PathLayer(`interp_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`interp_${i}`).apply {
roundRect(calc(x - hsw / 2), calc(525 - hsh / 2), hsw, hsh, hsr)
}
}
// Palette row labels
layer('hlabels').apply {
text(30, 474)`palette(c,5)`
text(30, 529)`palette(a,b,5)`
}
// Section divider
layer('connectors').apply {
M 10 560
L 590 560
}
// ═══════════════════════════════════════════════════════════
// Tier 2: Derived Colors
// ═══════════════════════════════════════════════════════════
layer('section-labels').apply {
text(10, 583)`Derived Colors`
}
let dsx = 260;
let dsp = 70;
// Row 9: triadic[1] → lighten, darken, saturate
define PathLayer('tri1_parent') ${ fill: tri1; stroke: #666; stroke-width: 1; }
layer('tri1_parent').apply {
roundRect(calc(hsx - hsw / 2), calc(605 - hsh / 2), hsw, hsh, hsr)
}
let derived1 = [tri1Light, tri1Dark, tri1Vivid];
let derived1names = ['lighten', 'darken', 'saturate'];
for ([color, i] in derived1) {
let x = calc(dsx + i * dsp);
define PathLayer(`d1_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`d1_${i}`).apply {
roundRect(calc(x - hsw / 2), calc(605 - hsh / 2), hsw, hsh, hsr)
}
}
// Row 10: ramp[2] → hueShift, complement, alpha
define PathLayer('ramp2_parent') ${ fill: rampMid; stroke: #666; stroke-width: 1; }
layer('ramp2_parent').apply {
roundRect(calc(hsx - hsw / 2), calc(660 - hsh / 2), hsw, hsh, hsr)
}
let derived2 = [rampShifted, rampComp, rampAlpha];
let derived2names = ['hueShift', 'compl.', 'alpha'];
for ([color, i] in derived2) {
let x = calc(dsx + i * dsp);
define PathLayer(`d2_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
layer(`d2_${i}`).apply {
roundRect(calc(x - hsw / 2), calc(660 - hsh / 2), hsw, hsh, hsr)
}
}
// Derived row labels
layer('hlabels').apply {
text(30, 609)`triad[1] →`
text(30, 664)`ramp[2] →`
}
// Derived swatch labels
layer('labels').apply {
for ([name, i] in derived1names) {
text(calc(dsx + i * dsp), calc(605 + hsh / 2 + 12))`${name}`
}
for ([name, i] in derived2names) {
text(calc(dsx + i * dsp), calc(660 + hsh / 2 + 12))`${name}`
}
}
// ── Connecting lines (reactivity chain) ──────────────────
layer('connectors').apply {
// Vertical from base swatch down to tier 1 divider
M sx calc(50 + sh / 2)
L sx 170
// Connector: triadic[1] down to derived row 9
M calc(hsx + 1 * hsp) calc(275 + hsh / 2)
L calc(hsx + 1 * hsp) calc(605 - hsh / 2 - 5)
L hsx calc(605 - hsh / 2 - 5)
L hsx calc(605 - hsh / 2)
// Arrow: parent → derived in row 9
M calc(hsx + hsw / 2) 605
L calc(dsx - hsw / 2) 605
// Connector: ramp[2] down to derived row 10
M calc(hsx + 2 * hsp) calc(470 + hsh / 2)
L calc(hsx + 2 * hsp) calc(660 - hsh / 2 - 5)
L hsx calc(660 - hsh / 2 - 5)
L hsx calc(660 - hsh / 2)
// Arrow: parent → derived in row 10
M calc(hsx + hsw / 2) 660
L calc(dsx - hsw / 2) 660
}
Compile with: svg-path-extended --output-svg-file=swatches.svg --viewBox="0 0 600 700" --width="600" --height="700"
Static Methods
Color.mix(c1, c2, ratio)
Mix two colors at a given ratio (0 = all c1, 1 = all c2):
let a = Color('#e63946');
let b = Color('#457b9d');
let mid = Color.mix(a, b, 0.5);
Color.palette(color, n)
Generate a lightness ramp of n colors from dark (L=0.15) to light (L=0.95), preserving hue and chroma:
let c = Color('#e63946');
let shades = Color.palette(c, 5); // 5 shades from dark to light
Color.palette(c1, c2, n)
Generate n evenly interpolated colors between two colors:
let a = Color('#e63946');
let b = Color('#457b9d');
let gradient = Color.palette(a, b, 7); // 7-step gradient
n must be an integer >= 2.
Color.lightDark(light, dark)
Create a theme-aware color that uses CSS light-dark() in style output:
let fg = Color.lightDark(Color('#333'), Color('#eee'));
// Style output: light-dark(#333333, #eeeeee)
Works with CSSVar-backed colors for full customizability:
let fg = Color.lightDark(
Color(CSSVar('--fg-light', '#333')),
Color(CSSVar('--fg-dark', '#eee'))
);
// Style output: light-dark(var(--fg-light, #333), var(--fg-dark, #eee))
Both arguments must be Colors. At compile time, .hex, .lightness, and other properties resolve to the light variant. Method calls (.lighten(), .hueShift(), etc.) operate on the light variant and lose the light-dark semantics.
@property Declarations
When you create a Color(CSSVar('--name', fallback)), the compiler automatically collects a CSS @property declaration for that custom property. This enables browsers to interpolate the property in transitions and animations.
The collected declarations appear in CompileResult.cssProperties and are emitted as a <style> block in CLI SVG output:
<svg ...>
<style>
@property --base-color {
syntax: "<color>";
inherits: true;
initial-value: #e63946;
}
</style>
...
</svg>
Only Color-typed CSSVars produce @property declarations — plain CSSVar('--width', 2) does not. When the same variable name appears multiple times, the first occurrence wins.
Style Block Auto-Conversion
Colors auto-convert to CSS strings when used in style blocks:
let primary = Color('#e63946');
let light = primary.lighten(0.2);
layer PathLayer('main') ${
stroke: primary;
fill: light;
}
This outputs stroke="#e63946" and fill as the lightened hex value — no .css property needed.
Template Literals
Colors display as Color(#hex) in template literals and log():
let c = Color('#e63946');
log(c); // Color(#e63946)
log(`color: ${c}`); // color: Color(#e63946)
Roundtrip Fidelity
Standard CSS colors roundtrip exactly:
let c = Color('#ff0000');
log(c.hex); // #ff0000
Named Colors
All 148 CSS named colors are supported:
let c = Color('coral');
let c = Color('dodgerblue');
let c = Color('mediumseagreen');
Named color lookup is case-insensitive.
Gradients
Gradients define SVG paint servers (<linearGradient> and <radialGradient>) that can be used as fill or stroke values on layers.
LinearGradient
Create a linear gradient with an ID and coordinates defining the gradient axis:
let fade = LinearGradient('fade', 0, 0, 1, 1) {|g|
g.stop(0, Color('#e63946'));
g.stop(0.5, Color('#f4a261'));
g.stop(1, Color('#2a9d8f'));
};
Constructor signature: LinearGradient(id, x1, y1, x2, y2) — coordinates are in objectBoundingBox units by default (0–1 range).
RadialGradient
Create a radial gradient with an ID, center point, and radius:
let glow = RadialGradient('glow', 0.5, 0.5, 0.5) {|g|
g.stop(0, Color('#ffffff'));
g.stop(1, Color('#000000').alpha(0));
};
Constructor signature: RadialGradient(id, cx, cy, r) — optional focal point: RadialGradient(id, cx, cy, r, fx, fy).
Trailing Block Syntax
Both constructors accept a trailing block {|g| ... } where g is bound to the newly created gradient. Use g.stop(offset, color) inside the block to add color stops:
- offset — a number from 0 to 1 (position along the gradient axis)
- color — any Color value (
Color('#hex'),Color('named'), OKLCH constructor, etc.)
The block is optional — you can create an empty gradient and add stops later or use .inherit() to derive from another gradient.
let empty = LinearGradient('empty', 0, 0, 1, 0);
Using Gradients in Styles
Reference a gradient in fill or stroke style properties. The compiler automatically wraps the gradient ID as url(#id):
let g = LinearGradient('sunset', 0, 0, 1, 0) {|g|
g.stop(0, Color('#e63946'));
g.stop(1, Color('#2a9d8f'));
};
define PathLayer('bg') ${ fill: g; stroke: none; }
layer('bg').apply {
M 0 0 L 200 0 L 200 200 L 0 200 Z
}
This produces fill="url(#sunset)" on the output <path> element, with a <linearGradient id="sunset"> in <defs>.
Gradient Attributes
Set optional attributes via property assignment after creation:
let g = LinearGradient('repeat-fade', 0, 0, 0.25, 0) {|g|
g.stop(0, Color('#e63946'));
g.stop(1, Color('#2a9d8f'));
};
g.spreadMethod = 'repeat';
g.gradientUnits = 'userSpaceOnUse';
g.gradientTransform = 'rotate(45)';
| Property | Values | Default |
|---|---|---|
spreadMethod |
'pad', 'reflect', 'repeat' |
'pad' |
gradientUnits |
'objectBoundingBox', 'userSpaceOnUse' |
'objectBoundingBox' |
gradientTransform |
SVG transform string | none |
interpolation |
'srgb', 'oklch', 'linearRGB' |
'srgb' |
steps |
Number of intermediate stops per unit offset | 10 |
Color Interpolation
Control how colors transition between stops using the interpolation property.
OKLCh Interpolation
Set interpolation = 'oklch' for perceptually uniform transitions. The compiler expands stops at compile time using OKLCh color mixing, avoiding the muddy midpoints common with sRGB interpolation:
let smooth = LinearGradient('smooth', 0, 0, 1, 0) {|g|
g.stop(0, Color('#e63946'));
g.stop(1, Color('#2a9d8f'));
};
smooth.interpolation = 'oklch';
smooth.steps = 12; // 12 intermediate stops per unit offset (default: 10)
The steps property controls the density of generated intermediate stops. Higher values produce smoother transitions but increase SVG output size. The compiler:
- Iterates adjacent stop pairs
- Generates
ceil(steps * offsetSpan) - 1intermediate stops between each pair - Uses
mixColors()for shortest-arc hue interpolation in OKLCh space - Always preserves the original stops at their exact offsets
linearRGB Interpolation
Set interpolation = 'linearRGB' for physically linear color transitions. This uses the native SVG color-interpolation attribute — no stop expansion is needed:
let physical = LinearGradient('physical', 0, 0, 1, 0) {|g|
g.stop(0, Color('#ff0000'));
g.stop(1, Color('#0000ff'));
};
physical.interpolation = 'linearRGB';
This emits color-interpolation="linearRGB" on the gradient element. The browser handles the interpolation natively.
Default (sRGB)
When interpolation is not set (or set to 'srgb'), the browser's default sRGB interpolation is used. No additional attributes or stop expansion occur.
Reactive Gradient Stops
Use Color(CSSVar(...)) in gradient stops to create live-updating gradients that respond to CSS custom property changes:
let accent = Color(CSSVar('--accent', '#e63946'));
let reactive = LinearGradient('reactive', 0, 0, 1, 0) {|g|
g.stop(0, accent); // → stop-color="var(--accent, #e63946)"
g.stop(1, Color('#2a9d8f'));
};
The compiler preserves the var() reference in the stop-color attribute, allowing the gradient to update when the custom property changes at runtime.
CSSVar stops are skipped during OKLCh expansion — since their actual color is determined at runtime, the compiler cannot interpolate them at compile time. Non-CSSVar stops adjacent to CSSVar stops will not have intermediate stops generated between them.
Pattern Paint Server
Create a tiling pattern with an ID, position, and tile dimensions:
let dot = @{ circle(10, 10, 3) };
let dots = Pattern('dots', 0, 0, 20, 20) {|p|
p.append(dot, ${ fill: Color('#e63946'); });
};
dots.patternUnits = 'userSpaceOnUse';
Constructor signature: Pattern(id, x, y, width, height) — defines the tile origin and size.
Pattern Methods
Use .append(pathBlock, styles?) inside the trailing block to add path elements to the pattern. This works the same way as Mask.append():
- pathBlock — a PathBlock (
@{ ... }) or ProjectedPath - styles — optional style block for the path element
let line = @{ m 0 0 l 20 20 };
let hatch = Pattern('hatch', 0, 0, 20, 20) {|p|
p.append(line, ${ stroke: Color('#999'); stroke-width: 1; });
};
Pattern Properties
| Property | Values | Default |
|---|---|---|
patternUnits |
'objectBoundingBox', 'userSpaceOnUse' |
'objectBoundingBox' |
patternTransform |
SVG transform string | none |
patternContentUnits |
'objectBoundingBox', 'userSpaceOnUse' |
'userSpaceOnUse' |
Using Patterns in Styles
Reference a pattern in fill or stroke style properties, just like gradients:
define PathLayer('bg') ${ fill: dots; stroke: none; }
layer('bg').apply { M 0 0 L 200 0 L 200 200 L 0 200 Z }
This produces fill="url(#dots)" on the output <path> element.
Pattern SVG Output
<defs>
<pattern id="dots" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 7 10 A 3 3 0 0 1 13 10 A 3 3 0 0 1 7 10" fill="#e63946"/>
</pattern>
</defs>
Conic Gradient
Create a conic (angular) gradient with an ID and center point:
let wheel = ConicGradient('wheel', 100, 100) {|g|
g.stop(0, Color('#e63946'));
g.stop(0.33, Color('#2a9d8f'));
g.stop(0.66, Color('#264653'));
g.stop(1, Color('#e63946'));
};
Constructor signature: ConicGradient(id, cx, cy) — center coordinates in user space.
Conic gradients use the same .stop(offset, color) method as linear and radial gradients. Stops map to the angular sweep: offset 0 is the start angle, offset 1 is the end angle.
Conic Gradient Properties
| Property | Values | Default |
|---|---|---|
from |
Start angle (requires unit: deg, rad, pi) |
0rad (3 o'clock) |
to |
End angle (requires unit) | from + 2pi (full revolution) |
direction |
'cw', 'ccw' |
'cw' |
spread |
'clamp', 'repeat', 'transparent' |
'clamp' |
innerRadius |
Number (pixels) | 0 |
innerFill |
'transparent', 'transparent-blend', 'center', or Color(...) |
'transparent' |
interpolation |
'srgb', 'oklch', 'linearRGB' |
'srgb' |
steps |
Intermediate stop density | 10 |
Angle Units Required
The from and to properties require an angle unit suffix on literal numbers:
gauge.from = 135deg; // degrees → converted to radians
gauge.to = 2.356rad; // radians (used as-is)
gauge.from = 0.75pi; // multiples of π
gauge.from = 135; // ERROR: requires angle unit. Use 135deg
Computed expressions and function results are accepted without unit checks (they are assumed to already be in radians):
gauge.from = rad(135); // OK — rad() returns radians
Partial Sweep
Set from and to for arcs less than (or greater than) a full revolution:
// Gauge: 270° arc with gap at bottom
let gauge = ConicGradient('gauge', 100, 100) {|g|
g.stop(0, Color('#2a9d8f'));
g.stop(0.5, Color('#e9c46a'));
g.stop(1, Color('#e63946'));
};
gauge.from = 135deg;
gauge.to = 405deg;
Direction
direction controls which way colors sweep within the arc:
'cw'(default) — colors flow clockwise fromfromtoto'ccw'— colors flow counter-clockwise (stop offsets are reversed)
let reversed = ConicGradient('rev', 100, 100) {|g|
g.stop(0, Color('#000'));
g.stop(1, Color('#fff'));
};
reversed.direction = 'ccw';
Spread Modes
spread controls what happens outside the [from, to] arc for partial sweeps:
| Spread | Effect |
|---|---|
'clamp' |
Edge colors extend to fill remaining area |
'repeat' |
Pattern tiles to fill remaining area |
'transparent' |
Outside-arc area is empty (no wedges emitted) |
Inner Radius
Set innerRadius to create a smooth center plateau — the area within innerRadius pixels of the center blends smoothly into the angular sweep:
gauge.innerRadius = 30;
By default, the center area is transparent with a hard edge (a "donut hole"). Use innerFill to control what fills inside the inner radius:
| Value | Effect |
|---|---|
'transparent' |
Hard cutoff — empty center (default) |
'transparent-blend' |
Smooth blend from transparent at center to gradient at edge |
'center' |
Smooth blend from first stop color at center to gradient at edge |
Color(...) |
Smooth blend from custom color at center to gradient at edge |
gauge.innerFill = 'transparent'; // hard donut hole (default)
gauge.innerFill = 'transparent-blend'; // soft transparent fade
gauge.innerFill = 'center'; // first-stop color, blends outward
gauge.innerFill = Color('#1a1a2e'); // custom color, blends outward
This is useful for donut-style gauges and ring charts. Inner radius rendering requires WebGPU, which is only available in the playground. The CLI wedge-path renderer ignores innerRadius and emits a warning when it is set.
// Ring gauge with transparent center and partial sweep
let ring = ConicGradient('ring', 100, 100) {|g|
g.stop(0, Color('#2a9d8f'));
g.stop(0.5, Color('#e9c46a'));
g.stop(1, Color('#e63946'));
};
ring.from = 135deg;
ring.to = 405deg;
ring.innerRadius = 30;
ring.innerFill = 'transparent'; // donut hole
Rendering
Since SVG has no native conic gradient element, the output depends on the consumer:
- CLI (
--output-svg-file): Wedge-path SVG approximation wrapped in<pattern>. Each ~1° slice is an individual<path>element with an interpolated fill color. - Playground: Canvas 2D
createConicGradient()→ rendered to a PNG image → injected as<pattern><image/></pattern>for higher quality.
Both approaches are referenced via url(#id) in fill/stroke, identical to native gradients.
OKLCh Interpolation
Conic gradients support OKLCh interpolation via the shared interpolation and steps properties:
let smooth = ConicGradient('smooth', 100, 100) {|g|
g.stop(0, Color('#e63946'));
g.stop(1, Color('#2a9d8f'));
};
smooth.interpolation = 'oklch';
smooth.steps = 15;
Conic Gradient CSS Variable Limitation
Conic gradients are rasterized at compile time (Canvas 2D in the playground, wedge-path approximation in the CLI). This means Color(CSSVar(...)) stops in conic gradients are baked out — the fallback color is extracted and used directly in the rasterized output.
Unlike linear and radial gradients, which use native SVG elements with live var() references, conic gradients will not update when CSS custom properties change at runtime.
Unfortunately, live-updating CSS variable colors is only available in the playground at this time. The compiler emits a warning when conic gradients contain CSSVar stops.
Conic Gradient Inheritance
Use .inherit(newId) to create child conic gradients. All conic-specific properties (from, to, direction, spread, innerRadius, innerFill) propagate to the child:
let child = wheel.inherit('child-wheel');
child.from = 90deg;
Gradient Inheritance
Create a new gradient that inherits stops and attributes from an existing one using .inherit(newId):
let base = LinearGradient('base', 0, 0, 1, 0) {|g|
g.stop(0, Color('#e63946'));
g.stop(0.5, Color('#f4a261'));
g.stop(1, Color('#2a9d8f'));
};
let rotated = base.inherit('rotated');
rotated.gradientTransform = 'rotate(90, 0.5, 0.5)';
The inherited gradient uses SVG's href attribute to reference the parent. It inherits all stops and attributes from the parent, and you can override specific attributes on the child. Inherited gradients with no stops of their own produce self-closing elements.
Property Access
| Expression | Returns |
|---|---|
gradient.id |
The gradient's string ID |
gradient.spreadMethod |
Current spreadMethod or undefined |
gradient.gradientUnits |
Current gradientUnits or undefined |
gradient.gradientTransform |
Current gradientTransform or undefined |
gradient.interpolation |
Current interpolation mode or null |
gradient.steps |
Current steps value or null |
gradient.from |
Conic: start angle in radians (default 0) |
gradient.to |
Conic: end angle in radians (default 2π) |
gradient.direction |
Conic: 'cw' or 'ccw' (default 'cw') |
gradient.spread |
Conic: spread mode (default 'clamp') |
gradient.innerRadius |
Conic: center plateau radius in pixels (default 0) |
gradient.innerFill |
Conic: inner fill mode — 'transparent', 'transparent-blend', 'center', or Color value |
pattern.id |
The pattern's string ID |
pattern.patternUnits |
Current patternUnits or null |
pattern.patternTransform |
Current patternTransform or null |
pattern.patternContentUnits |
Current patternContentUnits or null |
Dynamic Stop Generation
Use loops and expressions inside the trailing block for programmatic stops:
let ramp = LinearGradient('ramp', 0, 0, 1, 0) {|g|
let colors = ['#e63946', '#f4a261', '#2a9d8f', '#264653', '#e9c46a'];
for ([color, i] in colors) {
g.stop(calc(i / 4), Color(color));
}
};
Any statement valid in the language can appear inside the block — for loops, if statements, let bindings, function calls, etc.
SVG Output
The compiler produces gradient definitions in the <defs> section:
<defs>
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="rgb(89.56% 22.41% 27.51%)"/>
<stop offset="0.5" stop-color="rgb(95.69% 63.53% 38.04%)"/>
<stop offset="1" stop-color="rgb(16.47% 61.57% 56.08%)"/>
</linearGradient>
</defs>
Radial gradients use the <radialGradient> tag with cx, cy, r (and optionally fx, fy) attributes.
Inherited gradients use href:
<linearGradient id="rotated" href="#base" gradientTransform="rotate(90, 0.5, 0.5)"/>
Output Format
When using the JavaScript API, gradients appear in result.gradients:
const result = compile(`
let g = LinearGradient('fade', 0, 0, 1, 1) {|g|
g.stop(0, Color('#e63946'));
g.stop(1, Color('#2a9d8f'));
};
`);
// result.gradients:
// [
// {
// id: 'fade',
// type: 'linear',
// attrs: { x1: '0', y1: '0', x2: '1', y2: '1' },
// stops: [
// { offset: 0, color: 'rgb(89.56% 22.41% 27.51%)' },
// { offset: 1, color: 'rgb(16.47% 61.57% 56.08%)' }
// ]
// }
// ]
Error Handling
| Error | Cause |
|---|---|
Duplicate defs ID 'x' |
ID conflicts with another gradient, mask, clipPath, or pattern |
LinearGradient() expects 5 arguments |
Wrong argument count |
RadialGradient() expects 4-6 arguments |
Wrong argument count |
ConicGradient() expects 3 arguments |
Wrong argument count |
Pattern() expects 5 arguments |
Wrong argument count |
First argument must be a string |
Non-string ID |
stop() offset must be a number |
Non-numeric stop offset |
stop() color must be a Color value |
Non-Color stop color |
requires an angle unit |
Bare number on conic from/to (use 135deg) |
direction must be 'cw' or 'ccw' |
Invalid conic direction |
spread must be 'clamp', 'repeat', or 'transparent' |
Invalid conic spread |
innerRadius must be a number |
Non-numeric innerRadius |
innerRadius must be >= 0 |
Negative innerRadius |
innerFill must be 'transparent', 'transparent-blend', 'center', or a Color value |
Invalid innerFill |
Full Example
// Define a gradient palette
let warm = LinearGradient('warm', 0, 0, 0, 1) {|g|
g.stop(0, Color('#e63946'));
g.stop(0.5, Color('#f4a261'));
g.stop(1, Color('#e9c46a'));
};
let cool = RadialGradient('cool', 0.5, 0.5, 0.5) {|g|
g.stop(0, Color('#2a9d8f'));
g.stop(1, Color('#264653'));
};
// Use in layer styles
define PathLayer('bg') ${ fill: warm; stroke: none; }
define PathLayer('circle') ${ fill: cool; stroke: none; }
layer('bg').apply {
M 0 0 L 200 0 L 200 200 L 0 200 Z
}
layer('circle').apply {
circle(100, 100, 60)
}
Conic Gradient Rendering
Conic gradients are rasterized to bitmap and injected as SVG <pattern> elements because SVG has no native conic gradient primitive.
Playground (browser): When WebGPU is available (Chrome 113+), all conic gradients render through a WGSL fragment shader. This enables innerRadius/innerFill and consistent quality. Rendered textures are cached — unchanged gradients skip re-rendering. When WebGPU is unavailable (Firefox, Safari), the playground falls back to Canvas 2D's createConicGradient(), which does not support innerRadius or innerFill.
CLI: Conic gradients render as wedge-shaped SVG paths (pure math, no GPU). The innerRadius and innerFill properties are ignored with a warning.
Mesh Gradient
Create a mesh gradient with an ID, dimensions, and grid size:
let mesh = MeshGradient('terrain', 200, 200, 4, 3) {|g|
g.getPoint(0, 0).color = Color('#264653');
g.getPoint(0, 3).color = Color('#2a9d8f');
g.getPoint(2, 0).color = Color('#e9c46a');
g.getPoint(2, 3).color = Color('#e63946');
};
Constructor signature: MeshGradient(id, width, height, cols, rows) — creates a rows × cols grid of control points evenly spaced across the given dimensions.
colsandrowsmust be >= 2 (at least one patch)- All points start transparent (
oklch(0 0 0 / 0)) - The trailing block
{|g| ... }is optional
Grid Access Methods
| Method | Arguments | Returns | Description |
|---|---|---|---|
getPoint(row, col) |
row, col (numbers) | MeshPoint | Single control point at grid position |
getRow(row) |
row (number) | Array of MeshPoints | All points in a row |
getCol(col) |
col (number) | Array of MeshPoints | All points in a column |
colorAll(color) |
Color value | — | Set every point to the same color |
MeshPoint Properties
Each point returned by getPoint, getRow, or getCol has:
| Property | Read | Write | Type |
|---|---|---|---|
x |
yes | yes | number |
y |
yes | yes | number |
color |
yes | yes | Color |
MeshPoint Methods
| Method | Arguments | Description |
|---|---|---|
translate(dx, dy) |
numbers | Shift the point position |
Mesh Gradient Properties
| Expression | Returns |
|---|---|
mesh.id |
The gradient's string ID |
mesh.cols |
Number of columns |
mesh.rows |
Number of rows |
mesh.width |
Width in user-space units |
mesh.height |
Height in user-space units |
Mesh Gradient Example
let m = MeshGradient('heat', 200, 200, 3, 3) {|g|
// Color the corners
g.getPoint(0, 0).color = Color('#264653');
g.getPoint(0, 2).color = Color('#2a9d8f');
g.getPoint(2, 0).color = Color('#e9c46a');
g.getPoint(2, 2).color = Color('#e63946');
// Shift a point for artistic control
g.getPoint(1, 1).translate(10, -5);
g.getPoint(1, 1).color = Color('#f4a261');
};
define PathLayer('bg') ${ fill: m; stroke: none; }
layer('bg').apply {
M 0 0 L 200 0 L 200 200 L 0 200 Z
}
Rendering
Mesh gradients are rasterized via WebGPU using bilinear patch interpolation. Each quad cell in the grid is rendered as a smooth color blend between its four corner points.
- Playground: WebGPU shader renders each patch; the result is injected as
<pattern><image/></pattern>, same as conic gradients. - CLI: Mesh gradients are not supported in the CLI wedge-path renderer. A warning is emitted and the gradient renders as transparent.
Freeform Gradient
Create a freeform (scattered-point) gradient with an ID and dimensions:
let ff = FreeformGradient('glow', 200, 200) {|g|
g.point(100, 100, Color('#ffffff'));
g.point(0, 0, Color('#264653'));
g.point(200, 0, Color('#2a9d8f'));
g.point(200, 200, Color('#e63946'));
g.point(0, 200, Color('#e9c46a'));
};
Constructor signature: FreeformGradient(id, width, height) — creates an empty gradient canvas. Add points with .point(x, y, color).
Methods
| Method | Arguments | Description |
|---|---|---|
point(x, y, color) |
x, y (numbers), color (Color) | Add a color point at the given position |
Freeform Gradient Properties
| Expression | Returns |
|---|---|
ff.id |
The gradient's string ID |
ff.width |
Width in user-space units |
ff.height |
Height in user-space units |
ff.falloff |
Distance falloff exponent (default 2.0) |
Falloff
The falloff property controls how quickly colors blend with distance. Higher values create sharper boundaries around each point; lower values create smoother blends:
ff.falloff = 1.0; // very smooth, linear falloff
ff.falloff = 2.0; // default — inverse-square (natural)
ff.falloff = 4.0; // tight halos around each point
falloff must be a positive number.
Freeform Gradient Example
let nebula = FreeformGradient('nebula', 300, 300) {|g|
g.point(150, 150, Color('#ffffff'));
g.point(50, 80, Color('#e63946'));
g.point(250, 80, Color('#2a9d8f'));
g.point(80, 250, Color('#f4a261'));
g.point(220, 250, Color('#264653'));
};
nebula.falloff = 3.0;
define PathLayer('bg') ${ fill: nebula; stroke: none; }
layer('bg').apply {
M 0 0 L 300 0 L 300 300 L 0 300 Z
}
Rendering
Freeform gradients are rasterized via WebGPU using inverse-distance weighted interpolation. Each pixel's color is a weighted average of all control points, where the weight is 1 / distance^falloff.
- Playground: WebGPU shader computes IDW per-pixel; the result is injected as
<pattern><image/></pattern>. - CLI: Freeform gradients are not supported in the CLI. A warning is emitted and the gradient renders as transparent.
A warning is also emitted at compile time if a freeform gradient has fewer than 2 points.
Error Handling
| Error | Cause |
|---|---|
MeshGradient() expects 5 arguments |
Wrong argument count |
MeshGradient() first argument must be a string |
Non-string ID |
MeshGradient() width, height, cols, rows must be numbers |
Non-numeric dimensions |
MeshGradient() cols and rows must be >= 2 |
Grid too small |
FreeformGradient() expects 3 arguments |
Wrong argument count |
FreeformGradient() first argument must be a string |
Non-string ID |
FreeformGradient() width and height must be numbers |
Non-numeric dimensions |
getPoint(row, col) out of bounds |
Index outside grid |
getRow(row) out of bounds |
Row index outside grid |
getCol(col) out of bounds |
Column index outside grid |
point() expects 3 arguments (x, y, color) |
Wrong argument count |
FreeformGradient falloff must be positive |
Non-positive falloff |
TopoGradient
Topological gradients define smooth surfaces using closed-path contours at specific elevations, like topographic map contour lines rendered as a smooth gradient. Each contour carries its own color, creating a natural mapping from shape to color.
Constructor
TopoGradient(id, width, height)
| Argument | Type | Description |
|---|---|---|
id |
string | Unique gradient identifier |
width |
number | Gradient coordinate width |
height |
number | Gradient coordinate height |
Contours
Each contour defines a closed path at a specific elevation with a color. Contours are the color stops of a topological gradient — the gradient interpolates between them based on distance.
g.contour(projectedPath, elevation, color)
| Argument | Type | Description |
|---|---|---|
projectedPath |
ProjectedPathValue | Closed path from .project(x, y) |
elevation |
number | Elevation level (0–1) |
color |
Color | Color at this elevation |
The path must be closed (end with closePath()). Use @{ ... } path blocks with .project(x, y) to position contours in absolute space.
Properties
| Property | Read | Write | Type | Default | Description |
|---|---|---|---|---|---|
id |
yes | no | string | — | Gradient ID |
width |
yes | no | number | — | Render width |
height |
yes | no | number | — | Render height |
easing |
yes | yes | string | 'linear' |
Easing: linear, smoothstep, ease-in, ease-out, ease-in-out |
interpolation |
yes | yes | string | 'srgb' |
Color interpolation space |
method |
yes | yes | string | 'distance' |
Solver: 'distance' (SDF-based) or 'laplace' (Jacobi iteration) |
iterations |
yes | yes | number | 200 |
Jacobi iterations for Laplace solver (range: 1–2000). Only meaningful when method = 'laplace'; ignored by 'distance'. Higher values produce smoother results but take longer to compute. |
baseColor |
yes | yes | Color | — | Color outside all contours (elevation 0) |
Basic Example
// Define contour shapes
let shore = @{
M(0, 0)
C(100, -40, 250, 30, 300, 100)
C(270, 210, 30, 220, 0, 0)
closePath()
};
let peak = @{ circle(0, 0, 40); closePath() };
let topo = TopoGradient('terrain', 400, 300) {|g|
g.contour(shore.project(50, 50), 0.3, Color('#f9e79f'))
g.contour(shore.scale(0.7, 0.7).project(100, 90), 0.55, Color('#27ae60'))
g.contour(peak.project(200, 150), 0.8, Color('#6e2c00'))
};
topo.baseColor = Color('#1a5276');
topo.easing = 'smoothstep';
define PathLayer('bg') ${ fill: topo; }
layer('bg').apply { rect(0, 0, 400, 300) }
Programmatic Contours
Contours can be generated procedurally using loops:
let topo = TopoGradient('rings', 400, 400) {|g|
for ([level, i] in [0.2, 0.4, 0.6, 0.8]) {
let r = calc(150 - i * 35);
let ring = @{ circle(0, 0, r); closePath() };
g.contour(ring.project(200, 200), level, Color('#27ae60'))
}
};
topo.baseColor = Color('#1a5276');
Multiple Peaks / Islands
Non-nested contours at the same elevation create separate features. Each pixel's elevation is determined by its innermost containing contour.
let topo = TopoGradient('archipelago', 600, 400) {|g|
// Main island
g.contour(mainIsland.project(100, 100), 0.35, Color('#f9e79f'))
g.contour(mainPeak.project(180, 160), 0.7, Color('#6e2c00'))
// Small island (separate, not nested)
g.contour(smallIsland.project(450, 280), 0.35, Color('#f9e79f'))
g.contour(smallPeak.project(460, 290), 0.6, Color('#27ae60'))
};
topo.baseColor = Color('#1a5276');
Algorithm
TopoGradient supports two solver methods for computing the elevation field.
Distance Solver (method = 'distance')
The default method uses distance-based SDF (Signed Distance Field) interpolation:
- Containment test: For each contour, ray-cast to determine if the pixel is inside (even-odd rule)
- Floor elevation: Highest elevation among all contours containing the pixel
- Ceiling elevation: Lowest elevation among contours NOT containing the pixel but above the floor
- Distance interpolation: Compute minimum distances to floor and ceiling boundaries, interpolate elevation
- Easing: Apply the easing function to the interpolation parameter
- Color lookup: Sample the color ramp (built from contour colors sorted by elevation)
Laplace Solver (method = 'laplace')
The Laplace solver computes the mathematically smoothest possible surface between contours by solving the Laplace equation ∇²h = 0 with contour pixels as boundary conditions. This produces results like a rubber sheet stretched between fixed-elevation boundaries.
The solver uses Jacobi iteration: each non-boundary pixel is repeatedly replaced with the average of its 4 neighbors until the field converges. The iterations property controls how many passes are performed (default: 200).
Distance vs Laplace comparison:
- Distance (SDF): Fast, uses signed distance blending with smooth transition zones. Produces concentric-like gradients that follow contour shapes. Best for: decorative gradients, radial-style effects.
- Laplace: Solves for the harmonic function, producing physically correct potential field flow. Elevation changes smoothly around corners and between non-nested contours. Best for: terrain/height maps, natural-looking blends, multi-peak topologies.
let s = @{ circle(0, 0, 80); closePath() };
let topo = TopoGradient('terrain', 400, 300) {|g|
g.contour(s.project(200, 150), 0.3, Color('#2ecc71'))
g.contour(s.project(200, 150, 0.5, 0.5), 0.7, Color('#e74c3c'))
};
topo.method = 'laplace';
topo.iterations = 300;
topo.baseColor = Color('#1a5276');
Rendering
TopoGradient is rasterized per-pixel:
- Playground: WebGPU shader with SDF computation (fast); Canvas 2D fallback on Firefox/Safari (slower)
- CLI: Warning emitted, solid-color approximation rendered
Error Handling
| Error | Cause |
|---|---|
TopoGradient() expects 3 arguments |
Wrong argument count |
TopoGradient() first argument must be a string |
Non-string ID |
TopoGradient() width and height must be numbers |
Non-numeric dimensions |
.contour() expects 3 arguments |
Wrong argument count |
.contour() first argument must be a ProjectedPathValue |
Non-projected path |
.contour() elevation must be between 0 and 1 |
Out-of-range elevation |
.contour() third argument must be a Color value |
Non-Color color |
.contour() path must be closed |
Path not ending with closePath() |
TopoGradient easing must be one of: ... |
Invalid easing value |
TopoGradient iterations must be a number |
When setting iterations to a non-number |
TopoGradient iterations must be between 1 and 2000 |
When iterations is out of range |
CSSVar Type
CSSVar creates CSS custom property references (var()) that can be used in style blocks. This lets SVGs generated by Pathogen be parameterized by the consuming page's CSS.
Constructor
let v = CSSVar('--primary'); // no fallback
let v = CSSVar('--primary', '#e63946'); // string fallback
let v = CSSVar('--primary', Color('#e63946')); // Color fallback
The variable name must start with --. The optional fallback can be a plain string or a Color value (which auto-converts to its CSS representation).
Properties
| Property | Type | Description |
|---|---|---|
.var |
string | The variable name (e.g. --primary) |
.fallback |
string or null | The fallback value, or null if none |
.css |
string | The full var() expression |
let v = CSSVar('--primary', '#e63946');
log(v.var); // --primary
log(v.fallback); // #e63946
log(v.css); // var(--primary, #e63946)
Style Blocks
CSSVar values auto-convert in style blocks — no .css needed:
let fg = CSSVar('--foreground', '#333');
define PathLayer('main') ${ stroke: fg; fill: CSSVar('--fill', 'none'); }
This produces stroke="var(--foreground, #333)" and fill="var(--fill, none)" in the SVG output.
Composition with Color
CSSVar composes with the Color type for typed fallbacks:
let brand = Color('#e63946');
let fg = CSSVar('--primary', brand);
// fg.css → var(--primary, #e63946)
Display
log() displays CSSVar values in constructor form:
let v = CSSVar('--primary', '#e63946');
log(v); // CSSVar(--primary, #e63946)
let v2 = CSSVar('--bg');
log(v2); // CSSVar(--bg)
Template literals also use this form:
let v = CSSVar('--primary', '#e63946');
log(`color: ${v}`); // color: CSSVar(--primary, #e63946)
Masks and Clip Paths
Masks and clip paths are SVG <defs> elements that control visibility of layers. They're created with Mask() and ClipPath() constructors and referenced from layer style blocks.
Masks
A mask uses luminance to control visibility — white areas are fully visible, black areas are hidden, and gray values create partial transparency.
Creating a Mask
let m = Mask('my-mask');
The argument is the mask's ID string. IDs must be unique across all masks and clip paths.
Appending Paths
Use .append(path, styles?) to add path elements to the mask:
let base = @{ m 0 0 l 200 0 l 0 200 l -200 0 z };
let hole = @{ m 50 50 l 100 0 l 0 100 l -100 0 z };
let m = Mask('reveal');
m.append(base, ${ fill: white; }); // visible area
m.append(hole, ${ fill: black; }); // hidden cutout
The first argument accepts either a PathBlock or a ProjectedPath. PathBlocks are automatically projected at the origin (0, 0). The optional second argument is a style block for the path element.
Using a Mask
Reference the mask from a layer's style block using the .id property:
define PathLayer('art') ${ mask: m.id; }
layer('art').apply {
M 10 10 L 190 190
}
The mask property automatically wraps the ID with url(#...), so m.id (which returns 'reveal') becomes mask: url(#reveal) in the output.
Full Example
// Define mask geometry
let fullRect = @{ m 0 0 l 200 0 l 0 200 l -200 0 z };
let circle = @{ m 100 50 a 50 50 0 1 1 0 100 a 50 50 0 1 1 0 -100 };
// Create mask: white = visible, black = hidden
let m = Mask('circle-reveal');
m.append(fullRect, ${ fill: black; });
m.append(circle, ${ fill: white; });
// Apply mask to layer
define PathLayer('drawing') ${ mask: m.id; stroke: #333; stroke-width: 2; }
layer('drawing').apply {
for (i in 0..20) {
M 0 calc(i * 10)
L 200 calc(i * 10)
}
}
This draws horizontal lines that are only visible inside the circular mask.
Clip Paths
A clip path uses geometry to clip content — anything inside the path is visible, everything outside is hidden. Unlike masks, clip paths don't use styles (they're purely geometric).
Creating a Clip Path
let c = ClipPath('my-clip');
Appending Paths
Use .append(path) to add path elements. No styles parameter — clip paths are geometry-only:
let shape = @{ m 20 20 l 160 0 l 0 160 l -160 0 z };
let c = ClipPath('frame');
c.append(shape);
Using a Clip Path
define PathLayer('scene') ${ clip-path: c.id; }
layer('scene').apply {
M 0 0 L 200 200
}
Like masks, the clip-path property automatically wraps the ID with url(#...).
Auto-Wrapping
The following CSS properties automatically wrap bare ID strings with url(#...):
maskclip-pathfiltermarker-startmarker-midmarker-end
If the value already starts with url(, it's left as-is:
// These produce the same output:
define PathLayer('a') ${ mask: m.id; } // m.id → 'my-mask' → url(#my-mask)
define PathLayer('b') ${ mask: url(#my-mask); } // already wrapped, left as-is
Properties
| Property | Returns | Description |
|---|---|---|
.id |
string | The raw ID string passed to the constructor |
Methods
| Method | Applies To | Description |
|---|---|---|
.append(path, styles?) |
Mask | Add a path element with optional styles |
.append(path) |
ClipPath | Add a path element (no styles) |
Error Handling
- Duplicate IDs across masks and clip paths throw an error
- Constructor requires exactly one string argument
.append()requires a PathBlock or ProjectedPath as the first argument- Mask
.append()requires a style block as the optional second argument
Objects
Objects are key-value containers for grouping related data — coordinates, configuration, metadata, or any structured values.
Object Literals
Create objects with curly braces and key: value pairs:
let obj = {};
let point = { x: 50, y: 80 };
let config = { name: 'Dave', age: 32, cats: ['foo', 'bar', 'baz'] };
Keys can be identifiers or string literals. Trailing commas are allowed:
let obj = {
'first-name': 'Alice',
lastName: 'Smith',
age: 30,
};
Objects can be nested:
let shape = {
center: { x: 100, y: 100 },
radius: 50,
};
Reading Properties
Dot notation — for identifier keys:
let x = point.x; // 50
let r = shape.radius; // 50
Bracket notation — for any string key, including dynamic expressions:
let x = point['x']; // 50
let key = 'name';
let val = config[key]; // 'Dave'
Accessing a key that doesn't exist returns null:
let missing = point.z; // null
let also = point['nope']; // null
The length property returns the number of keys:
let size = point.length; // 2
Writing Properties
Use bracket notation to set or update properties:
let obj = {};
obj['x'] = 10;
obj['y'] = 20;
obj['x'] = 99; // overwrite
This also works for updating array elements:
let arr = [1, 2, 3];
arr[0] = 99; // arr is now [99, 2, 3]
Checking Key Existence
Use .has() to check if a key exists:
let obj = { name: 'Alice' };
if (obj.has('name')) {
// true
}
if (obj.has('age')) {
// false
}
Object Namespace Functions
The Object namespace provides utility functions:
Object.keys(obj)
Returns an array of all keys:
let obj = { a: 1, b: 2, c: 3 };
let keys = Object.keys(obj); // ['a', 'b', 'c']
Object.values(obj)
Returns an array of all values:
let vals = Object.values(obj); // [1, 2, 3]
Object.entries(obj)
Returns an array of [key, value] pairs:
let entries = Object.entries(obj); // [['a', 1], ['b', 2], ['c', 3]]
Object.delete(obj, key)
Removes a key from the object. Returns the deleted value, or null if the key didn't exist:
let obj = { x: 10, y: 20 };
let deleted = Object.delete(obj, 'x'); // 10
// obj is now { y: 20 }
Iterating Over Objects
Keys only
let obj = { x: 10, y: 20 };
for (key in obj) {
log(key); // 'x', then 'y'
}
Key-value pairs
for ([key, value] in obj) {
log(key, value); // 'x' 10, then 'y' 20
}
This also works with Object.entries():
for ([key, value] in Object.entries(obj)) {
log(key, value);
}
Reference Semantics
Objects use reference semantics (like arrays). Assigning an object to another variable shares the same underlying data:
let a = { x: 1 };
let b = a;
b['x'] = 99;
log(a.x); // 99 — both a and b point to the same object
Merging Objects (<<)
The << operator creates a new object by merging two objects together. Properties from the right side override those on the left:
let a = { x: 1, y: 2 };
let b = { y: 99, z: 3 };
let merged = a << b;
log(merged); // {x: 1, y: 99, z: 3}
The original objects are not modified:
log(a); // {x: 1, y: 2} — unchanged
Multiple merges can be chained (evaluated left-to-right):
let defaults = { stroke: 'black', width: 2, fill: 'none' };
let theme = { stroke: 'red' };
let overrides = { width: 4 };
let final = defaults << theme << overrides;
// {stroke: 'red', width: 4, fill: 'none'}
Merge is shallow — nested objects are shared by reference, not deep-copied:
let inner = { val: 1 };
let a = { nested: inner };
let b = a << {};
b.nested['val'] = 99;
log(a.nested.val); // 99 — same inner object
Using Objects with Path Commands
Objects are natural containers for coordinates and configuration:
let start = { x: 10, y: 20 };
let end = { x: 180, y: 160 };
M start.x start.y
L end.x end.y
Debug & Console
The playground includes debugging tools to help you understand how your code executes and inspect values during evaluation.
Console Output
Click the Console button in the header to view debug output.
log() Function
Use log() to inspect values during execution:
log("message") // String message
log(myVar) // Variable with label
log("pos:", ctx.position) // Multiple args
log(ctx) // Full context object
Output Format
String arguments display as-is. Other expressions show a label with the source:
log("radius is", r)
// Output:
// radius is
// r = 50
Objects are expandable in the console - click the arrow to explore nested properties.
ctx Object
The ctx object tracks path state during evaluation:
ctx.position
Current pen position after the last command.
| Property | Description |
|---|---|
ctx.position.x |
X coordinate |
ctx.position.y |
Y coordinate |
M 100 50
log(ctx.position) // {x: 100, y: 50}
L 150 75
log(ctx.position) // {x: 150, y: 75}
ctx.start
Subpath start position (set by M/m, used by Z).
| Property | Description |
|---|---|
ctx.start.x |
X coordinate |
ctx.start.y |
Y coordinate |
ctx.commands
Array of all executed commands with their positions:
// Each entry contains:
{
command: "L", // Command letter
args: [150, 75], // Evaluated arguments
start: {x: 100, y: 50},
end: {x: 150, y: 75}
}
Using ctx in Paths
Access position values with calc():
M 50 50
// Draw relative to current position
L calc(ctx.position.x + 30) ctx.position.y
circle(ctx.position.x, ctx.position.y, 5)
Example: Debug a Loop
M 20 100
for (i in 0..4) {
log("iteration", i, ctx.position)
L calc(ctx.position.x + 40) 100
}
This logs the iteration number and current position at each step, helping you trace how the path is constructed.
CLI Reference
The svg-path-extended CLI compiles extended SVG path syntax into standard SVG path strings or complete SVG files.
Installation
npm install -g svg-path-extended
Or use with npx:
npx svg-path-extended [options]
Basic Usage
Compile a File
svg-path-extended input.svgx
Or with the explicit flag:
svg-path-extended --src=input.svgx
Compile Inline Code
svg-path-extended -e 'circle(100, 100, 50)'
Read from Stdin
echo 'let x = 50; circle(x, x, 25)' | svg-path-extended -
cat myfile.svgx | svg-path-extended -
Output Options
Output Path Data to File
svg-path-extended --src=input.svgx -o output.txt
svg-path-extended --src=input.svgx --output output.txt
Output as Complete SVG File
Generate a complete SVG file with the path embedded:
svg-path-extended --src=input.svgx --output-svg-file=output.svg
This creates a ready-to-use SVG file that can be opened in any browser or image viewer.
Log Output
Pathogen programs can use log() to produce diagnostic output. By default, the CLI discards log entries. Two flags expose them:
Print to stderr
svg-path-extended -e 'let x = 42; log(x); M x 0' --print-logs
Output on stderr:
[line 1] x = 42
The path data still goes to stdout, so logs don't interfere with piping:
svg-path-extended -e 'log("hello"); circle(50, 50, 25)' --print-logs > output.txt
Write structured JSON
svg-path-extended --src=input.pathogen --log-file=logs.json
This writes the full LogEntry[] array with line numbers and typed parts:
[
{
"line": 3,
"parts": [
{ "type": "value", "label": "x", "value": "42" }
]
}
]
Both flags can be combined:
svg-path-extended --src=debug.pathogen --print-logs --log-file=logs.json --output-svg-file=out.svg
Annotated Output
Use --annotated to get a human-readable debug output that shows:
- Original comments preserved in place
- Loop iterations with line numbers
- Function call annotations with expanded output
- Each path command on its own line
This is useful for debugging complex path generation or understanding how your code produces its output.
Basic Usage
svg-path-extended -e 'for (i in 0..3) { M i 0 }' --annotated
Output:
//--- for (i in 0..3) from line 1
//--- iteration 0
M 0 0
//--- iteration 1
M 1 0
//--- iteration 2
M 2 0
//--- iteration 3
M 3 0
With Comments
svg-path-extended -e '// Draw points
for (i in 0..3) { M i 0 }' --annotated
Output:
// Draw points
//--- for (i in 0..3) from line 2
//--- iteration 0
M 0 0
//--- iteration 1
M 1 0
//--- iteration 2
M 2 0
//--- iteration 3
M 3 0
Loop Truncation
Long loops (>10 iterations) are automatically truncated to show the first 3 and last 3 iterations:
svg-path-extended -e 'for (i in 0..100) { M i 0 }' --annotated
Output:
//--- for (i in 0..100) from line 1
//--- iteration 0
M 0 0
//--- iteration 1
M 1 0
//--- iteration 2
M 2 0
... 95 more iterations ...
//--- iteration 98
M 98 0
//--- iteration 99
M 99 0
//--- iteration 100
M 100 0
Function Call Annotations
Function calls show their name, arguments, and expanded output:
svg-path-extended -e 'circle(50, 50, 25)' --annotated
Output:
//--- circle(50, 50, 25) called from line 1
M 25 50
A 25 25 0 1 1 75 50
A 25 25 0 1 1 25 50
Save to File
svg-path-extended --src=complex.svgx --annotated -o debug-output.txt
SVG Styling Options
When using --output-svg-file, you can customize the appearance:
| Option | Default | Description |
|---|---|---|
--stroke=<color> |
#000 |
Stroke color |
--fill=<color> |
none |
Fill color |
--stroke-width=<n> |
2 |
Stroke width |
--viewBox=<box> |
0 0 200 200 |
SVG viewBox |
--width=<w> |
200 |
SVG width |
--height=<h> |
200 |
SVG height |
Examples
Red circle with no fill:
svg-path-extended -e 'circle(100, 100, 50)' \
--output-svg-file=circle.svg \
--stroke=red \
--stroke-width=3
Blue filled polygon:
svg-path-extended -e 'polygon(100, 100, 80, 6)' \
--output-svg-file=hexagon.svg \
--stroke=navy \
--fill=lightblue \
--stroke-width=2
Large canvas with custom viewBox:
svg-path-extended --src=complex.svgx \
--output-svg-file=output.svg \
--viewBox="0 0 800 600" \
--width=800 \
--height=600
Help and Version
svg-path-extended --help
svg-path-extended -h
svg-path-extended --version
svg-path-extended -v
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Error (parse error, file not found, etc.) |
File Extensions
By convention, source files use the .svgx extension, but any text file will work.
Examples
Generate a Spiral
svg-path-extended -e '
M 100 100
for (i in 1..50) {
L calc(100 + cos(i * 0.3) * i * 1.5) calc(100 + sin(i * 0.3) * i * 1.5)
}
' --output-svg-file=spiral.svg --stroke=teal --stroke-width=2
Process Multiple Files
for file in examples/*.svgx; do
svg-path-extended --src="$file" --output-svg-file="${file%.svgx}.svg"
done
Use in a Build Script
{
"scripts": {
"build:icons": "svg-path-extended --src=src/icons.svgx --output-svg-file=dist/icons.svg"
}
}
Examples
Practical examples showing how to use svg-path-extended for common tasks.
Basic Shapes
Simple Rectangle
rect(10, 10, 180, 80)
Circle
circle(100, 100, 50)
Rounded Rectangle
roundRect(20, 40, 160, 120, 15)
Using Variables
Centered Circle
let width = 200;
let height = 200;
let cx = calc(width / 2);
let cy = calc(height / 2);
let r = 40;
circle(cx, cy, r)
Configurable Star
let centerX = 100;
let centerY = 100;
let outerR = 60;
let innerR = 25;
let points = 5;
star(centerX, centerY, outerR, innerR, points)
Loops and Patterns
Row of Circles
for (i in 0..5) {
circle(calc(30 + i * 35), 100, 15)
}
Grid of Dots
for (row in 0..5) {
for (col in 0..5) {
circle(calc(20 + col * 40), calc(20 + row * 40), 5)
}
}
Concentric Circles
let cx = 100;
let cy = 100;
for (i in 1..6) {
circle(cx, cy, calc(i * 15))
}
Trigonometry
Points on a Circle
let cx = 100;
let cy = 100;
let r = 60;
let points = 8;
for (i in 0..points) {
let angle = calc(i / points * TAU());
let x = calc(cx + cos(angle) * r);
let y = calc(cy + sin(angle) * r);
circle(x, y, 5)
}
Spiral
M 100 100
for (i in 1..100) {
let angle = calc(i * 0.2);
let r = calc(i * 0.8);
L calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)
}
Sine Wave
M 0 100
for (i in 1..40) {
let x = calc(i * 5);
let y = calc(100 + sin(i * 0.3) * 30);
L x y
}
Flower Pattern
let cx = 100;
let cy = 100;
let petalCount = 6;
let petalRadius = 25;
let centerRadius = 15;
// Petals
for (i in 0..petalCount) {
let angle = calc(i / petalCount * TAU());
let px = calc(cx + cos(angle) * 35);
let py = calc(cy + sin(angle) * 35);
circle(px, py, petalRadius)
}
// Center
circle(cx, cy, centerRadius)
Custom Functions
Reusable Square
fn square(x, y, size) {
rect(x, y, size, size)
}
square(10, 10, 50)
square(70, 10, 50)
square(130, 10, 50)
Diamond Shape
fn diamond(cx, cy, size) {
M cx calc(cy - size)
L calc(cx + size) cy
L cx calc(cy + size)
L calc(cx - size) cy
Z
}
diamond(100, 100, 40)
Arrow
fn arrow(x1, y1, x2, y2, headSize) {
// Line
M x1 y1
L x2 y2
// Arrowhead (simplified)
let angle = atan2(calc(y2 - y1), calc(x2 - x1));
let a1 = calc(angle + 2.5);
let a2 = calc(angle - 2.5);
M x2 y2
L calc(x2 - cos(a1) * headSize) calc(y2 - sin(a1) * headSize)
M x2 y2
L calc(x2 - cos(a2) * headSize) calc(y2 - sin(a2) * headSize)
}
arrow(20, 100, 180, 100, 15)
Conditionals
Size-Based Shape
let size = 80;
if (size > 50) {
circle(100, 100, size)
} else {
rect(calc(100 - size / 2), calc(100 - size / 2), size, size)
}
Alternating Pattern
for (i in 0..10) {
let x = calc(20 + i * 18);
if (calc(i % 2) == 0) {
circle(x, 100, 8)
} else {
rect(calc(x - 6), 94, 12, 12)
}
}
Complex Examples
Gear Shape
let cx = 100;
let cy = 100;
let innerR = 30;
let outerR = 50;
let teeth = 12;
M calc(cx + outerR) cy
for (i in 0..teeth) {
let a1 = calc(i / teeth * TAU());
let a2 = calc((i + 0.3) / teeth * TAU());
let a3 = calc((i + 0.5) / teeth * TAU());
let a4 = calc((i + 0.8) / teeth * TAU());
L calc(cx + cos(a1) * outerR) calc(cy + sin(a1) * outerR)
L calc(cx + cos(a2) * outerR) calc(cy + sin(a2) * outerR)
L calc(cx + cos(a3) * innerR) calc(cy + sin(a3) * innerR)
L calc(cx + cos(a4) * innerR) calc(cy + sin(a4) * innerR)
}
Z
// Center hole
circle(cx, cy, 10)
Recursive-Style Tree (using loops)
// Simple branching pattern
fn branch(x, y, length, angle, depth) {
let x2 = calc(x + cos(angle) * length);
let y2 = calc(y + sin(angle) * length);
M x y
L x2 y2
}
let startX = 100;
let startY = 180;
// Trunk
M startX startY
L startX 120
// Main branches
for (i in 0..5) {
let angle = calc(-1.57 + (i - 2) * 0.4);
let len = calc(30 - abs(i - 2) * 5);
branch(startX, 120, len, angle, 0)
}
Tips
- Start simple: Build complex shapes from simple parts
- Use variables: Makes code readable and adjustable
- Extract functions: Reuse common patterns
- Test incrementally: Generate SVGs often to see results
- Use comments: Document your intent for complex sections