Solandra standardises on HSLA colours: hue (0–360), saturation (0–100), lightness (0–100), alpha (0–1). HSLA is a great fit for generative work because meaningful variations are simple arithmetic: rotate the hue for a new colour scheme, nudge lightness for depth, drop alpha for layering.
s.setFillColor(210, 70, 50) // h, s, l
s.setStrokeColor(210, 70, 50, 0.5) // ... and alpha
s.background(45, 20, 95)
// or with a { h, s, l, a } object (a ColorSpec)
s.setFillColorFromSpec({ h: 210, s: 70, l: 50 })
s.backgroundFromSpec({ h: 45, s: 20, l: 95 })Hue arithmetic in action; each ring shifts hue and lightness slightly:
s.background(230, 40, 12)
s.aroundCircle({ n: 12, r: 0.3 }, ([x, y], i) => {
s.times(5, (n) => {
s.setFillColor(200 + i * 12, 70, 65 - n * 8, 0.85)
s.fill(new Circle({ at: [x, y], r: 0.11 - n * 0.02 }))
})
})LinearGradient and RadialGradient produce real canvas gradients. Both take colour stops as [position, ColorSpec] tuples and can be used for fills, strokes or backgrounds via setFillGradient, setStrokeGradient and backgroundGradient.
import { LinearGradient, RadialGradient } from "solandra"
s.backgroundGradient(
new LinearGradient({
from: [0, 0],
to: [0, 1],
colors: [
[0, { h: 215, s: 80, l: 25 }],
[0.6, { h: 340, s: 70, l: 40 }],
[1, { h: 30, s: 90, l: 60 }],
],
})
)
s.setFillGradient(
new RadialGradient({
start: [0.5, 0.42],
end: [0.5, 0.42],
rStart: 0,
rEnd: 0.35,
colors: [
[0, { h: 50, s: 100, l: 80 }],
[1, { h: 50, s: 100, l: 80, a: 0 }],
],
})
)
s.fill(new Circle({ at: [0.5, 0.42], r: 0.35 }))Sometimes you want a discrete sequence of colours rather than a canvas gradient: one colour per tile, per layer, per particle. The theme helpers each return a function from a step number to a ColorSpec:
simpleLinearGradient(a, b, steps) — interpolate every channel between two colourshueRange({ h1, h2, s, l, steps }) — vary only huesaturationRange({ h, s1, s2, l, steps }) — vary only saturationlightnessRange({ h, s, l1, l2, steps }) — vary only lightnessalphaRange({ h, s, l, a1, a2, steps }) — vary only alphaimport { hueRange, lightnessRange } from "solandra"
s.background(0, 0, 96)
const warm = hueRange({ h1: 0, h2: 60, s: 80, l: 55, steps: 12 })
const fade = lightnessRange({ h: 215, s: 70, l1: 25, l2: 80, steps: 12 })
s.forHorizontal({ n: 12, margin: 0.06 }, ([x, y], [dX, dY], _c, i) => {
s.setFillColorFromSpec(warm(i))
s.fill(new Rect({ at: [x, y], w: dX * 0.85, h: dY * 0.45 }))
s.setFillColorFromSpec(fade(i))
s.fill(new Rect({ at: [x, y + dY * 0.55], w: dX * 0.85, h: dY * 0.45 }))
})Hues picked at random rarely look intentional. harmony builds a scheme around a colour by the usual relationships of colour theory, returning the base colour first and then its companions, ready to sample from or step through:
complementary (the default) — the opposite hue, maximum contrastanalogous — neighbouring hues, calm and closely relatedtriadic — three hues evenly spaced round the circletetradic — four hues, two complementary pairssplitComplementary — the two hues either side of the complement: nearly the contrast of complementary, easier to live withmonochrome — one hue at a range of lightnessesSaturation, lightness and alpha come from the base colour (except in a monochrome scheme, which is what varies the lightness). n sets how many colours the open-ended schemes give, and spread how far apart they sit: degrees of hue for analogous and splitComplementary, of lightness for monochrome.
import { harmony } from "solandra"
s.background(0, 0, 96)
const base = { h: 205, s: 70, l: 50 }
const schemes = [
harmony(base, { type: "analogous", n: 5 }),
harmony(base, { type: "triadic" }),
harmony(base, { type: "splitComplementary" }),
harmony(base, { type: "monochrome", n: 5 }),
]
s.forVertical({ n: 4, margin: 0.05 }, ([x, y], [dX, dY], _c, i) => {
schemes[i].forEach((color, j) => {
s.setFillColorFromSpec(color)
const w = dX / schemes[i].length
s.fill(new Rect({ at: [x + j * w, y], w: w * 0.94, h: dY * 0.8 }))
})
})A scheme is just an array of ColorSpecs, so sample picks from it and setFillColorFromSpec uses it directly:
s.background(30, 15, 92)
const scheme = harmony(
{ h: 15, s: 70, l: 55 },
{ type: "analogous", n: 5, spread: 18 }
)
s.forTiling({ n: 12, type: "square", margin: 0.05 }, (at, [w, h]) => {
s.setFillColorFromSpec(s.sample(scheme))
s.fill(new Rect({ at, w, h }))
})mixColors(a, b, proportion) blends two colours, and unlike plain interpolation it takes the short way round the hue circle. Interpolating hue as a number sweeps the long way whenever that crosses 0/360: red (350) to orange (10) would pass through the entire spectrum instead of the 20 degrees actually between them.
import { mixColors } from "solandra"
const sunset = { h: 350, s: 80, l: 55 }
const gold = { h: 40, s: 85, l: 60 }
s.forHorizontal({ n: 24 }, (at, [dX, dY], _c, i) => {
s.setFillColorFromSpec(mixColors(sunset, gold, i / 23))
s.fill(new Rect({ at, w: dX, h: dY }))
})It pairs naturally with harmony: pick a scheme, then blend between its colours for everything in between. Proportions are not clamped, so values beyond 0 and 1 carry on past the two colours.
Based on Inigo Quilez's cosine palette technique, Solandra can generate whole colour palettes procedurally. palettePreset(name, steps) gives you a curated palette as an array of [h, s, l] tuples; presets are "rainbow", "warmth", "rusty", "autumnal", "natural", "neon" and "subtle".
import { palettePreset } from "solandra"
s.background(0, 0, 10)
const presets = ["rainbow", "warmth", "natural", "neon", "subtle"]
s.forVertical({ n: 5, margin: 0.05 }, ([x, y], [dX, dY], _c, i) => {
const colors = palettePreset(presets[i], 10)
colors.forEach(([h, sat, l], j) => {
s.setFillColor(h, sat, l)
s.fill(
new Rect({
at: [x + (j * dX) / 10, y + dY * 0.1],
w: dX / 10 - 0.005,
h: dY * 0.8,
})
)
})
})For full control use palette({ a, b, c, d, steps }) with your own cosine coefficients (each a [r, g, b] triple): color(t) = a + b * cos(2π(c * t + d)).
import { palette } from "solandra"
s.background(230, 30, 10)
const colors = palette({
a: [0.6, 0.4, 0.5],
b: [0.4, 0.4, 0.4],
c: [1.0, 1.0, 1.0],
d: [0.1, 0.25, 0.55],
steps: 64,
})
s.forTiling(
{ n: 8, type: "square", margin: 0.05 },
(_pt, [dX], [cX, cY], i) => {
const [h, sat, l] = colors[i % colors.length]
s.setFillColor(h, sat, l)
s.fill(new Star({ at: [cX, cY], n: 6, r: dX * 0.42, a: i * 0.1 }))
}
)Because alpha is a first-class argument everywhere, translucent layering (a staple of generative texture) is easy; combine it with withBlendMode from Transforms for stronger effects:
s.background(45, 30, 96)
s.times(60, () => {
s.setFillColor(s.sample([200, 215, 230]), 70, 55, 0.15)
s.fill(
new Circle({
at: s.perturb({ at: [0.5, 0.5], magnitude: 0.6 }),
r: 0.08 + s.random() * 0.15,
})
)
})Solandra was made by James Porter.
Check out the GitHub page or install with npm i solandra