Library / SDK
color-js/color.js avatar
color-js/color.js

Color.js: a colour library that is also a working proposal for the platform

Color conversion & manipulation library by the editors of the CSS Color specifications

2,308 stars100 forksJavaScriptMIT

At a glance

What is it?
This is a colour conversion and manipulation library written by two of the editors of the CSS Color specifications, used by browsers to test their own implementations and by the accessibility testing engine that most compliance work depends on. The line in its readme that matters most is the one admitting that parts of its API are a testing ground for a native platform colour object, which explains both the design and the size of its surface.
Who is it for?
Color.js is the right choice if you are doing anything where a naive channel clamp will produce a visibly wrong colour, because it implements proper gamut mapping and lets you choose both a difference metric and an adaptation method rather than accepting a default you did not know you were accepting.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 5 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 2, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A library that is also a proposal

The provenance is stated in the first paragraph and it is not decorative. The library was created by two of the editors of the CSS Color specifications, and they are still working on it, joined by what the readme calls a small grassroots team of co-maintainers.

Installation is one command:

code
npm install colorjs.io

Then there is an impact section, and the most consequential line in it is the last. Parts of this library's API are being used as a testing ground for the design of a native colour object for the web platform. In other words, the shape of the API here is a proposal, being validated in public by being implemented.

That single fact explains almost everything else about the library, including the parts that would otherwise look over-engineered for a colour helper.

It explains the breadth. A colour conversion library for an application needs a handful of spaces and a difference metric. A proposal for a platform primitive needs every space the specification defines, every interpolation method, every way of measuring difference, and a conversion path for input and output syntax that is complete rather than convenient. The library ships Lab and its cylindrical form, a perceptual uniform space and its cylindrical form, the classic hue-based variants, a wide-gamut display space, a high-dynamic-range viewing space, and the standard's own transfer function, and the readme says and more, which is the phrasing of a list that is not finished.

It explains the two entry points. There is an object-oriented interface where a colour is something you hold and operate on repeatedly, and a separate functional interface described as procedural and tree-shakeable for performance-sensitive work. A library with one audience would ship one. A library proposing a platform primitive has to serve both a person reading a stylesheet's worth of colour values and a program converting a million pixels.

It also explains the risk. If the platform ships a native colour object with a different shape, this API is a candidate for replacement rather than a compatibility layer, and the authors have said as much by describing it as a test. Anyone depending on it for a long-lived application is depending on an API that is explicitly provisional.

The coordinate accessors are generated, which is why the API feels uniform

The most striking thing about the interface is that you can reach into any coordinate of any colour space through a property, in any space, and the readme demonstrates it casually on three different space models in three lines.

Set the lightness through the cylindrical perceptual space. Multiply the chroma of that same space by a factor. Add ten to a coordinate of a hue-based space. All three are property assignments on the same object, and all three are shown as the ordinary way to do it.

That does not work by hand. The dependency list and the build scripts together explain how it works. One of the build steps generates the space accessors, and it runs before the type build and before the linter on every lint invocation, including the one used in continuous integration. The space definitions are the source, and the accessors are derived from them.

This is a much better design than it first appears, for a reason that is about correctness rather than convenience. If you hand-wrote twenty accessors for twenty spaces, you would write the first four carefully and then copy and paste, and the copies would drift. Some would forget the round-trip conversion between the space's own coordinates and the underlying storage. Some would handle alpha and some would not. Some would be one assignment and some would be a getter. The generated approach makes the inconsistency impossible, and it means adding a colour space gives you a complete, consistent interface for free.

The same reasoning explains the three levels of access in the readme, which look like redundant options until you see that they are three different needs. Direct property assignment is the fastest thing to type. A setter with prefixed coordinate names lets you target a space other than the object's current one, which is what you want when you are converting and adjusting in a single step. And the same setter without the prefix operates in the object's own space, which is what you want when you have already converted.

The prefixed form also accepts a function rather than a value, which is the API decision I would single out. Passing a function that receives the current coordinate and returns a new one means the read and the write are one operation, so you never write the line where you read a coordinate, transform it in your head, and assign it back. That pattern is safe when there is one caller and wrong when there are two, and a library that makes the function form the primary one has removed a class of bug rather than documented around it.

