Library / SDK
texel-org/color avatar
texel-org/color

@texel/color: a fast, allocation-free colour library for JavaScript

modern color library for image processing and creative coding (oklab, xyz, p3, rec2020 + more)

535 stars14 forksJavaScriptMIT

At a glance

What is it?
@texel/color converts between seventeen colour spaces including OKLab, Display P3 and Rec 2020, does gamut mapping by the cusp-clipping method, and runs without allocating a single array or object in the hot path. Zero dependencies, and a bundle of about 3.5kb once tree-shaken.
Who is it for?
Adopt @texel/color if you are doing real-time colour work in the browser, whether generative art, a picker or a Canvas2D scene, since the allocation-free API and the tree-shakeable colour space set keep the hot path cheap. Do not adopt it if you need a full colour management stack, since it implements a focused set of spaces rather than every ICC workflow.
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 144 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 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the library is, and who it is for

The package is described as a minimal and modern color library for JavaScript, especially useful for real-time applications, generative art, and graphics on the web. The description in the repository metadata calls it a modern color library for image processing and creative coding, with oklab, xyz, p3, rec2020 and more.

That framing tells you what the design is optimising for. A general colour management library is built for correctness across every profile and every workflow. This one is built for a specific job: converting colours, fast, in code that runs at frame rate.

The feature list is four items: fast color conversion, color difference, gamut mapping, and serialization. The primary language is JavaScript, the licence is MIT, the default branch is main, and the package is published to npm as @texel/color at version 1.1.11 with type module and the types entry at types.d.ts. There are no GitHub releases.

The author is Matt DesLauriers, and the repository layout shows a small, focused package: src/ for the implementation, test/ for the tests and benchmarks, docs/ for generated documentation, tools/ for the doc tooling configuration, a DOC.md file and types.d.ts at the root.

The colour space list is where the scope becomes precise. It is xyz (D65), xyz-d50, oklab, oklch, okhsv, okhsl, srgb, srgb-linear, display-p3, display-p3-linear, rec2020, rec2020-linear, a98-rgb, a98-rgb-linear, prophoto-rgb and prophoto-rgb-linear. Every space comes with its linear-light counterpart, which is the detail that matters for correctness in a renderer: sRGB, Display P3, Rec 2020, Adobe RGB and ProPhoto RGB are all defined as gamma-encoded encodings of a linear light space, and mixing the two up is one of the classic ways to get washed out or over-saturated results.

The library describes itself as focused on a minimal and modern set of color spaces, which is a deliberate narrowing. There is no CMYK, no Lab or LCH in the CIE sense, no ICC profile handling, and no spot colours.

Speed as a design constraint, not a benchmark result

The performance claims are stated as part of the design rather than as an afterthought, and they come in three forms.

The headline claim is that the library is approximately 5 to 125 times faster than Colorjs.io, with a link to a benchmarks section in the README. The range that wide is a signal that the comparison depends heavily on which operation and which colour space, which is true of colour maths: conversion between two spaces with a matrix is cheap, while conversion that involves a transfer function and a chromatic adaptation is not.

The second claim is about memory rather than speed. The library is optimized for low memory and minimal allocations, and the specific statement is that no arrays or objects are created within conversion and gamut mapping functions. That is a stronger claim than being fast, because it changes the shape of the API rather than just the implementation.

The third is bundle size. It has zero dependencies, and unused color spaces can be automatically tree-shaken away for small sizes, with the README giving about 3.5kb minified if you only require OKLCH to sRGB conversion.

That last number is worth taking seriously, because tree-shaking a colour library is harder than it sounds. A library that exposes one universal convert function taking a space object as an argument cannot be tree-shaken, since every space has to be reachable from that one call. This one exposes separate named space objects, one per colour space, and separate named functions, so a bundler can see that a program references only OKLCH and sRGB and drop everything else.

