Skip to main content

culori vs chroma-js vs tinycolor2: Color Manipulation in JavaScript (2026)

·PkgPulse Team

TL;DR

culori is the modern choice — ESM-native, supports all modern CSS color spaces (OKLCH, LCH, Display P3), used by Tailwind CSS v4 and Radix UI for color system generation, and perceptually uniform by default. chroma-js is the most feature-rich for data visualization — color scales, interpolation, and manipulation with a clean chainable API. tinycolor2 is the most downloaded but dated — jQuery-era design, CommonJS, no modern color spaces. For design systems and UI: culori. For data visualization and color gradients: chroma-js. For legacy codebases: tinycolor2.

Key Takeaways

  • culori: ~8M weekly downloads — modern, OKLCH/LCH support, used by Tailwind v4 and Radix
  • chroma-js: ~8M weekly downloads — color scales, LAB interpolation, data viz standard
  • tinycolor2: ~10M weekly downloads — legacy, jQuery era, limited to sRGB color space
  • OKLCH is the future of CSS color — use culori for any design-system work in 2026
  • Tailwind CSS v4 uses culori internally for its color system
  • For accessibility (WCAG contrast ratio): all three support it, culori most accurately

PackageWeekly DownloadsBundle SizeOKLCH/LCHColor ScalesESM-native
culori~8M~30KB
chroma-js~8M~60KB❌ (LAB only)
tinycolor2~10M~15KB❌ CJS

Color Space Quick Reference

