colormotion
v4.3.0
Published
Dynamic color wheel for LEDs
Downloads
3,035
Maintainers
Readme
colormotion
colormotion is a JavaScript library that generates dynamic color palettes.
Its primary use case is for generating color palettes that change over time,
such as for animations or visualizations in LED art.
Compatible with Node.js and browser environments.
It supports many different color interpolation methods:
- RGB
- LAB
- LRGB
- HSL
- LCH
- HSV
- HSI
- HCL
- OKLab
- OKLCH
It uses the chroma.js library under the hood for color interpolation.
Check out the interactive docs here.
Installation
Install the colormotion npm module using your favorite package manager:
npm install colormotion
# or
# pnpm install colormotion
# or
# yarn add colormotionQuick Start
Import the Theme class from colormotion using CommonJS or ES modules:
// CommonJS
const { Theme } = require('colormotion');
// ES modules
import { Theme } from 'colormotion';Create a new theme instance:
const theme = new Theme();Create a new theme instance with 5 random colors:
const theme = new Theme({
nColors: 5,
});Create a new theme instance with 5 random colors and 1024 steps:
const theme = new Theme({
nColors: 5,
nSteps: 1024,
});Create a new theme instance with specific colors:
const theme = new Theme({
colors: ['red', 'green', 'blue'],
});Supposing we have some draw loop, we can use the theme to generate colors for our LEDs:
function draw() {
// Suppose we have an LED strip with 100 LEDs
for (let i = 0; i < 100; i++) {
const color = theme.getColor(i);
// Set the color of the i-th LED to `color`
}
// Advance the theme to the next frame
theme.tick();
}Easily set a new color palette, to which the theme will gradually transition.
// Set an entirely new color palette
theme.setColors(['red', 'green', 'blue']);
// Set a new color palette using an input color as a seed
theme.randomFrom('#662c91');
// Set a completely random palette
theme.randomTheme();
// Replace the oldest color with a new color, in its place on the wheel
theme.rotateColor('#17a398');
// Replace the oldest color with a new random color
theme.rotateRandomColor();Rotating replaces one color in place: every other color keeps its place on the wheel, so only the colors
between the replaced color's two neighbours change, and an LED that shows another part of the wheel keeps
its color. Successive rotations replace the colors oldest first, starting from the first color of the list
they were set from. activePalette.ageOrder lists the positions of the colors, oldest first, so its first
entry is the color the next rotation replaces.
const theme = new Theme({ colors: ['red', 'green', 'blue'] });
theme.rotateColor('purple'); // purple, green, blue
theme.rotateColor('orange'); // purple, orange, blue
theme.activePalette.ageOrder; // [2, 0, 1]: blue is nextChange the interpolation mode, generating a different set of intermediary colors, to which the theme will gradually transition.
// Set the interpolation mode to LAB
theme.setMode('lab');
// Rotate the interpolation mode
theme.rotateMode();Update all options at once while still smoothly transitioning. The mode is optional and defaults to the current mode.
theme.update({
colors: ['red', 'green', 'blue'],
mode: 'rgb',
});To end a transition at a set time, give its length in ticks with transitionDuration.
The colors ease in and out and become exactly the new palette on the last tick.
Durations count calls to tick(), so they follow the frame rate.
// Transition over exactly 360 ticks: 6 seconds at 60 ticks per second
theme.setColors(['red', 'green', 'blue'], { transitionDuration: 6 * 60 });
// Advance the transition without rotating the color wheel
theme.tick(0);
// Apply a palette at once
theme.setColors(['black'], { transitionDuration: 0 });
// Check for, or finish, a transition in progress
if (theme.isTransitioning) {
theme.finishTransition();
}A duration of 0 applies the palette before the call returns, notifying subscribers once. A value that is
not a finite number is ignored, and transitionSpeed applies instead.
Sending the same duration again for the palette the theme is already transitioning to changes nothing,
so an update can be re-sent every frame. A different duration re-times the transition to end that many
ticks from now, continuing from the current colors. A re-time, like a new target, starts the easing
again from rest, so re-send the same duration rather than a countdown of the ticks left.
transitionSpeed suits targets that change continuously.
Set the overall brightness of the theme (0 to 1).
theme.brightness = 0.6;By default, brightness darkens colors in CIELAB space, so colors keep some luminance even at brightness 0. For LEDs you may prefer linear brightness, where 0 turns the LEDs off and 0.5 is half output:
const theme = new Theme({
colors: ['red', 'green', 'blue'],
brightnessMode: 'linear',
});To sample the whole color wheel into a float array on every frame, for a lookup table or an LED
pipeline, use fillRgb. It writes the red, green and blue of every step, three numbers per color, and
creates no color objects. The values are exactly those of getColor(i).rgb(false) divided by 255, so
the color index, a transition in progress and the brightness apply as they do in getColor. Pass
max: 255 for the 0-255 values.
const lut = new Float32Array(theme.nSteps * 3);
function draw() {
theme.fillRgb(lut, { brightness: 0.8 });
// lut[3 * i], lut[3 * i + 1] and lut[3 * i + 2] are the red, green and blue
// of theme.getColor(i, { brightness: 0.8 }), from 0 to 1
theme.tick();
}Supply your own random number generator (any function returning a number
in [0, 1)) to get reproducible random palettes, for example from a seeded
PRNG:
const theme = new Theme({
nColors: 5,
random: seededRandom(42),
});Random colors within constraints
Random colors are drawn uniformly in HSV by default: any hue, any saturation, and a brightness (HSV value)
from minBrightness to 1. Uniform HSV hue is uneven to the eye, though: about 22 % of fully saturated draws
land in the greens between OKLCH hues 120° and 150°, and about 4 % in each 30° of gold or cyan. Uniform
saturation draws greys and pastels too: at a minBrightness of 0.537, about a quarter of draws are under
relative chroma 0.35.
Pass constraints to any random method to draw in OKLCH terms instead: the hue uniform over the OKLCH hues
allowed, the relative chroma uniform within a range, and the brightness uniform from minBrightness to 1,
as before. Relative chroma is a color's OKLCH chroma as a fraction of the most sRGB allows at its hue and
brightness, so every value from 0 (grey) to 1 (as saturated as sRGB gets) is a color at every hue. Every
color drawn meets its constraint.
import { hueArc } from 'colormotion';
// No greys or pastels, and no olive or lime
theme.randomTheme({
minBrightness: 0.5,
constraints: { chroma: { min: 0.5 }, avoid: [hueArc(95, 135)] },
});A constraint has three optional parts:
hues: arcs of OKLCH hue,{ center, width }with the full width in degrees, orhueArc(from, to), the arc running up from one hue to the other (hueArc(330, 30)is the reds from 330° through 0° to 30°). Left out, every hue.avoid: arcs taken out ofhues. They are ignored if they would leave no hue, so a color can always be drawn.chroma:{ min, max }, the relative chroma allowed, from 0 to 1.
constraints is one constraint for every color, or a list with one for each position in the palette.
randomTheme and new Theme({ nColors, constraints }) draw the color at position i within
constraints[i]; randomFrom keeps its seed at position 0 as it is and draws the rest from position 1;
pushRandomColor draws within the constraint for the position it adds; and rotateRandomColor within the
constraint for the position it replaces (the oldest, activePalette.ageOrder[0]). So a palette built from a
few picks can roll one color at a time and keep each color near its pick:
import { Theme, measureColor } from 'colormotion';
const theme = new Theme({
colors: ['#e8b450', '#7a1a2b', '#2b8f6b'],
mode: 'oklch',
});
const nearEachPick = theme.activePaletteHexes.map((hex) => {
const { hue, chroma } = measureColor(hex);
return {
hues: hue === null ? [] : [{ center: hue, width: 24 }], // ± 12°
chroma: { min: chroma - 0.2, max: chroma + 0.2 },
};
});
// Replace the oldest color with one near the pick in its position
theme.rotateRandomColor({ constraints: nearEachPick });To draw one color, use randomColor. awayFrom keeps it at least deltaEThreshold (CIEDE2000, 20 by
default) from other colors, as the random methods keep a new color from its neighbours: best effort, up to
100 candidates (fewer with candidateBudget), then the furthest of them.
import { randomColor } from 'colormotion';
const color = randomColor({
constraint: { hues: [hueArc(20, 60)], chroma: { min: 0.7 } },
minBrightness: 0.5,
awayFrom: ['#7a1a2b', '#e8b450'],
random: seededRandom(42),
});measureColor(color) gives a color's OKLCH hue, relative chroma and brightness (hue is null for greys),
colorFromHue({ hue, brightness, chroma }) gives the color at those measures, and
meetsConstraint(color, constraint) checks a color against a constraint.
Without constraints (for randomColor, without constraint), colors are drawn in HSV exactly as before,
so a seeded random draws the same colors as in 4.0.
Themes that keep their shape
analyzeTheme(colors) finds how a theme's hues relate: a few picks, or a whole palette. Vivid colors
(relative chroma 0.75 or more) define the hue structure, and hues less than 30° apart chain into a group.
Muted tints and tones follow the nearest group within 45°, and greys, whites and blacks (under 0.12) take
no part. The relationship follows from the groups:
| Relationship | Groups |
| ------------- | ---------------------------------------------------------------------------------- |
| neutral | none: every color is a grey, white or black |
| family | one (or three packed into less than 90°) |
| accent | two, less than 90° apart |
| contrast | two, 90° or more apart |
| bridge | three within half the wheel, spanning 90° or more: the middle one bridges the ends |
| pair-accent | three spread wider, two of them less than 90° apart |
| triad | three, every gap 90° or more |
| spectrum | four or more |
import { analyzeTheme } from 'colormotion';
// Measure the colors as they show when lifted to a brightness floor
const analysis = analyzeTheme(
['#e8b450', '#f4dca8', '#7a1a2b', '#b8862f', '#2b1a12'],
{ minBrightness: 0.537 },
);
analysis.relationship; // 'accent': gold, champagne and brass against oxblood and umber, 63° apart
analysis.anchor; // 79.2…: the OKLCH hue of the heaviest group
analysis.picks[4].role; // 'muted': the umber follows the oxbloodThe result is plain JSON, and the thresholds are options (neutralChroma, structuralChroma,
familyJoin, attachWithin and contrastFrom). It depends on the colors, not their order.
A relationship template turns an analysis into one slot per color: an arc of hue relative to the anchor,
and a band of relative chroma that keeps the color in its role. adjacentConstraints(analysis) places it
at the theme's own anchor, so each position's constraint is the neighbourhood of its color: its hue ± 12°
(plus a quarter of its group's span, up to ± 20°) and its relative chroma ± 0.2. Rolling one color at a time
within them keeps the theme near its colors:
import { Theme, adjacentConstraints, analyzeTheme } from 'colormotion';
const theme = new Theme({
colors: ['#e8b450', '#f4dca8', '#7a1a2b', '#b8862f', '#2b1a12'],
mode: 'oklch',
});
const nearEachColor = adjacentConstraints(
analyzeTheme(theme.activePaletteHexes, { minBrightness: 0.5 }),
);
// Replace the oldest color with one near the color in its position
theme.rotateRandomColor({ minBrightness: 0.5, constraints: nearEachColor });The slots are narrowed where they have to be, so that no palette drawn within them changes the relationship or the number of groups: two groups are never drawn close enough to merge, a group's colors never far enough apart to split, a muted color never far enough from its group to go off on its own, and where a theme sits near a threshold (two groups nearly 90° apart, say), no group can move across it (where the colors' chroma alone could move it, their chroma bands narrow too). That holds for colors as drawn: rounded to 8 bits, a hue can move by a degree or so, and more for a muted color.
To grow a few picks into more colors, pass nColors. The extra slots go to the picks by relative chroma
(neutral picks get none), in a run after each pick's own slot, template.pickSlots[j]:
import {
analyzeTheme,
randomColor,
relationshipTemplate,
templateConstraints,
} from 'colormotion';
const picks = ['#e8b450', '#7a1a2b'];
const analysis = analyzeTheme(picks, { minBrightness: 0.5 });
const template = relationshipTemplate(analysis, { nColors: 5 });
const constraints = templateConstraints(template, analysis.anchor ?? 0);
// The picks in their own slots, and a color near the pick in each of the others
const colors: string[] = [];
template.slots.forEach((_, i) => {
const pick = template.pickSlots!.indexOf(i);
colors.push(
pick >= 0
? picks[pick]
: randomColor({
constraint: constraints[i],
minBrightness: 0.5,
awayFrom: [colors[i - 1]],
}).hex(),
);
});
// gold, a color near gold, oxblood, and two near oxblood (the more saturated pick gets the extra)randomLike(analysis) draws a palette in the same relationship at another anchor hue, optionally mirrored:
a contrast pair stays a contrast pair, a bridge a bridge. Each palette is analyzed again, as drawn and
rounded to 8 bits, and redrawn until it matches (up to attempts, 16 by default). Keep the constraints it
returns to roll single colors in the new shape:
import { hueArc, randomLike } from 'colormotion';
const anchor = analysis.anchor ?? 0;
const { colors, constraints } = randomLike(analysis, {
minBrightness: 0.5,
// 30° to 90° from the theme's anchor, either way round, with no slot centered in olive or lime
anchor: [
hueArc(anchor + 30, anchor + 90),
hueArc(anchor - 90, anchor - 30),
],
avoid: [hueArc(95, 135)],
random: seededRandom(42),
});
theme.setColors(colors);
theme.rotateRandomColor({ minBrightness: 0.5, constraints });Each color is kept deltaEThreshold from its neighbours by drawing candidates until one is far enough,
up to 100. Where the slots are too narrow for that (two groups a few degrees wide at a high minBrightness,
say), every color pays all 100 candidates, 300 calls to random, and randomLike pays that for each color
of each attempt: up to 33,680 calls for 8 colors. candidateBudget bounds the whole call: each attempt may
use half of what is left of it, shared by its colors, and a color that runs out keeps the furthest candidate
it found. At 1000, 8 colors take at most 3,416 calls:
const { colors } = randomLike(analysis, {
minBrightness: 0.8,
candidateBudget: 1000,
random: seededRandom(42),
});The bound costs some spacing where the threshold is hard to reach. At 1000, the first attempt of up to 5
colors is the one drawn without a budget, but 8 colors share 500 in it, about 72 candidates for each color
after the first, so a few that would have reached the threshold within 100 do not. And since each attempt may
use half of what is left, a palette verified only after several attempts may keep neighbours close: in the
costliest case known (olive and green grown to 8 colors at a minBrightness of 0.8), 1 or 2 palettes in 100
have neighbours within 5 ΔE of each other, which none has without a budget.
The random palette methods take it too, shared by the colors they draw, and so does randomColor, though a
single color never looks at more than 100 candidates: from 99, a budget changes one only where no distance
can be measured (see RandomColorOptions.candidateBudget). Left out, every draw is as in 4.2; given, a color
that stops short leaves random elsewhere, so a seeded random draws differently after it.
With no theme to start from, relationshipTemplate(kind, { nColors }) draws a template of a kind, and
randomLike a palette from it:
theme.setColors(
randomLike(relationshipTemplate('triad', { nColors: 5 }), {
minBrightness: 0.5,
}).colors,
);All of these are pure functions of their inputs and random.
Upgrading to 4.0
Rotating a color (theme.rotateColor, theme.rotateRandomColor, palette.rotateOn and
palette.rotateRandomOn) replaces the oldest color in its position. Before 4.0, it dropped the first color
and appended the new one, which moved every color one place along the wheel, so every part of the wheel
changed color at once. To keep that behavior, shift the colors yourself:
theme.setColors([...theme.activePaletteHexes.slice(1), color]);
// or, for a ColorPalette
palette.newColors([...palette.hexes.slice(1, -1), color]);To shift in a random color, drawn at least deltaEThreshold from the last color as before 4.0, drop the
oldest color and push a random one. With the same random, this draws the same colors as 3.x did, as long
as the colors are only ever set from lists, pushed or popped (so their ages stay in list order); a palette
of one color gains a second.
theme.setColors(theme.activePalette.popOldest().pushRandom().colors);
// or, for a ColorPalette
palette.popOldest().pushRandom();Also changed:
popOldestColorandpopOldestdrop the oldest color by age: the first color unless the palette has been rotated.rotateRandomColorandrotateRandomOndraw the new color at leastdeltaEThresholdfrom both of its neighbours on the wheel, not from the last color, so a seededrandomgives different colors than in 3.x.- Rotating and popping keep the other colors as they are, at full precision (they were rounded to 8-bit
hex). Rotating, pushing and popping never take a color that matches the first for the closing repeat of
the wheel:
pushNewColoradds such a color, where it used to add nothing, and a rotation or a pop no longer loses one. - A palette can now end on a color that matches its first (rotating blue into red, green, blue gives blue,
green, blue).
activePaletteHexesleaves out the closing repeat, so given back as colors, such a list loses its last color. To give a palette's colors back as they are, passactivePalette.hexes(which ends with the closing repeat) oractivePalette.colors. - A
palettepassed to theThemeconstructor that already has the theme's settings is used as it is, rather than copied. A copy, as for any other palette, keeps the colors' ages.
Development
npm test # unit tests (watch mode)
npm run bench # benchmarks: palette building, getColor, fillRgb, transition ticks, LED frames, random draws, theme analysis
npm run docs:api # regenerate the docs site's API reference (Node 22.18 or later)The docs site shows each method's signature and every exported type, generated from the source into docs/src/generated/api.json. A test fails when a public signature or its JSDoc changes until the reference is regenerated.
To check a change for speed, save a baseline on main and compare your branch against it (the file is written under tests/):
git switch main && npm run bench -- --outputJson bench-main.json
git switch my-branch && npm run bench -- --compare bench-main.json
