moo-color
moo-color 2 is an immutable TypeScript class for parsing, converting, formatting, and adjusting colors. It accepts hex, named colors, transparent, RGB, HSL, HWB, HSV, and CMYK input; emits several string and data forms; and includes mixing, hue and lightness changes, random HWB colors, WCAG 2.1 luminance, and contrast ratios. It ships ESM, CommonJS, browser-global, and declaration builds.
moo-color 2 is a pleasant small immutable utility for conventional RGB, HSL, HWB, HSV, and CMYK work. Pick Culori when modern CSS color spaces or deeper color science are part of the requirement.
Use it if
- You want one immutable class for common color parsing, conversion, formatting, and adjustment tasks
- You need WCAG 2.1 relative luminance and the 4.5-to-1 normal-text AA check without a larger accessibility toolkit
- You consume both CSS colors and non-CSS working models such as HSV or CMYK
- You need ESM, CommonJS, a direct browser script build, and bundled TypeScript declarations from the same package
- You must support Node.js below 18: version 2.0.0 declares node >=18 and made that a documented breaking change
- You need CSS Color 4 spaces such as Lab, LCH, OKLab, OKLCH, color(), or relative colors; the accepted model list stops at rgb, hwb, hsl, hsv, and cmyk
- You are upgrading mutation-based v1 code without time for a migration: v2 makes every manipulation, setAlpha, and changeModel return a new instance, and removes setColor
- You need alpha-aware accessibility decisions: contrastRatioWith calculates each color's RGB luminance directly and does not composite transparency against a background
- You expect TypeScript to prove a color string is valid: the public types accept # followed by any string and also include string & {}, so malformed values still fail only when the constructor throws
Setup reality
Install moo-color and import the named MooColor class; a default export also exists, and CommonJS uses const { MooColor } = require('moo-color'). Version 2 requires Node 18 or newer, has one dependency on color-name, and needs no native build, peer package, config, or credentials. Browser users can load dist/moo-color.global.js and receive window.MooColor, but a normal bundler should use the package export. The migration trap is v2 immutability. color.lighten(20), color.setAlpha(0.5), and color.changeModel('hsl') no longer change color; save the returned instance or chain it. setColor was removed, so replace it with new MooColor(nextValue). Invalid strings throw from the constructor rather than producing an invalid-state object, and the broad TypeScript input type does not remove the need for try/catch around user input. The supported parser list does not cover newer Lab and OKLCH syntax, and examples use comma-separated functional forms, so test any CSS syntax copied directly from modern stylesheets. Contrast helpers implement the normal-text AA threshold of 4.5 only. They do not expose the 3-to-1 large-text threshold or 7-to-1 AAA threshold, and they ignore alpha compositing; first blend translucent foregrounds against the actual background. Values are converted through RGB for most model pairs, so repeated cross-model edits can accumulate rounding differences. Formatting chooses rgba, hsla, or hex alpha automatically when opacity is below one.
Patterns
Parse a user-provided color safelyparse-user-color
import { MooColor } from 'moo-color'
function parseColor(input) {
try {
return new MooColor(input)
} catch {
return null
}
}The constructor throws for an unrecognized string; TypeScript's ColorInput type still permits arbitrary named-color strings.
Format one color several waysformat-color
const color = new MooColor('rgba(255, 128, 0, 0.5)')
console.log(color.toHex())
console.log(color.toRgb('percent'))
console.log(color.toHsl())
console.log(color.toHwb())Opacity is preserved and causes alpha-bearing output such as eight-digit hex, rgba, or hsla.
Prefer short hex or an exact CSS nameuse-short-or-named-hex
const red = new MooColor('#ff0000')
red.toHex('short') // '#f00'
red.toHex('name') // 'red'name mode only returns a name for an exact opaque table match; otherwise it falls back to hexadecimal.
Chain immutable color adjustmentschain-immutable-edits
const base = new MooColor('hsl(210, 60%, 40%)')
const accent = base
.lighten(15)
.saturate(10)
.rotate(20)
console.log(base.toHsl())
console.log(accent.toHsl())Version 2 returns a new instance at every step. The base value remains unchanged.
Retain the result of a v2 manipulationmigrate-v1-mutation
let color = new MooColor('#336699')
color = color.lighten(10)
color = color.setAlpha(0.8)Calling these methods without assignment was useful in v1 but silently leaves the original unchanged in v2.
Read converted numeric color datainspect-color-data
const color = new MooColor('#ff8000')
const hsl = color.getColorAs('hsl')
console.log(hsl.model, hsl.values, hsl.alpha)getColor and getColorAs return copies, so changing the values array does not mutate the MooColor instance.
Create an instance stored in another modelchange-working-model
const rgb = new MooColor('#663399')
const hwb = rgb.changeModel('hwb')
console.log(rgb.getModel()) // 'rgb'
console.log(hwb.getModel()) // 'hwb'changeModel is immutable in v2. Most conversions pass through RGB, except direct HSV and HWB conversion.
Check the normal-text AA thresholdcheck-wcag-contrast
const foreground = new MooColor('#1f2937')
const background = new MooColor('#ffffff')
const ratio = foreground.contrastRatioWith(background)
const passesNormalAA = foreground.isContrastEnough(background)isContrastEnough uses a fixed 4.5 threshold. Apply 3 for qualifying large text or 7 for AAA yourself.
Composite transparency before a contrast checkhandle-alpha-contrast
const page = new MooColor('#ffffff')
const translucent = new MooColor('rgba(0, 0, 0, 0.5)')
const effective = page.mix(translucent, translucent.getAlpha() * 100)
console.log(effective.contrastRatioWith(page))contrastRatioWith ignores alpha. This blend approximates the visible foreground on an opaque page before calculating contrast.
Blend two colors with explicit weightingmix-two-colors
const red = new MooColor('red')
const blue = new MooColor('blue')
const mostlyRed = MooColor.mix(red, blue, 75)
const mostlyBlue = red.mix(blue, 75)Static mix weights its first color; instance mix weights the color passed as the argument. The percent meanings are intentionally different.
Generate a constrained random colorcreate-random-color
const warmTint = MooColor.random({
hue: [0, 60],
white: [10, 30],
black: 5
})
console.log(warmTint.toHex())Random generation operates in HWB. Each option accepts a fixed number or an inclusive-looking min and max tuple.
Load version 2 from CommonJSuse-commonjs
const { MooColor } = require('moo-color')
const color = new MooColor('cmyk(0%, 100%, 100%, 0%)')
console.log(color.toRgb())Version 2 publishes a dedicated CommonJS file and declarations alongside its ESM package. Node 18 or newer is required.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| culori | npm | Modern color science needs Lab, LCH, OKLab, OKLCH, gamut mapping, interpolation, or a function-based API |
| chroma-js | npm | Data visualization needs scales, domain mapping, interpolation modes, and palette generation |
| color | npm | You want a mature chainable immutable color object with a broad existing user base |
| tinycolor2 | npm | Legacy browser code values a long-established permissive parser and tinycolor-compatible API |