The README makes this concrete with the wildcard import case, noting that modern bundlers such as esbuild and vite will apply tree-shaking and remove any features that are not needed, such as color spaces and gamut mapping functions that you did not reference in your code, and that the above script results in a ~3.5kb minified bundle with esbuild.

The API shape supports this. convert takes a fromSpace and a toSpace, and the spaces are named exports, so a program that imports only OKLCH and sRGB never references the matrix for Rec 2020. The conversion matrices are generated code, in src/conversion_matrices.js, produced by a Python script at tools/print_matrices.py, which is also how the high precision matrices described in the accuracy section are kept in the repository rather than typed by hand.

Converting, and the out parameter that avoids allocation

The core function is documented with its full signature.

js
const rgb = convert([0.5, 0.15, 30], OKLCH, sRGB);

That is convert(coords, fromSpace, toSpace, output = [0, 0, 0]). It converts the coords, typically [r,g,b] or [l,c,h] or similar, expected to be in fromSpace, to the specified toSpace. The from and to spaces are one of the spaces interfaces.

The output parameter is the interesting part of the design. You can pass output, which is a 3 dimensional vector, and the result will be stored into it, and this can be used to avoid allocating any new memory in hot code paths.

So the library offers both ergonomics and control. Call it with two arguments and you get a fresh array back, which is what you want in application code. Call it with a third and it writes into your array, which is what you want in a render loop where you convert thousands of colours per frame.

The return value is the new coordinates in the destination space, such as [r,g,b] if the sRGB space is the target. There is an important caveat about ranges: most spaces use normalized and unbounded coordinates, so RGB spaces are in the range 0 to 1 and might be out of bounds, that is, out of gamut. The README says it is likely you will want to combine this with gamutMapOKLCH.

That sentence is the single most important thing in the documentation for a newcomer. Converting an OKLCH colour with a high chroma to sRGB gives you coordinates that fall outside 0 to 1, and those coordinates are mathematically correct but not displayable. Naively clamping them produces the well-known result where saturated colours look washed out and hue shifts, because clamping channels independently changes the hue.

The coordinates in the example are commented to show the ranges. L runs 0 to 1, C runs 0 to 0.4 and H runs 0 to 360 in degrees. The sRGB output is in range 0 to 1, and for the input [0.5, 0.15, 30] the result is [0.658, 0.217, 0.165].

Wildcard imports are also supported, with import * as colors and then colors.convert and colors.OKLCH, which is the case where tree-shaking still works because the bundler can see which properties are used.

Gamut mapping, and why the mapping strategy changes your colours

The gamut mapping function is the part of the library with the most design content behind it.

js
const rgb = gamutMapOKLCH(oklch, gamut);

That is gamutMapOKLCH(oklch, gamut = sRGBGamut, targetSpace = gamut.space, out = [0, 0, 0], mapping = MapToCuspL, [cusp]). It performs fast gamut mapping in OKLCH as described by Björn Ottosson in 2021. This takes an input [l,c,h] coords in OKLCH space, and ensures the final result will lie within the specified color gamut, defaulting to sRGBGamut.

The technique matters. Clipping a colour to the sRGB gamut by clamping channels is the naive approach and it shifts hue. The cusp-clipping method instead finds the most chromatic colour with the same lightness and hue that still fits in the gamut, and scales chroma toward it. The result keeps the hue and the lightness and gives up chroma, which is what a designer expects when a brand colour is out of gamut.

The mapping parameter selects the strategy, and there are five.

js
import {
  // possible mappings
  MapToL,
  MapToGray,
  MapToCuspL,
  MapToAdaptiveGray,
  MapToAdaptiveCuspL,
} from "@texel/color";

// preserve lightness when performing sRGB gamut mapping
const rgb = [0, 0, 0];
gamutMapOKLCH(oklch, sRGBGamut, sRGB, rgb, MapToL);