Gamut mapping instead of clipping, and four ways to measure the difference

The feature list has a line that is a statement of intent rather than a feature: it does not gloss over the underlying science. What follows is the specific version of that claim, and it is the most technically substantive thing in the readme.

The first half is about gamut. The default behaviour of essentially every colour operation is to clamp: take a colour outside what the display can show and clip each channel to its limit. Clipping is fast, free, and wrong in a specific way. A saturated red outside the display gamut has a channel above one; clipping that channel produces a different hue, so a brand colour that was carefully chosen comes out looking like a different brand colour. The alternative is gamut mapping, which finds the nearest colour that is actually representable while preserving as much of the original appearance as possible, and the readme links to that rather than describing it in a sentence, which implies the algorithm is documented on its own page.

The second half is about measurement. The library implements four methods for computing the perceptual difference between two colours, including two successive revisions of the industry standard and the standard's own high-dynamic-range variant, plus a hue-based one. And it implements four methods for chromatic adaptation, which is the transform that accounts for a difference in viewing illuminant between a colour defined under one light and rendered under another, including the classical transform and three successors.

Neither list is padding. The reason they matter is that they are the parameters of the question you are actually asking. Two colours that are a small distance apart under one difference method and a large distance apart under another will lead you to a different design decision, and the choice of adaptation method changes your results if your colours were captured under a different light than they are displayed under. A library that hardcodes one of each is making that decision for you silently. This one exposes both and states that the defaults are sensible, which is the right balance: you get an answer immediately and a lever when you need one.

There is also a small piece of user-facing honesty in the feature list. The entry for being dependency free is followed by a parenthetical saying there is nothing wrong with dependencies, but it should be mentioned. For a library with three hundred million downloads, that line is the author being aware that the claim is also a marketing point, and saying so before you point it out.

Four ways in, and one of them works in one module system only

The package manifest defines an exports map with several entry points, and reading it is more informative than the installation section, because the installation section is written for people who want it to be simple.

There is the main entry, and it is asymmetric in an interesting way. The condition for a module import resolves to a file inside the source directory, not to a build. The condition for a require resolves to a bundled file built by a bundler. So a project using modules gets unbundled source, which their own bundler will process and tree-shake, and a project using the older loading mechanism gets a single pre-built file.

That is a considered choice rather than the usual arrangement, and it is the right one. Handing a modern bundler a pre-built bundle means it cannot tree-shake, and handing it source means it can. Handing a CommonJS consumer source would not work, so that path gets the bundle. The two paths are optimised for their consumers rather than for the author's convenience.

There is a second entry point with its own pair of files, and that is the procedural interface mentioned earlier. There is a third, which is the collection of colour space modules, and it exists only for module imports. There is a pattern entry for anything under the source directory, which is what makes the readme's deep import examples work, and a pattern entry for the built files.

That third asymmetry is a real, if small, defect. A project using the older loading mechanism can require the main bundle and can require the procedural bundle, but cannot require an individual colour space module, because no condition exists for it. If you are on that loading mechanism and you want one space without the rest, the deep import path is not available to you through the manifest.

There is one more piece of configuration worth noting, because it is the kind of thing that only matters if you have hit it. A versions field redirects one of the subpaths for type resolution, which is the compatibility shim for tooling that does not understand per-condition types. Its presence alongside a modern exports map with per-condition types in every branch suggests the project is supporting a spread of type-aware toolsets, which for a library with this many downloads is not vanity.

The types themselves are shipped as files, and the manifest points at a types directory rather than at a single declaration file, which means a consumer's editor can resolve deep type imports as well as the top-level one.

Hand-written types, and a test suite for the types

Most JavaScript libraries that ship type declarations generate them from annotations in the source. This one ships a types directory that appears to be maintained separately, and the build scripts include a step that type checks that directory, a step that lints it, and a script that runs all three.

There is a test script for the types. Not a type check, which catches a declaration that does not compile, but a lint and a check as a group, wrapped in a script that you would run in continuous integration alongside the runtime tests.

