Skip to content

Benchmarks

@urcolor/core against the color libraries people actually reach for, plus the canvas grid samplers @urcolor/shared layers on top. Every number comes from the suites in packages/core/bench, regenerated with bun run --cwd packages/core bench:report.

Read the groups, not the totals

No library wins everywhere. These libraries trade spec coverage, perceptual accuracy and object model against each other, so a group urcolor loses says something about one of those choices rather than about the library. Where a comparison is not like for like, the note above the group says so.

A library missing from a group cannot express that operation at all. Nothing was dropped for being slow.

Each chart plots the fast end of its group. Bars past 10× the leader are named underneath and printed in the table, because one very slow bar flattens every other one to a pixel. Bar labels are in the unit named on the axis, rounded to three figures; the table under each chart has the rest.

Every library keeps the same color on every chart:

urcolorculorichroma-jscolorjs.iocolordtinycolor2@ctrl/tinycolor

Setup

Measured on Apple M1 at ~0.91 GHz, bun 1.3.0 (arm64-darwin), with mitata. Times are the mean per operation.

LibraryVersion
@urcolor/core2.0.0
@urcolor/shared1.0.0
culori4.0.2
chroma-js3.2.0
colorjs.io0.7.1
colord2.9.3
tinycolor21.6.0
@ctrl/tinycolor4.2.0
sh
# every suite
bun run --cwd packages/core bench

# one suite at a time
bun run --cwd packages/core bench parse convert

# regenerate this page
bun run --cwd packages/core bench:report

Each row runs against a rotating pool of eight colors. Against a single constant the JIT inlines the smaller operations and hoists them out of the measurement loop: an early draft of this suite had tinycolor2.toRgbString() "running" in 0.17 picoseconds. The rotation costs about a nanosecond, and every row pays it.

Two APIs, two costs

The same engine ships twice: a tree-shakeable functional API (parse, convert, mix) and the Color class on top of it. Groups listing both rows show what the wrapper costs, usually one allocation. Reach for the functions in per-pixel loops and the class everywhere else.

Parsing

CSS string in, the library's own representation out. Hex and the legacy functional notations are the common denominator. oklch(), lab() and color(display-p3 …) are CSS Color 4, which only urcolor, culori and colorjs.io parse.

parse: hex

Off the chart: colorjs.io at 25.07 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor parse (fn)343.1 nsfastest
culori parse405.7 ns1.18× slower
colord411.4 ns1.20× slower
urcolor Color.parse471.9 ns1.38× slower
chroma-js1.69 µs4.92× slower
tinycolor22.00 µs5.82× slower
@ctrl/tinycolor2.98 µs8.69× slower
colorjs.io25.07 µs73.05× slower