MapToCuspL is the default. MapToL preserves lightness explicitly. MapToGray maps to a grey with the same lightness when the chroma has to go to zero, and the two Adaptive variants presumably trade between the two depending on the input.

The targetSpace parameter decouples the gamut from the output encoding, and the README shows both useful cases.

js
import { gamutMapOKLCH, sRGBGamut, sRGBLinear, OKLCH } from "@texel/color";

// gamut map to sRGB but return linear sRGB
const lrgb = gamutMapOKLCH(oklch, sRGBGamut, sRGBLinear);

// or gamut map to sRGB but return OKLCH (does not perform RGB clip)
const lch = gamutMapOKLCH(oklch, sRGBGamut, OKLCH);

Returning linear sRGB avoids applying the transfer function, which is what you want when feeding a renderer that expects linear light. Returning OKLCH keeps the result in a perceptual space.

There is a note attached to that behaviour. If you map to an OKLab-based target such as OKLCH or OKHSL, the final step of RGB clipping will be skipped, which produces more predictable OKLab and OKLCH based results, but you will likely want to perform a final clampedRGB() step when converting to a displayable color.

So the library will hand you a colour that is out of the display gamut if you ask it to, on the understanding that you know what you are doing.

The cusp optimisation and the serialisation path

The cusp is the second piece of the performance story, and it is the kind of optimisation that only exists because the library cares about a specific workload.

js
const gamut = sRGBGamut;

// compute cusp once for this hue
const H = 30; // e.g. 30º hue
const hueAngle = degToRad(H);
const a = Math.cos(hueAngle);
const b = Math.sin(hueAngle);
const cuspLC = findCuspOKLCH(a, b, gamut);

// ... somewhere else in your program ...
// pass 'cusp' parameter for faster evaluation
// expected that your OKLCH coord has the same hue as the cusp (H)
gamutMapOKLCH(oklch, gamut, gamut.space, out, MapToCuspL, cuspLC);

findCuspOKLCH(a, b, gamut, out = [0, 0]) finds the cusp of a given OKLab hue plane, denoted with normalized a and b values in OKLab space, returning the [L, C] lightness and chroma. It is useful for pre-computing aspects of gamut mapping when you are working across a known hue.

The performance argument is that the cusp is a function of hue, so if your colour scheme lives in a small set of hues, you compute the cusp once per hue and reuse it. The comment in the example is explicit about the contract: the expected OKLCH coord has the same hue as the cusp.

Note the precondition on the inputs, stated separately: the a and b can also be from OKLab coordinates, but must be normalized so a squared plus b squared equals 1. The example derives them from the hue angle with a cosine and a sine, which is exactly the unit-circle parameterisation of the hue plane.

The other half of the output path is serialize.

js
const color = serialize(rgb, gamut.space);

That is serialize(coords, inputSpace, outputSpace = inputSpace). It turns the specified coords, assumed to be in inputSpace, into a string, first converting if needed to the specified outputSpace. If the space is sRGB, a plain rgb(r,g,b) string in bytes will be used for browser compatibility and performance, otherwise a CSS color string will be returned.

That last detail is a practical decision rather than a stylistic one. Browsers parse rgb() faster than they parse color(), and the sRGB path is the overwhelmingly common one, so it gets the cheap format. The README notes that not all spaces, such as certain linear spaces, are currently supported by CSS, and that you can optionally pass an alpha value.

Where @texel/color is the wrong choice

There are three limits, and the first is about what a colour library can and cannot do for you.

The first is that the colour space set is deliberately narrow. There is no CIE Lab or LCH, no CMYK, no ICC profile handling and no spot colours. For screen work in a browser or a canvas that is the right set. For print workflows, for anything that needs to go through a CMYK profile, or for a pipeline that has to read an ICC file and preserve its intent, this library is the wrong tool and the reason is not effort but scope. The README says as much, describing the project as focused on a minimal and modern set of color spaces.

