Read, convert and write CSS colors, fit them to what a screen can show, and check their contrast.

@ui-sdk/core/color reads CSS colors, converts them between color spaces, writes them back as CSS, fits them to a screen's gamut and compares two colors.

The helpers are plain functions on plain data, and they run without a DOM. A color input, a swatch list or a theme editor of your own reads and writes colors with them.

Colors as Data

Read a CSS Color

parseColor reads any CSS color into its space, three channels and an alpha. It returns null for text that isn't a color:

import { parseColor } from "@ui-sdk/core/color"

parseColor("#ff0000") // { space: "srgb", coords: [1, 0, 0], alpha: 1 }
parseColor("oklch(0.7 0.15 250 / 50%)") // { space: "oklch", coords: [0.7, 0.15, 250], alpha: 0.5 }
parseColor("rebeccapurple") // { space: "srgb", coords: [0.4, 0.2, 0.6], alpha: 1 }
parseColor("not a color") // null

It reads hex, named colors, transparent, rgb(), hsl(), hwb(), lab(), lch(), oklab(), oklch(), and color() in the srgb, srgb-linear, display-p3 and xyz spaces, with the older comma syntax like rgba(255, 0, 0, 0.5) too. A color keeps the space it was written in, and its channels use the ranges CSS gives them: 0 to 1 for sRGB and Display P3, 0 to 100 for percentages like HSL's saturation, and degrees for hues. Values past a range that CSS clamps, like rgb(300 0 0), are clamped the same way.

Missing Channels

A channel written as none is missing, and reads as null. A grey has no hue, so converting one to a space with a hue leaves the hue missing too:

import { convertColor, parseColor } from "@ui-sdk/core/color"

parseColor("oklch(0.7 0.1 none)")!.coords // [0.7, 0.1, null]
convertColor(parseColor("white")!, "oklch").coords // [1, 0, null]

A missing channel counts as 0 when the color converts. Keep the last hue yourself while someone drags to grey and back, so it doesn't jump to red.

Converting

Between Spaces

convertColor returns the same color in another space:

import { convertColor, parseColor } from "@ui-sdk/core/color"

const red = parseColor("red")!

convertColor(red, "hsl").coords // [0, 100, 50]
convertColor(red, "oklch").coords // [0.628, 0.258, 29.234], rounded
convertColor(red, "srgb") === red // true, it's already sRGB

The spaces are CSS's: "srgb", "srgb-linear", "display-p3", "hsl", "hwb", "lab", "lch", "oklab", "oklch", "xyz-d65" and "xyz-d50", plus "hsv", the space most pickers draw their square in. OKLCH's lightness, chroma and hue change the way people see them change, so stepping its lightness keeps the hue, and two colors with the same lightness look equally light.

Writing CSS

Formats

formatColor writes a color as CSS, in its own space or in the format you pass:

import { formatColor, parseColor } from "@ui-sdk/core/color"

const red = parseColor("red")!

formatColor(red) // "rgb(255 0 0)"
formatColor(red, "hex") // "#ff0000"
formatColor(red, "oklch") // "oklch(0.628 0.2577 29.23)"
formatColor(red, "display-p3") // "color(display-p3 0.9175 0.2003 0.1386)"
formatColor(red, "oklch", { precision: 3 }) // "oklch(0.63 0.26 29)"

"srgb" writes rgb(), and the spaces with no function of their own write color(). An alpha below 1 goes after a slash, or into a fourth byte in hex. HSV has no CSS syntax, so an HSV color writes hsl().

precision sets the significant digits per channel, 5 by default, counted against each channel's range: rgb() gets two decimals and OKLCH's lightness four. Hex, rgb(), hsl() and hwb() only hold sRGB colors, so a wider color is clipped to fit. Map it with toGamut first to keep it closer.

import * as React from "react"
import {
  formatColor,
  isInGamut,
  parseColor,
  toGamut,
  type Color,
  type ColorFormat,
} from "@ui-sdk/core/color"
import { Field, FieldControl, FieldLabel } from "@ui-sdk/react/field"

