culori vs chroma-js vs tinycolor2: Color Manipulation in JavaScript (2026)
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
Download Trends
| Package | Weekly Downloads | Bundle Size | OKLCH/LCH | Color Scales | ESM-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
| Feature | culori | chroma-js | tinycolor2 |
|---|---|---|---|
| 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.