The second is that gamut mapping is a judgement call, and the library makes it for you by default. The default mapping is MapToCuspL, which preserves lightness and hue and discards chroma. That is the right default for most design work, and it is the wrong default for work where chroma is the point, for example a data visualisation that encodes a third variable in saturation. The adaptive variants exist for this, but only if you know to look for them. The same applies to the fact that converting to an OKLab-based target skips RGB clipping, which the README flags and which is easy to miss.

The third is the maintenance picture. The last push was on 2026-06-01 and the repository has no GitHub releases at all, so the npm version at 1.1.11 is the only version marker. A library without tagged releases gives you no changelog to consult and no release notes to read when a behaviour changes, and the version number alone does not tell you whether a change was a bug fix or a behavioural difference in the gamut mapping.

The honest alternative is culori, which appears in the dev dependencies and is the reference point for comparison. The difference in approach is that culori is a broader colour library with a wider space set, a plugin architecture and a more conventional API, while this one has a narrow set of spaces with hand-written conversion paths, generated high precision matrices and an allocation-free API designed for frame-rate use. The README's own benchmark compares against colorjs.io, another broad library, and the package includes a bench-colorjs script and a bench-size script that pipes an esbuild bundle through wc -c, so the claims are checkable rather than asserted.

That is the real trade. If you are converting a handful of colours in a form handler, either library is fine and the broader one will serve you better. If you are converting thousands of colours per frame, the allocation-free API and the tree-shakeable space set are what you are buying, and the narrow scope is what you are paying for.

Editorial conclusion

Adopt @texel/color if you are doing real-time colour work in the browser, whether generative art, a picker or a Canvas2D scene, since the allocation-free API and the tree-shakeable colour space set keep the hot path cheap. Do not adopt it if you need a full colour management stack, since it implements a focused set of spaces rather than every ICC workflow. Before you start, read the gamut mapping section, because the default mapping strategy changes the lightness of your colours, and note the last push was on 2026-06-01 with no tagged releases.

Frequently asked questions

What is @texel/color and what colour spaces does it support?

It is a minimal and modern colour library for JavaScript, aimed at real-time applications, generative art and graphics on the web. The supported spaces are xyz (D65), xyz-d50, oklab, oklch, okhsv, okhsl, srgb, srgb-linear, display-p3, display-p3-linear, rec2020, rec2020-linear, a98-rgb, a98-rgb-linear, prophoto-rgb and prophoto-rgb-linear.

How do I install and use @texel/color?

Install it with npm install @texel/color --save, then import the convert function and the space objects you need, for example import { convert, OKLCH, sRGB } from "@texel/color". The README shows that converting [0.5, 0.15, 30] from OKLCH to sRGB gives [0.658, 0.217, 0.165], with sRGB output in the range 0 to 1.

How does @texel/color avoid allocating memory in hot paths?

The README states that no arrays or objects are created within conversion and gamut mapping functions. You pass an output array to convert and to gamutMapOKLCH and the result is written into it, which the documentation describes as usable to avoid allocating any new memory in hot code paths.

What gamut mapping strategies does @texel/color offer?

The mapping parameter selects the strategy and can be MapToL, MapToGray, MapToCuspL, MapToAdaptiveGray or MapToAdaptiveCuspL, with MapToCuspL as the default. The mapping uses the cusp-clipping method described by Björn Ottosson in 2021, which preserves lightness and hue and reduces chroma rather than clamping channels.

How do I make @texel/color faster for a fixed hue?

Use findCuspOKLCH to compute the cusp for the hue once and pass it as the final parameter to gamutMapOKLCH, which the README says allows for faster evaluation for known hues. The a and b inputs must be normalized so that a squared plus b squared equals 1, and the OKLCH coordinate is expected to have the same hue as the cusp.

Official sources

  1. Issues
  2. License: MIT
  3. README
  4. texel-org/color on GitHub
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/texel-org-color.svg)](https://hysenlabs.com/projects/texel-org-color)

Was this useful?