The reason to do this is well known to anybody who has maintained a JavaScript library with hand-written declarations, and it is worth spelling out because the failure mode is quiet. A declaration file is a promise about behaviour. Nothing enforces the promise. A function that takes a colour space name can be declared as taking any string, and the library will throw on an unknown one; a test that asserts the declaration rejects an invalid value is the only way to notice the declaration has drifted into being useless. A declaration that says a property is writable when the setter is read-only is worse than no declaration, because the type checker now endorses code that fails at runtime.

So this project treats its type surface as a product with its own test suite. That is a meaningfully higher standard than most, and it is consistent with the rest of the repository, which also includes a benchmarks directory, a notebook directory, a release artefacts directory, and a test directory in addition to the tests directory the manifest points at.

That last point is a small inconsistency rather than an insight: the test script in the manifest points at one of the two test directories, and a field in the same manifest points at the other. It is the kind of thing that happens when a directory is renamed and a configuration field is missed, and it is harmless as long as you know which one the script actually uses.

The documentation shows one operation four ways, on purpose

The manipulation section is the longest part of the visible readme, and the reason is structural rather than accidental. It performs the same four operations, setting a lightness, scaling a chroma, setting a hue, and doing it in a space other than the object's own, four separate times, using four different styles.

The first pass uses direct property assignment. The second constructs a colour and uses the setter with prefixed coordinate names, showing both a literal value and a function for relative change. The third converts the object to a space and uses the setter with unprefixed names. The fourth uses the chaining form, where each call returns a new colour rather than mutating.

Four examples of the same thing is a documentation decision, and it is a good one. A reader who has just met the library can pick the style that matches how they think, and a reader who has been using it for two years can skip to the section that added something new. The alternative, documenting each style once in isolation, would make the reader assemble the comparison themselves.

It also exposes two real semantic differences that are easy to miss. The unprefixed form only works after you have converted the object, because the names it accepts are the coordinates of the object's current space. And the chaining form returns new objects rather than mutating, which is the opposite of the property assignment form. So this is not four spellings of one thing. It is four things, and the readme's repetition is how you find out which is which.

That distinction matters for the API's future. If a native platform colour object ships, the mutation semantics have to be decided, and immutable chaining is much easier to implement over a platform primitive than mutation is, because a platform object may expose read-only properties. The library supports both, which means the application that chose chaining will port and the one that chose mutation will not.

Editorial conclusion

Color.js is the right choice if you are doing anything where a naive channel clamp will produce a visibly wrong colour, because it implements proper gamut mapping and lets you choose both a difference metric and an adaptation method rather than accepting a default you did not know you were accepting. It is a poor fit if you want a two kilobyte helper, since the object-oriented entry point carries the full space set and the functional entry point exists precisely because the other one is too big for that job. Treat the API as provisional, since the authors describe parts of it as a testing ground for a platform feature that may land with a different shape, and pin the version rather than tracking the default branch, which is receiving commits a couple of days old.

Frequently asked questions

Who maintains Color.js and why does that matter?

It was created by two of the editors of the CSS Color specifications and is still maintained by them with a small team of co-maintainers. The readme also states that parts of the API are used as a testing ground for a native colour object for the web platform, so the interface is a proposal as well as a library.

How does Color.js handle colours outside a display's gamut?

With gamut mapping rather than naive clipping, which preserves as much of the original appearance as possible instead of clamping each channel and shifting the hue. The library also implements four methods for perceptual difference and four for chromatic adaptation, with defaults chosen so a simple case needs no configuration.

How do I import a single colour space from Color.js?

Import the individual module by path, from the CDN root or from inside the installed package. The package manifest exposes a subpath for the collection of colour spaces for module imports only, and a pattern entry that maps anything under the source directory, which is what makes the per-space deep imports work.

Does Color.js have any dependencies?

None. The feature list calls this out with the aside that there is nothing wrong with dependencies but it should be mentioned. The package ships a built distribution, the source directory, and a separately maintained types directory, with a bundler used only for the older CommonJS loading path.

Why does Color.js offer both an object API and a functional API?

The object interface is for holding a colour and performing several operations on it, and the separate functional entry point is described as procedural and tree-shakeable for performance-sensitive work and smaller bundles. A library that also serves as a platform proposal has to work for both a person reading a handful of values and a program converting a large number of pixels.

Official sources

  1. color-js/color.js on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/color-js-color-js.svg)](https://hysenlabs.com/projects/color-js-color-js)