Start

Install color-kit, parse a color, and answer the two questions every color tool asks of it.

Install

pnpm add @pbroom/color-kit@next

color-kit is published under the next tag until 1.0. React is optional: only @pbroom/color-kit/react needs it.

Parse a color

Every value is an OKLCH Color: lightness l from 0 to 1, chroma c, hue h in degrees, and alpha. parse reads any CSS Color 4 string.

parse.ts
import { parse } from '@pbroom/color-kit';

const coral = parse('oklch(0.7 0.25 30)');
// → { l: 0.7, c: 0.25, h: 30, alpha: 1 }

Can a display show it?

coral is outside sRGB and outside Display P3. color-kit keeps the color you asked for and maps a copy for the display, reducing chroma only, so lightness and hue hold still.

gamut.ts
import { inSrgbGamut, toCss, toSrgbGamut } from '@pbroom/color-kit';

inSrgbGamut(coral); // → false
const shown = toSrgbGamut(coral);
toCss(shown, 'oklch'); // → 'oklch(0.7 0.1915 30)'

Is it readable?

Measure contrast on the color you paint.

contrast.ts
import { contrastRatio, parse } from '@pbroom/color-kit';

contrastRatio(shown, parse('#fff')).toFixed(2); // → '2.90'

2.90:1 fails WCAG AA for body text (4.5:1) and for large text (3:1). Try your own colors. The example paints hex, so it measures with { precision: '8bit' }, and it blends a translucent color over each background before measuring, since contrastRatio ignores alpha:

parse
oklch(0.7 0.25 30)
inSrgbGamut
false
inP3Gamut
false
toSrgbGamut
oklch(0.7 0.1915 30)
vs white
2.90:1
vs black
7.23:1
quick-start.tsx
import { useState } from 'react';
import {
  contrastRatio,
  inP3Gamut,
  inSrgbGamut,
  mix,
  parse,
  toCss,
  toSrgbGamut,
  tryParse,
  type Color,
} from '@pbroom/color-kit';
import { Chip, Readout } from './controls';

const WHITE = parse('#fff');
const BLACK = parse('#000');

/**
 * Contrast of `color` painted over an opaque `background`. A translucent
 * color shows the background through it, so composite first: source-over in
 * gamma-encoded sRGB, the way browsers blend by default, is a plain sRGB mix
 * from the background toward the opaque color by its alpha. The chip paints
 * hex, so measure 8-bit channels to match what is on screen.
 */
function contrastOver(color: Color, background: Color): string {
  const painted = mix(background, { ...color, alpha: 1 }, color.alpha, {
    space: 'srgb',
  });
  const ratio = contrastRatio(painted, background, { precision: '8bit' });
  return `${ratio.toFixed(2)}:1`;
}

export default function QuickStart() {
  const [input, setInput] = useState('oklch(0.7 0.25 30)');
  const color = tryParse(input);
  const shown = color ? toSrgbGamut(color) : null;

  return (
    <div className="grid gap-4">
      <input
        aria-label="CSS color"
        value={input}
        onChange={(event) => setInput(event.currentTarget.value)}
        spellCheck={false}
        className="w-full rounded-sm border border-border bg-transparent px-2 py-1 [font-family:var(--font-mono)] text-[13px] text-foreground"
      />
      {color && shown ? (
        <div className="flex items-start gap-4">
          <Chip css={toCss(shown, 'hex')} label="Displayed in sRGB" />
          <Readout
            rows={[
              ['parse', toCss(color, 'oklch')],
              ['inSrgbGamut', String(inSrgbGamut(color))],
              ['inP3Gamut', String(inP3Gamut(color))],
              ['toSrgbGamut', toCss(shown, 'oklch')],
              ['vs white', contrastOver(shown, WHITE)],
              ['vs black', contrastOver(shown, BLACK)],
            ]}
          />
        </div>
      ) : (
        <p className="m-0 text-[13px] text-muted-foreground">
          <code>tryParse</code> returned <code>null</code>: not a CSS color.
        </p>
      )}
    </div>
  );
}

Next