const formats: [ColorFormat, string][] = [
  ["hex", "Hex"],
  ["srgb", "RGB"],
  ["hsl", "HSL"],
  ["oklch", "OKLCH"],
  ["display-p3", "Display P3"],
]

function gamutOf(fitsSrgb: boolean, fitsP3: boolean) {
  if (fitsSrgb) {
    return "Every screen shows this color."
  }
  if (fitsP3) {
    return "Outside sRGB, inside P3. Hex, RGB and HSL show the closest sRGB color."
  }
  return "Outside sRGB and P3. Each format shows the closest color it holds."
}

const fitted = (color: Color, format: ColorFormat) =>
  format === "oklch"
    ? color
    : toGamut(color, format === "display-p3" ? "p3" : "srgb")

function Example() {
  const [text, setText] = React.useState("oklch(0.7 0.25 150)")
  const color = parseColor(text)
  return (
    <div data-slot="stack">
      <Field invalid={!color}>
        <FieldLabel>Color</FieldLabel>
        <div data-slot="color-field">
          <span
            data-slot="color-swatch"
            style={{ background: color ? formatColor(color) : undefined }}
          />
          <FieldControl
            value={text}
            spellCheck={false}
            onChange={(event) => setText(event.target.value)}
          />
        </div>
      </Field>
      {color ? (
        <>
          <dl data-slot="color-formats">
            {formats.map(([format, label]) => (
              <React.Fragment key={format}>
                <dt>{label}</dt>
                <dd data-format={format}>
                  {formatColor(fitted(color, format), format)}
                </dd>
              </React.Fragment>
            ))}
          </dl>
          <p data-gamut data-slot="hint">
            {gamutOf(isInGamut(color), isInGamut(color, "p3"))}
          </p>
        </>
      ) : (
        <p data-gamut data-slot="hint">
          Not a color
        </p>
      )}
    </div>
  )
}

export { Example }
Type any CSS color, like #7654cd or lab(60 40 -60), and each format updates. The line below says which screens can show it. Try oklch(0.7 0.35 150) to go past P3.

Gamut

Fit a Screen

A gamut is the range of colors a screen can show. Every screen shows sRGB, and most phones and laptops show the wider P3. isInGamut says whether a color fits, and toGamut returns the closest color that does, in the color's own space:

import { formatColor, isInGamut, parseColor, toGamut } from "@ui-sdk/core/color"

const green = parseColor("oklch(0.7 0.25 150)")!

isInGamut(green) // false
isInGamut(green, "p3") // true
formatColor(toGamut(green)) // "oklch(0.7091 0.2103 147.07)"
formatColor(toGamut(green), "hex") // "#00c248"
toGamut(green, "p3") === green // true, it already fits

toGamut maps the way CSS Color 4 and browsers do: it lowers the chroma in OKLCH, keeping the lightness and hue, until clipping what's left is too small to see. That keeps a vivid green green, where clipping each channel can shift its hue.

Mixing

Mix Two Colors

mixColor mixes two colors the way CSS color-mix() does. Pass how much of the second color you want, from 0 to 1:

import { formatColor, mixColor, parseColor } from "@ui-sdk/core/color"

const blue = parseColor("#2563eb")!
const yellow = parseColor("#facc15")!

formatColor(mixColor(blue, yellow), "hex") // "#90a3ad", halfway in Oklab
formatColor(mixColor(blue, yellow, 0.25, { space: "srgb" })) // "rgb(90.25 125.25 181.5)"

Colors mix in Oklab by default, where the steps between two colors look even. Set space to mix in another one, and the result is in that space. Translucent colors mix by their alpha, as color-mix() does, so a transparent color doesn't darken the other.

import * as React from "react"
import {
  formatColor,
  mixColor,
  parseColor,
  type ColorSpace,
} from "@ui-sdk/core/color"