parse: hex + alpha (#rrggbbaa)

Off the chart: colorjs.io at 12.45 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord521.4 nsfastest
urcolor650.9 ns1.25× slower
culori952.5 ns1.83× slower
@ctrl/tinycolor1.57 µs3.02× slower
tinycolor21.65 µs3.16× slower
chroma-js1.96 µs3.75× slower
colorjs.io12.45 µs23.88× slower

parse: rgb() modern syntax

Off the chart: colorjs.io at 32.45 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord1.19 µsfastest
urcolor1.80 µs1.51× slower
culori4.93 µs4.16× slower
chroma-js9.76 µs8.23× slower
colorjs.io32.45 µs27.37× slower

parse: rgba() legacy syntax

Off the chart: colorjs.io at 17.93 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord589.1 nsfastest
@ctrl/tinycolor881.1 ns1.50× slower
culori1.37 µs2.32× slower
tinycolor21.58 µs2.68× slower
urcolor2.29 µs3.89× slower
colorjs.io17.93 µs30.43× slower

parse: hsl()

Exact timings
LibraryTimeRelative
tinycolor21.14 µsfastest
urcolor1.26 µs1.11× slower
@ctrl/tinycolor1.29 µs1.13× slower
colord1.35 µs1.19× slower
culori2.22 µs1.95× slower
chroma-js7.07 µs6.22× slower
colorjs.io8.31 µs7.32× slower

parse: named color

Off the chart: colorjs.io at 4.97 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor127.0 nsfastest
colord131.1 ns1.03× slower
chroma-js282.8 ns2.23× slower
culori397.2 ns3.13× slower
tinycolor2496.9 ns3.91× slower
@ctrl/tinycolor632.1 ns4.98× slower
colorjs.io4.97 µs39.16× slower

parse: oklch() [CSS Color 4]

Off the chart: colorjs.io at 10.67 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor676.0 nsfastest
culori1.29 µs1.91× slower
colorjs.io10.67 µs15.79× slower

parse: lab() [CSS Color 4]

Exact timings
LibraryTimeRelative
urcolor851.1 nsfastest
culori1.39 µs1.63× slower
colorjs.io6.79 µs7.97× slower

parse: color(display-p3 …) [CSS Color 4]

Off the chart: colorjs.io at 11.35 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor796.1 nsfastest
culori1.42 µs1.79× slower
colorjs.io11.35 µs14.25× slower

parse: invalid input (rejection)

Exact timings
LibraryTimeRelative
urcolor tryParse61.4 nsfastest
colord .isValid()156.1 ns2.54× slower
@ctrl/tinycolor355.8 ns5.79× slower
culori parse359.1 ns5.85× slower
tinycolor2369.9 ns6.02× slower

Conversion

An already-parsed color moved into another space: transfer functions and matrices, with no parsing mixed in. Where a group converts out of a perceptual space, every operand is pre-converted, so each library pays for exactly one leg.

convert: sRGB → HSL

Off the chart: @ctrl/tinycolor at 342.7 ns, colorjs.io at 1.79 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
culori21.4 nsfastest
urcolor convert (fn)43.0 ns2.01× slower
colord44.6 ns2.08× slower
tinycolor271.0 ns3.31× slower
urcolor .to()71.4 ns3.33× slower
chroma-js94.3 ns4.41× slower
@ctrl/tinycolor342.7 ns16.00× slower
colorjs.io1.79 µs83.66× slower

convert: sRGB → Oklch

Exact timings
LibraryTimeRelative
culori174.0 nsfastest
urcolor convert (fn)175.8 ns1.01× slower
urcolor .to()260.7 ns1.50× slower
chroma-js1.13 µs6.47× slower
colorjs.io1.32 µs7.60× slower

convert: sRGB → Oklab

Off the chart: colorjs.io at 1.59 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
culori117.5 nsfastest
urcolor149.3 ns1.27× slower
chroma-js1.17 µs9.92× slower
colorjs.io1.59 µs13.57× slower

convert: sRGB → CIE Lab

Off the chart: colorjs.io at 1.38 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
culori121.2 nsfastest
colord137.2 ns1.13× slower
urcolor182.6 ns1.51× slower
chroma-js220.6 ns1.82× slower
colorjs.io1.38 µs11.41× slower

convert: sRGB → XYZ D65

Off the chart: colorjs.io at 1.22 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
culori71.7 nsfastest
urcolor105.4 ns1.47× slower
colorjs.io1.22 µs17.04× slower

convert: Oklch → sRGB

Exact timings
LibraryTimeRelative
culori145.9 nsfastest
urcolor237.3 ns1.63× slower
colorjs.io1.23 µs8.44× slower
chroma-js1.45 µs9.92× slower

convert: sRGB → Display P3

Exact timings
LibraryTimeRelative
culori147.7 nsfastest
urcolor175.6 ns1.19× slower
colorjs.io1.21 µs8.17× slower

convert: sRGB → Rec. 2020

Exact timings
LibraryTimeRelative
culori148.7 nsfastest
urcolor179.4 ns1.21× slower
colorjs.io1.31 µs8.80× slower

convert: chain sRGB → Oklch → Lab → sRGB

Exact timings
LibraryTimeRelative
culori585.5 nsfastest
urcolor624.5 ns1.07× slower
colorjs.io4.10 µs7.00× slower

gamut: map into sRGB

Off the chart: colorjs.io .toGamut() at 1.82 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
culori clampChroma122.6 nsfastest
urcolor .toGamut()225.1 ns1.84× slower
colorjs.io .toGamut()1.82 µs14.84× slower

gamut: inGamut check

Exact timings
LibraryTimeRelative
culori124.8 nsfastest
urcolor265.1 ns2.12× slower
colorjs.io863.7 ns6.92× slower

Mixing and interpolation

One-shot blends, then the gradient shape: build an interpolator once and sample it repeatedly. colord and the tinycolors mix only in sRGB, which is cheaper and perceptually worse than an Oklab blend, so they appear in the sRGB group alone.

mix: sRGB, 50%

Off the chart: colorjs.io at 3.83 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor mix (fn)137.8 nsfastest
urcolor .mix()204.4 ns1.48× slower
chroma-js422.4 ns3.07× slower
colord532.5 ns3.86× slower
culori757.3 ns5.50× slower
@ctrl/tinycolor1.11 µs8.04× slower
tinycolor21.24 µs9.01× slower
colorjs.io3.83 µs27.81× slower

mix: Oklab, 50%

Exact timings
LibraryTimeRelative
urcolor mix (fn)419.1 nsfastest
urcolor .mix()446.4 ns1.06× slower
culori835.9 ns1.99× slower
colorjs.io3.24 µs7.74× slower
chroma-js3.80 µs9.06× slower

mix: Oklch (shorter hue arc), 50%

Off the chart: colorjs.io at 8.25 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor489.0 nsfastest
culori897.3 ns1.84× slower
chroma-js4.56 µs9.33× slower
colorjs.io8.25 µs16.87× slower

mix: CIE Lab, 50%

Off the chart: colorjs.io at 3.86 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor367.6 nsfastest
culori902.2 ns2.45× slower
chroma-js1.21 µs3.30× slower
colorjs.io3.86 µs10.51× slower

gradient: 64 Oklab samples (interpolator reused)

Off the chart: colorjs.io at 36.10 µs, chroma-js at 235.94 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor2.70 µsfastest
culori6.43 µs2.38× slower
colorjs.io36.10 µs13.39× slower
chroma-js235.94 µs87.53× slower

gradient: 64 Oklab samples (mix per sample)

Off the chart: chroma-js at 225.04 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor20.77 µsfastest
culori52.03 µs2.50× slower
colorjs.io197.01 µs9.48× slower
chroma-js225.04 µs10.83× slower

palette: 11 stops from two anchors

Off the chart: colorjs.io at 39.14 µs, chroma-js at 39.42 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor3.31 µsfastest
culori3.35 µs1.01× slower
colorjs.io39.14 µs11.81× slower
chroma-js39.42 µs11.90× slower

Manipulation

The libraries disagree about where an adjustment happens. urcolor and chroma-js lighten in a perceptual space and pay for two conversions on top of the arithmetic. colord and the tinycolors nudge HSL lightness directly. Same verb, different job.

manipulate: lighten (perceptual: urcolor/chroma-js; HSL: rest)

Off the chart: tinycolor2 at 1.72 µs, @ctrl/tinycolor at 1.83 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord .lighten()131.9 nsfastest
urcolor lighten (fn)342.0 ns2.59× slower
urcolor .lighten()457.1 ns3.47× slower
chroma-js .brighten()493.8 ns3.74× slower
tinycolor21.72 µs13.06× slower
@ctrl/tinycolor1.83 µs13.87× slower

manipulate: darken

Off the chart: tinycolor2 at 2.09 µs, @ctrl/tinycolor at 2.25 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord128.1 nsfastest
urcolor356.3 ns2.78× slower
chroma-js586.9 ns4.58× slower
tinycolor22.09 µs16.28× slower
@ctrl/tinycolor2.25 µs17.53× slower

manipulate: saturate

Off the chart: tinycolor2 at 1.51 µs, @ctrl/tinycolor at 1.86 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord119.3 nsfastest
urcolor348.0 ns2.92× slower
chroma-js644.6 ns5.41× slower
tinycolor21.51 µs12.69× slower
@ctrl/tinycolor1.86 µs15.62× slower

manipulate: rotate hue 60°

Off the chart: tinycolor2 at 1.80 µs, @ctrl/tinycolor at 2.08 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord146.5 nsfastest
urcolor356.3 ns2.43× slower
tinycolor21.80 µs12.30× slower
@ctrl/tinycolor2.08 µs14.23× slower

channel: set lightness in Oklch

Exact timings
LibraryTimeRelative
urcolor .with()210.3 nsfastest
colorjs.io .set()2.13 µs10.14× slower

channel: read one channel

Off the chart: chroma-js at 96.0 ns, colorjs.io at 666.5 ns. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor .get()7.3 nsfastest
colord18.6 ns2.54× slower
chroma-js96.0 ns13.08× slower
colorjs.io666.5 ns90.84× slower

alpha: set opacity

Off the chart: @ctrl/tinycolor at 712.9 ns, tinycolor2 at 726.8 ns. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor .withAlpha()27.3 nsfastest
colord .alpha()73.7 ns2.70× slower
chroma-js .alpha()253.2 ns9.29× slower
@ctrl/tinycolor712.9 ns26.14× slower
tinycolor2726.8 ns26.65× slower

Difference and contrast

ΔE2000 is the expensive one: trigonometry plus a rotation term. ΔE76 is here so the cost of the 2000 formula is visible rather than implied. WCAG contrast is a handful of multiplications, so that group mostly measures each library's object-model overhead.

deltaE: CIEDE2000

Exact timings
LibraryTimeRelative
culori384.1 nsfastest
urcolor deltaE (fn)412.3 ns1.07× slower
urcolor .deltaE()432.6 ns1.13× slower
colord .delta()558.9 ns1.46× slower
chroma-js654.0 ns1.70× slower
colorjs.io1.71 µs4.44× slower

deltaE: CIE76 (Euclidean Lab)

Exact timings
LibraryTimeRelative
urcolor290.4 nsfastest
culori353.2 ns1.22× slower
colorjs.io1.23 µs4.24× slower

deltaE: ΔEOK (Oklab Euclidean)

Exact timings
LibraryTimeRelative
urcolor260.5 nsfastest
culori276.3 ns1.06× slower
colorjs.io1.20 µs4.62× slower

contrast: WCAG 2.1 ratio

Off the chart: colorjs.io at 2.97 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor contrast (fn)140.2 nsfastest
culori151.6 ns1.08× slower
urcolor .contrast()156.2 ns1.11× slower
colord262.1 ns1.87× slower
chroma-js302.6 ns2.16× slower
tinycolor21.12 µs8.01× slower
@ctrl/tinycolor1.25 µs8.89× slower
colorjs.io2.97 µs21.21× slower

contrast: APCA

Exact timings
LibraryTimeRelative
urcolor196.2 nsfastest
colorjs.io1.70 µs8.65× slower

relative luminance

Exact timings
LibraryTimeRelative
colord55.4 nsfastest
culori69.3 ns1.25× slower
chroma-js93.4 ns1.69× slower
urcolor97.9 ns1.77× slower

equality: same color, different space

Exact timings
LibraryTimeRelative
chroma-js hex compare394.2 nsfastest
colord .isEqual()433.3 ns1.10× slower
urcolor .equals()445.4 ns1.13× slower

Serialization

Color object to CSS string, the last step of every render path. It runs as often as parsing does.

serialize: → hex

Off the chart: colorjs.io at 619.0 ns. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor serialize (fn)36.6 nsfastest
urcolor .toString()45.6 ns1.25× slower
culori formatHex57.4 ns1.57× slower
colord .toHex()84.3 ns2.30× slower
tinycolor2113.8 ns3.11× slower
@ctrl/tinycolor118.8 ns3.25× slower
chroma-js .hex()144.9 ns3.96× slower
colorjs.io619.0 ns16.92× slower

serialize: → rgb()

Off the chart: chroma-js at 246.5 ns, colorjs.io at 791.6 ns. The table below carries every row.

Exact timings
LibraryTimeRelative
tinycolor211.2 nsfastest
@ctrl/tinycolor12.8 ns1.14× slower
culori34.7 ns3.09× slower
colord39.0 ns3.48× slower
urcolor50.1 ns4.47× slower
chroma-js246.5 ns21.96× slower
colorjs.io791.6 ns70.54× slower

serialize: → hsl()

Off the chart: colorjs.io at 2.01 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord55.3 nsfastest
tinycolor279.7 ns1.44× slower
urcolor127.3 ns2.30× slower
culori136.9 ns2.47× slower
@ctrl/tinycolor323.9 ns5.86× slower
colorjs.io2.01 µs36.40× slower

serialize: → oklch()

Off the chart: colorjs.io at 1.99 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor59.3 nsfastest
culori61.8 ns1.04× slower
colorjs.io1.99 µs33.49× slower

serialize: sRGB color → oklch() string (convert + print)

Exact timings
LibraryTimeRelative
culori217.5 nsfastest
urcolor280.9 ns1.29× slower
colorjs.io1.96 µs9.00× slower

Canvas gradient rendering

The CPU path behind a picker surface: a slider track, an SV plane, a hue wheel. Each call returns an Uint8ClampedArray of RGBA bytes ready for putImageData, so these are milliseconds per frame. No other library ships a grid sampler, so the comparison rows hand-roll the same loop with that library's interpolator. urcolor's WebGL entry points are not measured here: they need a live canvas and a GPU context, and their cost is one uniform upload and one draw call whatever the surface size.

canvas: 512px linear track, Oklab → RGBA bytes

Off the chart: chroma-js (hand-rolled) at 1.89 ms. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor interpolateStops93.75 µsfastest
culori (hand-rolled)108.43 µs1.16× slower
colorjs.io (hand-rolled)831.88 µs8.87× slower
chroma-js (hand-rolled)1.89 ms20.19× slower

canvas: 128×128 bilinear plane, Oklab → RGBA bytes

Off the chart: colorjs.io (hand-rolled) at 178.23 ms. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor sampleBilinearGrid4.18 msfastest
culori (hand-rolled)31.07 ms7.43× slower
colorjs.io (hand-rolled)178.23 ms42.65× slower

canvas: 128×128 HSV S/V plane → RGBA bytes

Off the chart: colorjs.io (hand-rolled) at 30.45 ms. The table below carries every row.

Exact timings
LibraryTimeRelative
culori (hand-rolled)1.11 msfastest
urcolor sampleChannelGrid1.81 ms1.63× slower
colorjs.io (hand-rolled)30.45 ms27.54× slower

canvas: 128×128 Oklch polar wheel → RGBA bytes

Exact timings
LibraryTimeRelative
culori (hand-rolled)2.85 msfastest
urcolor samplePolarGrid3.56 ms1.25× slower

canvas: 128×128 conic hue ring → RGBA bytes

Exact timings
LibraryTimeRelative
culori (hand-rolled)2.93 msfastest
urcolor sampleConicRing3.78 ms1.29× slower

canvas: per-pixel cost, 1 000 Oklch → sRGB pixels

Off the chart: colorjs.io (hand-rolled) at 1.99 ms. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor sampleConicRing135.22 µsfastest
culori (hand-rolled)163.91 µs1.21× slower
chroma-js (hand-rolled)1.31 ms9.68× slower
colorjs.io (hand-rolled)1.99 ms14.73× slower

End-to-end pipelines

String in, string out. The groups above isolate one cost each; these measure the round trip an application actually pays, which is where a library's object model shows up.

pipeline: hex → lighten → hex

Off the chart: colorjs.io at 30.32 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord409.3 nsfastest
urcolor fn510.4 ns1.25× slower
culori (hand-rolled)520.8 ns1.27× slower
urcolor Color589.0 ns1.44× slower
chroma-js1.13 µs2.77× slower
tinycolor21.51 µs3.69× slower
@ctrl/tinycolor2.11 µs5.17× slower
colorjs.io30.32 µs74.08× slower

pipeline: hex → oklch → hex (round trip)

Off the chart: colorjs.io at 8.04 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor fn488.0 nsfastest
culori495.4 ns1.02× slower
urcolor Color607.2 ns1.24× slower
chroma-js3.11 µs6.37× slower
colorjs.io8.04 µs16.47× slower

pipeline: two hex strings → mixed hex (Oklab)

Off the chart: colorjs.io at 15.89 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
urcolor fn748.5 nsfastest
urcolor Color859.4 ns1.15× slower
culori1.39 µs1.85× slower
chroma-js5.71 µs7.63× slower
colorjs.io15.89 µs21.22× slower

pipeline: two hex strings → ΔE2000

Off the chart: colorjs.io at 9.60 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
culori676.0 nsfastest
colord700.7 ns1.04× slower
urcolor fn717.2 ns1.06× slower
urcolor Color803.2 ns1.19× slower
chroma-js1.54 µs2.28× slower
colorjs.io9.60 µs14.20× slower

pipeline: two hex strings → WCAG contrast

Off the chart: colorjs.io at 11.09 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord408.3 nsfastest
urcolor fn478.0 ns1.17× slower
culori518.1 ns1.27× slower
urcolor Color535.0 ns1.31× slower
tinycolor2964.4 ns2.36× slower
chroma-js1.19 µs2.90× slower
colorjs.io11.09 µs27.17× slower

batch: 10 swatches → darken → hex

Off the chart: colorjs.io at 188.20 µs. The table below carries every row.

Exact timings
LibraryTimeRelative
colord3.65 µsfastest
urcolor fn5.18 µs1.42× slower
culori (hand-rolled)5.22 µs1.43× slower
urcolor Color6.05 µs1.66× slower
chroma-js13.25 µs3.63× slower
tinycolor216.88 µs4.62× slower
colorjs.io188.20 µs51.57× slower

Reproducing

A benchmark is worth what the machine running it is worth. To check these numbers yourself:

sh
git clone https://github.com/ur-color/urcolor
cd urcolor && bun install
bun run --cwd packages/core bench

Close everything else first, because background load lands squarely in the mean. If your ordering differs from these tables and you can reproduce it, open an issue. A benchmark that holds on one laptop only is not a benchmark.