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);
}
Why OKLCH Is the Right Color Space for Design Systems in 2026
OKLCH has become the preferred color space for design system work because it solves a fundamental problem with HSL: equal steps in HSL hue do not produce equal perceived brightness changes. Rotate from blue (240°) to yellow (60°) in HSL and the visual brightness change is dramatic. Rotate in OKLCH and the perceived lightness stays nearly constant, because OKLCH's lightness channel is calibrated to match human visual perception.
Culori's OKLCH implementation reflects this correctly. When you generate a 10-step color scale in OKLCH by stepping from L=0.95 to L=0.10 in equal increments, you get shades that look evenly spaced to the human eye. The same operation in HSL produces a scale where the middle values appear to cluster — the brightness difference between steps 4 and 5 looks much smaller than between steps 1 and 2. Tailwind CSS v4's color system is built on this observation: all palette colors are generated in OKLCH, which is why Tailwind v4 palettes look more balanced than earlier versions.
The "blue hump" problem in older LCH (not OKLCH) is also worth understanding. Standard LCH inherited from CIELAB has a perceptual non-uniformity in the blue region: blues at the same nominal lightness appear darker than other hues. OKLCH was specifically designed to correct this by applying a non-linear transformation to the LCH lightness axis. If you are manipulating blues, purples, or indigos in a color system, the difference between LCH and OKLCH is visible, and culori correctly uses OKLCH for all operations that advertise perceptual uniformity.
From a CSS compatibility perspective, OKLCH is supported in all modern browsers (Chrome 111+, Firefox 113+, Safari 15.4+) as a native CSS color function. Using oklch(60% 0.15 240) directly in CSS is now safe for 95%+ of users in 2026. Culori's formatCss() function generates these native CSS values, allowing design token systems to output OKLCH directly rather than falling back to hex conversion.
Color Scales for Data Visualization with chroma-js
Chroma-js's scale() function is the most capable color scale implementation in the JavaScript ecosystem, and its advantages become clear when building heatmaps, choropleth maps, or quantitative data visualizations.
The choice of interpolation color space dramatically affects how color scales look in practice. Linear interpolation in RGB — what you get in CSS gradients and most naive color mixing — produces muddy browns and grays when interpolating between complementary colors like blue and orange. Interpolating in LAB or OKLCH produces perceptually smooth transitions that pass through pure, saturated colors rather than desaturated midpoints. Chroma-js's .mode('lab') option applies LAB-space interpolation, and its .mode('oklch') option (added in chroma-js v3) uses the superior OKLCH space.
The .domain() method enables non-linear scale mapping. For data with a skewed distribution — package download counts where most packages have under 10K weekly downloads but a few have 50M+ — a linear color scale maps 95% of your data into a narrow band at one end. Setting .domain([0, 1000, 1000000]) with three control points creates a scale that is sensitive at the low end and compresses the high end, producing a more informative visualization.
Chroma-js also provides chroma.brewer which exposes ColorBrewer's research-validated sequential, diverging, and qualitative palettes. These palettes are specifically designed for map visualization and have been tested for colorblindness compatibility. chroma.scale('RdYlBu') gives you the red-yellow-blue diverging scale used in thousands of data visualizations, calibrated for maximum discriminability across the full population including deuteranopes and protanopes.
Migrating from tinycolor2
Tinycolor2's ~10 million weekly downloads represent a large installed base, most of it from projects that haven't revisited their color library dependency in several years. The migration path is straightforward but requires attention to API differences.
For sRGB manipulation that doesn't require perceptual uniformity — darkening, lightening, and saturation adjustments on hex colors — culori's API is a direct functional replacement. Where tinycolor2 uses a fluent chainable API (tinycolor('#3b82f6').darken(10).toHexString()), culori uses functional composition. The equivalent in culori is to parse the color, adjust the l channel in OKLCH, and format back to hex. This is slightly more verbose but more explicit about what color space the manipulation happens in.
For projects using tinycolor2 primarily for WCAG contrast ratio checks, both culori's wcagContrast() and chroma-js's chroma.contrast() are drop-in conceptual replacements with identical mathematical output. The WCAG 2.1 contrast formula operates in sRGB luminance space, so all three libraries produce the same result for the same inputs.
The main migration challenge is CommonJS compatibility. Tinycolor2 is CJS-only, and some older build setups rely on this. Both culori and chroma-js are dual ESM/CJS packages, so they work in both module systems. However, if your project uses require('tinycolor2') in a CJS context, switching to culori requires ensuring your bundler is configured to handle ESM packages, which is the default behavior in Vite and modern webpack but may need configuration in older setups.
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 →
See also: React vs Vue and React vs Svelte, fast-deep-equal vs dequal vs Lodash isEqual.