sRGB:     Standard web colors (#rgb, hsl, rgb) — what CSS has always used
HSL:      Hue-Saturation-Lightness — common but perceptually non-uniform
LAB:      Perceptually uniform (L=lightness, a=green↔red, b=blue↔yellow)
LCH:      Like LAB but with hue angle — more intuitive than LAB
OKLCH:    Updated LCH — better hue uniformity, no "blue hump" problem
          → The CSS Color Level 4 recommended color space for design
P3:       Apple's Display P3 wide gamut — 25% more colors than sRGB

culori

culori — the modern color library for design systems:

Parse and convert

import { parse, formatHex, formatCss, toGamut } from "culori"

// Parse any CSS color:
const red = parse("#ff0000")
// { mode: "rgb", r: 1, g: 0, b: 0 }

const hsl = parse("hsl(210, 100%, 50%)")
// { mode: "hsl", h: 210, s: 1, l: 0.5 }

const oklch = parse("oklch(60% 0.15 240)")
// { mode: "oklch", l: 0.6, c: 0.15, h: 240 }

// Convert between color spaces:
import { oklch, rgb, hsl, p3 } from "culori"

const pkgpulseOrange = oklch("#FF8800")
// { mode: "oklch", l: 0.67, c: 0.17, h: 51 }

const asP3 = p3("#FF8800")
// { mode: "p3", r: 1.0, g: 0.53, b: 0 }

// Format as CSS:
formatHex(oklch("#FF8800"))        // "#ff8800"
formatCss(oklch("#FF8800"))        // "oklch(66.99% 0.165 51.11)"
formatCss(hsl("#FF8800"))          // "hsl(32.94 100% 50%)"

OKLCH for design system colors

import { oklch, formatCss, interpolate, samples } from "culori"

// Generate a color scale in OKLCH (perceptually uniform):
// OKLCH gives you equal visual steps between shades — HSL doesn't

function generateColorScale(baseHue: number, steps = 11) {
  return samples(steps).map((t) =>
    formatCss({
      mode: "oklch",
      l: 0.95 - t * 0.85,  // Lightness: 95% (lightest) to 10% (darkest)
      c: 0.15 + Math.sin(t * Math.PI) * 0.05,  // Chroma varies slightly
      h: baseHue,
    })
  )
}

// Blue scale:
const blueScale = generateColorScale(240)
// ["oklch(95% 0.15 240)", "oklch(86.5% 0.17 240)", ..., "oklch(10% 0.15 240)"]

// This is essentially how Tailwind v4 generates its color system

Accessibility: WCAG contrast ratio

import { wcagLuminance, wcagContrast, parse, formatHex } from "culori"

// WCAG 2.1 contrast ratio:
const textColor = "#1a1a1a"
const bgColor = "#ffffff"

const contrast = wcagContrast(textColor, bgColor)
console.log(contrast)  // 19.1 (AA requires 4.5, AAA requires 7)

// Find accessible text color for a given background:
function getAccessibleTextColor(bg: string): string {
  const darkContrast = wcagContrast("#000000", bg)
  const lightContrast = wcagContrast("#ffffff", bg)

  return darkContrast > lightContrast ? "#000000" : "#ffffff"
}

getAccessibleTextColor("#FF8800")  // "#000000" (dark text on orange bg)
getAccessibleTextColor("#1a237e")  // "#ffffff" (light text on dark blue bg)

Color manipulation

import { adjust, blend, clampRgb, formatHex } from "culori"

// Adjust lightness, chroma, hue in OKLCH:
import { oklch, formatCss } from "culori"

const base = oklch("#3b82f6")  // Tailwind blue-500

// Lighter version:
const light = formatCss({ ...base, l: base!.l + 0.15 })  // oklch(...)

// More saturated:
const vivid = formatCss({ ...base, c: (base?.c ?? 0) * 1.3 })

// Mix colors:
import { mix } from "culori"

const blended = mix("#ff0000", "#0000ff", 0.5, "oklch")
formatHex(blended)  // "#ff00ff" (purple — equal mix)
// Mixing in OKLCH preserves saturation better than RGB mixing

chroma-js

chroma-js — the data visualization color library:

Basic usage

import chroma from "chroma-js"

// Parse:
const color = chroma("#3b82f6")
chroma("blue")
chroma("hsl(210, 100%, 50%)")
chroma("rgb(59, 130, 246)")

// Convert:
chroma("#3b82f6").hex()     // "#3b82f6"
chroma("#3b82f6").rgb()     // [59, 130, 246]
chroma("#3b82f6").hsl()     // [213.4, 0.9337, 0.5980]
chroma("#3b82f6").lab()     // [54.99, 6.24, -53.86]
chroma("#3b82f6").lch()     // [54.99, 54.22, 276.6]
chroma("#3b82f6").luminance()  // 0.2126

// Modify:
chroma("#3b82f6").darken(1).hex()     // Darker
chroma("#3b82f6").brighten(1).hex()   // Lighter
chroma("#3b82f6").saturate(2).hex()   // More saturated
chroma("#3b82f6").desaturate(2).hex() // Less saturated
chroma("#3b82f6").alpha(0.5).css()    // "rgba(59,130,246,0.5)"

Color scales

import chroma from "chroma-js"

// Create a color scale between colors:
const scale = chroma.scale(["#ffffff", "#3b82f6", "#1e3a8a"])

scale(0)    // White → #ffffff
scale(0.5)  // Mid blue
scale(1)    // Dark blue → #1e3a8a

// Generate N colors from scale:
const palette = chroma.scale(["#f8f9fa", "#3b82f6"]).colors(10)
// ["#f8f9fa", "#d6e4fc", ..., "#3b82f6"]

// Scale with LAB interpolation (perceptually uniform):
const labScale = chroma.scale(["#3b82f6", "#ef4444"]).mode("lab")

// Diverging scale (good for heatmaps):
const heatmap = chroma.scale(["#313695", "#ffffbf", "#a50026"])
  .mode("lab")
  .domain([0, 50, 100])  // Custom domain

heatmap(0)    // Blue (cold)
heatmap(50)   // Yellow (neutral)
heatmap(100)  // Red (hot)

WCAG contrast

import chroma from "chroma-js"

const contrast = chroma.contrast("#1a1a1a", "#ffffff")
console.log(contrast.toFixed(2))  // "19.10"

// Check WCAG compliance:
function checkContrast(fg: string, bg: string) {
  const ratio = chroma.contrast(fg, bg)
  return {
    ratio,
    aa: ratio >= 4.5,
    aaa: ratio >= 7,
    aaLarge: ratio >= 3,  // Large text (18pt+ or 14pt+ bold)
  }
}

checkContrast("#000000", "#ffffff")
// { ratio: 21, aa: true, aaa: true, aaLarge: true }

tinycolor2

tinycolor2 — legacy but widely used:

Basic usage

import tinycolor from "tinycolor2"

// Parse:
const color = tinycolor("#3b82f6")
tinycolor("blue")
tinycolor("hsl(210, 100%, 50%)")

// Convert:
tinycolor("#3b82f6").toHexString()  // "#3b82f6"
tinycolor("#3b82f6").toRgbString()  // "rgb(59, 130, 246)"
tinycolor("#3b82f6").toHslString()  // "hsl(213, 93%, 59%)"

// Manipulate:
tinycolor("#3b82f6").darken(10).toHexString()    // Darker
tinycolor("#3b82f6").lighten(10).toHexString()   // Lighter
tinycolor("#3b82f6").saturate(20).toHexString()  // More saturated
tinycolor("#3b82f6").setAlpha(0.5).toRgbString() // "rgba(59,130,246,0.5)"

// Accessibility:
tinycolor.readability("#000000", "#ffffff")  // 21 (contrast ratio)
tinycolor.isReadable("#000000", "#ffffff")   // true (WCAG AA)

Why migrate from tinycolor2

// tinycolor2 limitations in 2026:
// 1. CommonJS only — no ESM, causes issues with bundlers
// 2. No OKLCH, LCH, LAB, Display P3 support
// 3. No active maintenance (last major update: 2021)
// 4. Perceptual color operations are inaccurate compared to culori

// Migration path:
// tinycolor2 → culori (for design systems, modern color spaces)
// tinycolor2 → chroma-js (for data visualization and gradients)

Feature Comparison

Featureculorichroma-jstinycolor2
OKLCH
LAB/LCH
Display P3
Color scales✅ Excellent
Interpolation modes
WCAG contrast
ESM❌ CJS
Bundle size~30KB~60KB~15KB
TypeScript✅ @types
Tailwind v4

When to Use Each

Choose culori if:

  • Building a design system with OKLCH color scales (Tailwind v4 approach)
  • You need perceptually uniform color generation
  • Working with modern CSS color functions (oklch(), lch(), lab())
  • Radix UI, Tailwind, or any modern design token system

Choose chroma-js if:

  • Data visualization — heatmaps, choropleth maps, gradient charts
  • Color interpolation between multiple colors in LAB space
  • You need a comprehensive color manipulation API with scales
  • D3.js or similar data viz work (chroma-js is popular in that ecosystem)

Choose tinycolor2 if:

  • Legacy codebase already using it — migration needed but not urgent
  • Simple color parsing and sRGB manipulation without modern color spaces
  • Webpack/CommonJS environments where ESM is problematic

Use CSS custom properties for simple cases:

/* No JavaScript needed for CSS-only color manipulation: */
:root {
  --brand: oklch(60% 0.15 240);
  --brand-light: oklch(80% 0.10 240);
  --brand-dark: oklch(40% 0.20 240);
}

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on culori v4.x, chroma-js v3.x, and tinycolor2 v1.x.

Compare UI utility and JavaScript packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.