const spaces: [ColorSpace, string][] = [
  ["oklab", "Oklab"],
  ["oklch", "OKLCH"],
  ["srgb", "sRGB"],
]

const steps = [0, 1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6, 1]

function Example() {
  const [from, setFrom] = React.useState("#2563eb")
  const [to, setTo] = React.useState("#facc15")
  const [space, setSpace] = React.useState<ColorSpace>("oklab")
  const mixes = steps.map((step) =>
    mixColor(parseColor(from)!, parseColor(to)!, step, { space })
  )
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <label data-slot="label">
          <input
            type="color"
            value={from}
            onChange={(event) => setFrom(event.target.value)}
          />
          From
        </label>
        <label data-slot="label">
          <input
            type="color"
            value={to}
            onChange={(event) => setTo(event.target.value)}
          />
          To
        </label>
        <select
          aria-label="Space"
          value={space}
          onChange={(event) => setSpace(event.target.value as ColorSpace)}
        >
          {spaces.map(([value, label]) => (
            <option key={value} value={value}>
              {label}
            </option>
          ))}
        </select>
      </div>
      <ol data-slot="color-steps">
        {mixes.map((mix, index) => (
          <li
            key={index}
            title={formatColor(mix, "hex")}
            style={{ background: formatColor(mix) }}
          />
        ))}
      </ol>
    </div>
  )
}

export { Example }
Pick two colors to see the steps between them. Choose sRGB and the middle turns darker, or OKLCH and it stays vivid, passing through green.

Hues

In a space with a hue, like OKLCH or HSL, the hue takes the shorter way around the color wheel. Set hue to "longer", "increasing" or "decreasing" to go another way:

const red = parseColor("oklch(0.63 0.26 29)")!
const violet = parseColor("oklch(0.55 0.25 300)")!

mixColor(red, violet, 0.5, { space: "oklch" }).coords[2] // 344.5, through pink
mixColor(red, violet, 0.5, { space: "oklch", hue: "longer" }).coords[2] // 164.5, through green

Comparing

Contrast

contrastRatio gives the WCAG 2 contrast ratio between two colors, from 1 to 21. Text needs 4.5 against its background, or 3 when it's large:

import { contrastRatio, parseColor } from "@ui-sdk/core/color"

contrastRatio(parseColor("#767676")!, parseColor("white")!) // 4.54

The order doesn't matter, and alpha doesn't count, so blend a translucent color over its background first.

import * as React from "react"
import { contrastRatio, parseColor } from "@ui-sdk/core/color"

function verdictOf(ratio: number) {
  if (ratio >= 4.5) {
    return "Passes for all text"
  }
  if (ratio >= 3) {
    return "Passes for large text only"
  }
  return "Fails for text"
}

function Example() {
  const [text, setText] = React.useState("#8a8a8a")
  const [background, setBackground] = React.useState("#ffffff")
  const ratio = contrastRatio(parseColor(text)!, parseColor(background)!)
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <label data-slot="label">
          <input
            type="color"
            value={text}
            onChange={(event) => setText(event.target.value)}
          />
          Text
        </label>
        <label data-slot="label">
          <input
            type="color"
            value={background}
            onChange={(event) => setBackground(event.target.value)}
          />
          Background
        </label>
      </div>
      <p data-slot="contrast-preview" style={{ color: text, background }}>
        Your order ships tomorrow.
      </p>
      <p data-ratio data-slot="hint">
        {ratio.toFixed(2)}:1, {verdictOf(ratio)}
      </p>
    </div>
  )
}

export { Example }
Pick a text color and a background. The ratio says whether the text passes WCAG's 4.5 for text or 3 for large text. Darken the gray until it passes for all text.

Distance

colorDistance measures how different two colors look, as the distance between them in Oklab. About 0.02 is the smallest difference most people notice:

import { colorDistance, parseColor } from "@ui-sdk/core/color"

colorDistance(parseColor("red")!, parseColor("#fe0000")!) // 0.002, too small to see

Use it to tell whether a swatch already holds a color, or to find the nearest one in a palette.