../

Color theory

Color models, harmonies, palettes and tonal scales in OKLCH, contrast (WCAG 2.2, APCA), color vision deficiency, dark mode, data visualization and wide gamut, with the modern CSS color functions. Where to use the colors is in UX/UI design principles.

Color models

ModelChannelsPerceptual?CSSUse for
RGB / hexred, green, blue (0–255)no#1c69e3, rgb(28 105 227)storage, hand-off, what screens emit
HSLhue, saturation, lightnessnohsl(217 78% 50%)quick tweaks; misleading lightness
HSV / HSBhue, saturation, valuenonone (use hwb())design tool pickers
HWBhue, whiteness, blacknessnohwb(217 11% 11%)"add white / add black" mixing
CIELABL*, a* (green–red), b* (blue–yellow)mostlylab(46% 14 -70)device-independent color, print
LCHlightness, chroma, hue (polar Lab)mostlylch(46% 71 281)Lab with intuitive axes; blues drift purple
OKLabL, a, byes (better)oklab(0.55 -0.035 -0.197)mixing, gradients, interpolation
OKLCHlightness 0–1, chroma 0–~0.37, hue 0–360yes (better)oklch(0.55 0.2 260)UI palettes, scales, states
CMYKcyan, magenta, yellow, key (black)nonone in browsersprint only; subtractive

Every CSS example in the table is the same blue, #1c69e3 (the HSL one to the nearest whole unit). RGB and HSL are additive, device-oriented models of sRGB. Lab, LCH, OKLab and OKLCH model human perception and are not tied to one gamut. Designers' RYB color wheel (red-yellow-blue, used in painting) puts complements in different places than RGB/OKLCH wheels: orange opposes blue in RYB, yellow opposes blue in RGB.

Why OKLCH for UI

PropertyBenefit
Perceptual lightnesssame L looks equally light across hues, so contrast is predictable
Independent channelschange L without the hue drifting (blue stays blue, unlike LCH)
Readableoklch(0.55 0.2 260): you can guess the color from the numbers
Wide gamutthe same syntax reaches Display P3 colors when chroma is high
Programmaticscales and hover states are arithmetic on L and C
AdoptedTailwind CSS v4's default palette and many design systems ship OKLCH

Caveat: max chroma depends on lightness and hue, so some L C H triples fall outside sRGB. The browser maps them back into range (see Gamut).

Color properties

PropertyMeaningOKLCH channelHSL channel
Hueposition on the wheel (red, blue…)h (degrees)h
Chroma / saturationcolorfulness vs grayc (absolute, 0 is gray)s (relative to max, per hue)
Lightness / valueperceived brightnessl (0 black, 1 white)l (math average, not perceived)
Temperaturewarm (red–yellow) vs cool (green–blue)hue rangehue range
Tint / shade / toneadd white / add black / add grayraise l / lower l / lower cmix

Warm colors advance and feel energetic; cool colors recede and feel calm. Tint neutrals slightly warm or cool (chroma 0.005–0.02) to match the brand instead of using pure gray.

Hue varies (l 0.65, c 0.1)
  • h 0oklch(0.65 0.1 0)
  • h 60oklch(0.65 0.1 60)
  • h 120oklch(0.65 0.1 120)
  • h 180oklch(0.65 0.1 180)
  • h 240oklch(0.65 0.1 240)
  • h 300oklch(0.65 0.1 300)
Chroma varies (l 0.6, h 260)
  • c 0oklch(0.6 0 260)
  • c 0.05oklch(0.6 0.05 260)
  • c 0.1oklch(0.6 0.1 260)
  • c 0.15oklch(0.6 0.15 260)
  • c 0.2oklch(0.6 0.2 260)
Lightness varies (c 0.06, h 260)
  • l 0.3oklch(0.3 0.06 260)
  • l 0.45oklch(0.45 0.06 260)
  • l 0.6oklch(0.6 0.06 260)
  • l 0.75oklch(0.75 0.06 260)
  • l 0.87oklch(0.87 0.06 260)

Only lightness moves contrast: the chroma row stays between 3.94:1 and 4.06:1 on white, the lightness row runs from 13.68:1 down to 1.48:1.

Perceptual uniformity (why HSL lies)

HSL computes lightness from the RGB channels, not from how bright the color looks:

ColorHSL lightnessOKLCH lContrast on white
hsl(60 100% 50%) yellow50%0.971.07:1
hsl(240 100% 50%) blue50%0.458.59:1
HSL: every chip is lightness 50%
  • hsl(0 100% 50%)
  • hsl(60 100% 50%)
  • hsl(120 100% 50%)
  • hsl(180 100% 50%)
  • hsl(240 100% 50%)
  • hsl(300 100% 50%)
OKLCH: every chip is l 0.7, c 0.11
  • oklch(0.7 0.11 30)
  • oklch(0.7 0.11 90)
  • oklch(0.7 0.11 150)
  • oklch(0.7 0.11 210)
  • oklch(0.7 0.11 270)
  • oklch(0.7 0.11 330)

Same "50% lightness", completely different perceived brightness and contrast: on white, the HSL row runs from 1.07:1 (yellow) to 8.59:1 (blue), while the OKLCH row stays within 2.55–2.81:1. A palette built by stepping HSL lightness gives uneven steps and unpredictable contrast between hues; in OKLCH, equal l steps look like equal steps, and the same step number across hues has similar contrast.

Harmonies

Angles are on the hue wheel (OKLCH h). Harmonies pick hues; lightness and chroma still decide whether the result works.

complementary 260 · 80 analogous 230 · 260 · 290 split-complementary 260 · 50 · 110 triadic 260 · 20 · 140
Harmonies from base hue 260 on the OKLCH wheel (l 0.72, c 0.11)
SchemeHuesFeelUI use
Monochromaticone hue, many l/ccalm, cohesiveproduct UI default: one brand hue + neutrals
Analogous3 neighbors within ~30–60°harmonious, low tensiongradients, illustrations, brand accents
Complementaryopposite, 180° aparthigh contrast, vibrantone accent against the main hue; avoid equal amounts
Split-complementarybase + two hues at 180° ± 30°contrast with less tensionbrand + two accents
Triadic3 hues 120° apartlively, balancedillustration, playful brands; mute two of them
Tetradic (rectangle / square)4 hues, two complementary pairsrich, hard to balancedata viz categories, let one dominate
:root {
  /* c 0.11 keeps every rotated hue inside sRGB */
  --base: oklch(0.6 0.11 260);
  --complement: oklch(from var(--base) l c calc(h + 180));
  --split-a: oklch(from var(--base) l c calc(h + 150));
  --split-b: oklch(from var(--base) l c calc(h + 210));
  --triad-a: oklch(from var(--base) l c calc(h + 120));
  --triad-b: oklch(from var(--base) l c calc(h + 240));
}
Complementary: 260 + 80
  • baseoklch(0.6 0.11 260)
  • complementoklch(0.6 0.11 80)
Analogous: 230, 260, 290
  • -30oklch(0.6 0.11 230)
  • baseoklch(0.6 0.11 260)
  • +30oklch(0.6 0.11 290)
Split-complementary: 260, 50, 110
  • baseoklch(0.6 0.11 260)
  • split-aoklch(0.6 0.11 50)
  • split-boklch(0.6 0.11 110)
Triadic: 260, 20, 140
  • baseoklch(0.6 0.11 260)
  • triad-aoklch(0.6 0.11 20)
  • triad-boklch(0.6 0.11 140)

Building a palette

RoleScaleUsed for
Neutral (gray)11 steps, chroma 0–0.02backgrounds, surfaces, borders, text; most of the UI
Primary (brand)11 stepsprimary actions, links, focus, selection
Accent1–2 scales, used sparinglyhighlights, badges, marketing
Dangerred, hue ~25–30errors, destructive actions
Warningamber, hue ~60–85caution, pending states
Successgreen, hue ~145–150confirmations, positive deltas
Infoblue or cyan, hue ~215–260neutral notices (can reuse primary)

Around OKLCH l 0.5, every semantic hue at full sRGB chroma gives at least 4.5:1 on white (red 6.7, amber 6.2, green 5.6, blue 6.0). Warning yellows are too light for text: use a dark amber for text and a pale amber background for fills.

60-30-10 rule

ShareRoleIn UI
~60%dominantbackground and surfaces (neutrals)
~30%secondarytext, cards, navigation, secondary surfaces
~10%accentprimary buttons, links, highlights, key data
60-30-10 with this sheet's scales
  • gray-100#f3f5f960%
  • gray-800#25262930%
  • blue-600#1c69e310%

A starting ratio from interior design, not a law. The point is that the accent stays rare so it keeps its pull (Von Restorff effect).

Tonal scales

A scale is one hue in 11 steps (50–950, as in Tailwind) or 12 (Radix). Lightness falls steadily; chroma peaks in the middle and fades at both ends, because very light and very dark colors can't be very saturated.

StepsTypical roleContrast on white (this scale)
50–100app background, subtle fills, hover on neutral rows~1.1–1.2
200–300borders, dividers, selected rows, disabled fills~1.5–1.9
400–500icons, solid fills with dark text, large text, focus rings~2.6–3.7
600–700solid buttons with white text, links, body-size colored text~5.0–6.7
800–950high-contrast text, headings, dark-mode surfaces~9–15
blue.css
:root {
  --blue-50: oklch(0.97 0.01 260);   /* #f1f5fc */
  --blue-100: oklch(0.93 0.02 260);  /* #e0e8f6 */
  --blue-200: oklch(0.87 0.05 260);  /* #c1d5f6 */
  --blue-300: oklch(0.8 0.09 260);   /* #9cbff8 */
  --blue-400: oklch(0.71 0.13 260);  /* #70a1f2 */
  --blue-500: oklch(0.62 0.18 260);  /* #3f82f0 */
  --blue-600: oklch(0.55 0.2 260);   /* #1c69e3 */
  --blue-700: oklch(0.48 0.17 260);  /* #1957bb */
  --blue-800: oklch(0.41 0.15 260);  /* #0f449a */
  --blue-900: oklch(0.35 0.12 260);  /* #0f3778 */
  --blue-950: oklch(0.27 0.09 260);  /* #082451 */
}
blue.css, hue 260
  • 50#f1f5fc
  • 100#e0e8f6
  • 200#c1d5f6
  • 300#9cbff8
  • 400#70a1f2
  • 500#3f82f0
  • 600#1c69e3
  • 700#1957bb
  • 800#0f449a
  • 900#0f3778
  • 950#082451

These values sit inside sRGB (chroma ~90% of the max for each lightness at hue 260). For another hue, keep the l column and re-fit c to that hue's gamut; yellows peak light, blues peak dark.

gray.css
:root {
  --gray-50: oklch(0.985 0.005 260);
  --gray-100: oklch(0.97 0.005 260);
  --gray-200: oklch(0.922 0.005 260);
  --gray-300: oklch(0.87 0.005 260);
  --gray-400: oklch(0.708 0.005 260);
  --gray-500: oklch(0.556 0.005 260);   /* 4.7:1 on white */
  --gray-600: oklch(0.439 0.005 260);
  --gray-700: oklch(0.371 0.005 260);
  --gray-800: oklch(0.269 0.005 260);
  --gray-900: oklch(0.205 0.005 260);
  --gray-950: oklch(0.145 0.005 260);
}

The same lightness curve on other hues: a danger red (chroma capped by the gamut at each step) and a cool neutral with barely any chroma.

Danger, hue 27, chroma up to 0.2
  • 50#fff2f0
  • 100#ffe0db
  • 200#ffc4bc
  • 300#ff9b90
  • 400#ff645a
  • 500#e2403a
  • 600#c2181d
  • 700#9e010e
  • 800#780108
  • 900#540004
  • 950#360002
Neutral, hue 260, chroma 0.01
  • 50#f1f5fc
  • 100#e4e8ef
  • 200#d0d4db
  • 300#b7bbc1
  • 400#9b9fa5
  • 500#808389
  • 600#66696f
  • 700#4f5358
  • 800#3a3d42
  • 900#26292e
  • 950#16181d

Contrast

WCAG 2.2 contrast ratios

Ratio = (L1 + 0.05) / (L2 + 0.05), with L1 the lighter relative luminance. Range 1:1 to 21:1. Don't round: 4.49:1 fails 4.5:1.

ContentAAAAASC
Body text4.5:17:11.4.3 / 1.4.6
Large text (24 px, or 18.66 px bold)3:14.5:11.4.3 / 1.4.6
UI component boundaries, icons, chart marks3:1none1.4.11
Focus indicator vs unfocused statenone at AA3:12.4.13
Disabled controls, decoration, logosexemptexemptnone
PairRatioResult
#777777 on white4.47:1fails AA text
#767676 on white4.54:1passes AA text
blue-500 #3f82f0 on white3.70:1large text and icons only
blue-600 #1c69e3 on white5.02:1passes AA text
black on blue-5005.66:1passes: mid tones often need dark text

Ratios are truncated, not rounded, as a checker should. From fail to AAA:

blue-300 text on white
#9cbff8 on #ffffff · 1.87:1 · text fail · large fail
#777777 text on white
#777777 on #ffffff · 4.47:1 · text fail · large AA
White on blue-500
#ffffff on #3f82f0 · 3.70:1 · text fail · large AA
Black on blue-500
#000000 on #3f82f0 · 5.66:1 · text AA · large AAA
White on blue-600
#ffffff on #1c69e3 · 5.02:1 · text AA · large AAA
blue-800 text on white
#0f449a on #ffffff · 9.07:1 · text AAA · large AAA

APCA (briefly)

The Accessible Perceptual Contrast Algorithm, a candidate for WCAG 3, scores lightness contrast (Lc, about -108 to 106) and accounts for polarity (dark on light vs light on dark) and font size and weight. Its published guidance, roughly: Lc 90 preferred for body text, 75 minimum for body, 60 for other content text, 45 for large headings, 30 for non-text and placeholder, 15 for dividers. WCAG 3 is still a draft: WCAG 2.x AA is the legal baseline (ADA cases, EN 301 549 and the European Accessibility Act), so check WCAG 2 first and use APCA to catch dark-mode pairs that pass the formula but read poorly.

Color vision deficiency

About 8% of men and 0.5% of women (Northern European ancestry) have some color vision deficiency; red-green types dominate.

TypeCone affectedConfusesApprox. prevalence (men)
DeuteranomalyM (green) weakred/green/brown, muted greens~5% (most common)
DeuteranopiaM missingred and green~1%
ProtanomalyL (red) weakred/green; reds look darker~1%
ProtanopiaL missingred and green; red looks near-black~1%
Tritanomaly / tritanopiaS (blue)blue/green, yellow/pinkvery rare, both sexes
Achromatopsia (monochromacy)none workall hues; sees lightness only~1 in 30,000
RuleExample
Never color alone (WCAG 1.4.1)error = red + icon + message; required = text, not only a red asterisk
Differ in lightness, not just huestatus dots at clearly different l survive grayscale
Avoid red vs green as the only pairuse blue/orange, or red/blue, for good/bad
Underline links in body textor 3:1 contrast with surrounding text plus a non-color cue on hover/focus
Label chart series directlylegends by color alone fail; add labels, shapes, dashes
Test with simulatorsChrome DevTools, Rendering, "Emulate vision deficiencies"; Sim Daltonism (macOS)

Each pair next to a deuteranopia simulation (Machado et al. 2009, full severity):

Risky: red vs green collapses to two olives
  • red#d62728
  • green#2ca02c
  • red, deutan#8b7c1f
  • green, deutan#968838
Safer: blue vs orange stays distinct
  • blue#0072b2
  • orange#e69f00
  • blue, deutan#3b67b1
  • orange, deutan#cab411

Meaning & psychology

Associations depend on culture and context, and the research on color psychology is weak. Treat this table as conventions to check, not rules.

ColorCommon Western UI meaningCaveats elsewhere
Rederror, danger, stop, urgency, saleluck and celebration in China; East Asian stock tickers show gains in red
Greensuccess, go, money, naturelosses in some East Asian markets; political meanings vary
Bluetrust, calm, links, corporatethe most "neutral" brand color, so also the least distinctive
Yellow / amberwarning, caution, attentionlow contrast on white; hard to use for text
Orangeenergy, friendly calls to actionalso used for warnings
Purplepremium, creative, magicalmourning in some countries (e.g. Thailand, Brazil)
Blackluxury, formalitymourning in much of the West
Whiteclean, empty, puremourning in parts of East and South Asia

Existing conventions (red = error, green = success) beat any psychological argument. Test with your users when a market differs.

Dark mode palettes

Dark mode is a separate mapping of the scales, not an inversion.

RuleLightDark
Page backgroundgray-50 or whitegray-950 (l ~0.15), not pure black
Elevationshadowslighter surfaces: 950, 900, 800 as layers rise
Body textgray-950gray-100 (~l 0.93–0.97), not pure white
Muted textgray-600gray-400
Bordersgray-200gray-800, or white at ~10% alpha
Accent / linksblue-600blue-400: lighter, less chroma, still 4.5:1
Text on accentwhiteoften dark (gray-950) on the lighter accent
Saturationfulllower chroma; saturated colors vibrate on dark
Semantic colors600 steps400 steps for text, low-alpha fills for backgrounds
Light theme tokens
  • bg: gray-50#f8fafe
  • surface#ffffff
  • fg: gray-950#090a0c
  • muted: gray-600#515255
  • border: gray-200#e3e5e9
  • accent: blue-600#1c69e3
Dark theme tokens
  • bg: gray-950#090a0c
  • surface: gray-900#161719
  • fg: gray-100#f3f5f9
  • muted: gray-400#9fa1a4
  • border: gray-800#252629
  • accent: blue-400#70a1f2

In this palette, blue-400 on gray-950 is 7.6:1 and gray-400 on gray-950 is 7.6:1. Re-check every pair, because mid-tone pairs that pass in light mode often fail after mapping: blue-600 kept as the dark mode link color drops to 3.94:1.

Link in dark mode: blue-400 on gray-950
#70a1f2 on #090a0c · 7.60:1 · text AAA · large AAA
Light-mode link carried over: blue-600 on gray-950
#1c69e3 on #090a0c · 3.94:1 · text fail · large AA

Use color-scheme so native controls and scrollbars follow the theme.

Data visualization palettes

TypeDataBuild itExamples
Categorical (qualitative)unordered groupsdistinct hues at similar l and c; at most ~8, then group into "other"Okabe–Ito, Tableau 10
Sequentialordered, low to highone hue (or a few) with lightness changing monotonicallyviridis, cividis, Blues
Divergingtwo directions from a meaningful midpointtwo hues meeting at a light neutralRdBu, PuOr, BrBG

Viridis and cividis are perceptually uniform, readable with color vision deficiency and in grayscale. Avoid rainbow ("jet") scales: their lightness goes up and down, which invents boundaries that aren't in the data. Encode important values with position or length first; color is the weakest channel.

Sequential: viridis, 5 stops
  • #440154
  • #3b528b
  • #21918c
  • #5ec962
  • #fde725
Diverging: ColorBrewer RdBu, 7 classes
  • #b2182b
  • #ef8a62
  • #fddbc7
  • #f7f7f7
  • #d1e5f0
  • #67a9cf
  • #2166ac
Avoid: jet rainbow, lightness goes up and down
  • #00007f
  • #0000ff
  • #00ffff
  • #7fff7f
  • #ffff00
  • #ff0000
  • #7f0000
okabe-ito.css
/* color-blind-safe categorical palette */
:root {
  --cat-1: #e69f00; /* orange */
  --cat-2: #56b4e9; /* sky blue */
  --cat-3: #009e73; /* bluish green */
  --cat-4: #f0e442; /* yellow */
  --cat-5: #0072b2; /* blue */
  --cat-6: #d55e00; /* vermillion */
  --cat-7: #cc79a7; /* reddish purple */
  --cat-8: #000000; /* black */
}
Okabe–Ito categorical
  • orange#e69f00
  • sky blue#56b4e9
  • bluish green#009e73
  • yellow#f0e442
  • blue#0072b2
  • vermillion#d55e00
  • reddish purple#cc79a7
  • black#000000

Gamut & wide color

GamutSizeWhere
sRGBthe web default; everything hex, rgb(), hsl() can expressevery screen
Display P3noticeably wider, mostly in greens, reds and oranges; contains sRGBApple devices, most recent phones and many laptops
Rec. 2020wider stillHDR video; few displays cover it
CSSDoes
color(display-p3 0.1 0.4 1)a color defined in the P3 space
oklch(0.55 0.25 260)OKLCH reaches outside sRGB when chroma is high
@media (color-gamut: p3)the screen can show roughly P3 or more
@media (dynamic-range: high)HDR-capable display

Colors outside the screen's gamut are mapped into it. CSS Color 4 asks for a perceptual mapping (reduce chroma in OKLCH); engines have historically clipped channels, which can shift hue. Keep an sRGB value as the default and put vivid P3 values behind @media (color-gamut: p3) (see Recipes). Hex can't express P3 colors, and design tokens exported as hex lose them.

CSS color toolkit

FeatureExampleBaseline
oklch() / oklab()oklch(0.55 0.2 260 / 80%)2023
color()color(display-p3 1 0.5 0)2023
color-mix()color-mix(in oklch, var(--a) 30%, white)2023
Relative color syntaxoklch(from var(--a) calc(l - 0.1) c h)2024
light-dark()light-dark(var(--gray-50), var(--gray-950))2024
contrast-color()contrast-color(var(--bg)): black or white2026
color-schemecolor-scheme: light darkwidely
accent-colortints checkboxes, radios, range, progresswidely
@media (prefers-contrast: more)stronger borders and text for users who askwidely
@media (forced-colors: active)Windows high contrast; use system colors (Canvas, CanvasText, Highlight)widely
.tag {
  /* tinted background from one token, 12% of the accent */
  background: color-mix(in oklch, var(--accent) 12%,
    transparent);
  color: var(--accent);
}
 
.overlay {
  /* same color, new alpha */
  background: oklch(from var(--gray-950) l c h / 60%);
}
 
.badge {
  background: var(--badge-bg);
  color: contrast-color(var(--badge-bg));
}

One token, six colors: color-mix() tints and relative-syntax variants of --c.

one-token.html
<style>
  .chips { --c: oklch(0.55 0.2 260);
    display: flex; flex-wrap: wrap; gap: 6px; }
  .chips span { padding: 6px 10px; border-radius: 4px; }
  .t12 { background: color-mix(in oklch, var(--c) 12%,
    transparent); }
  .t40 { background: color-mix(in oklch, var(--c) 40%,
    transparent); }
  .base { background: var(--c); color: white; }
  .dark { background: oklch(from var(--c)
    calc(l - 0.15) c h); color: white; }
  .half { background: oklch(from var(--c) l c h / 50%); }
  .comp { background: oklch(from var(--c) l 0.11
    calc(h + 180)); color: white; }
</style>
<div class="chips">
  <span class="t12">mix 12%</span>
  <span class="t40">mix 40%</span>
  <span class="base">base</span>
  <span class="dark">l - 0.15</span>
  <span class="half">alpha 50%</span>
  <span class="comp">h + 180</span>
</div>
Result

The interpolation space changes the result: in srgb mixes of blue and yellow pass through gray, in oklch keeps chroma through the middle. Use in oklab for gradients and neutral mixes, in oklch to keep colors vivid. In relative syntax, OKLCH l is a number from 0 to 1, so step it by 0.05, not 5%. contrast-color() only picks black or white and is new: give it a fallback, and don't rely on it for mid-tone backgrounds. More CSS in CSS.

interpolation.html
<style>
  .row { display: grid; grid-template-columns: 6ch 1fr;
    gap: 8px; align-items: center; margin: 0 0 8px; }
  .row i { height: 26px; border-radius: 4px; }
  .srgb { background: linear-gradient(to right in srgb,
    #0050ff, #ffe600); }
  .oklab { background: linear-gradient(to right in oklab,
    #0050ff, #ffe600); }
  .oklch { background: linear-gradient(to right in oklch,
    #0050ff, #ffe600); }
</style>
<p class="row">srgb <i class="srgb"></i></p>
<p class="row">oklab <i class="oklab"></i></p>
<p class="row">oklch <i class="oklch"></i></p>
Result

light-dark() picks by the element's color-scheme, so one declaration serves both themes:

light-dark.html
<style>
  .card { padding: 10px 12px; border-radius: 6px;
    background: light-dark(#f8fafe, #090a0c);
    color: light-dark(#090a0c, #f3f5f9);
    border: 1px solid light-dark(#e3e5e9, #252629); }
  .card a { color: light-dark(#1c69e3, #70a1f2); }
  .light { color-scheme: light; }
  .dark { color-scheme: dark; }
  .pair { display: grid; gap: 8px;
    grid-template-columns: 1fr 1fr; }
</style>
<div class="pair">
  <div class="card light">Light: <a href="#">link</a></div>
  <div class="card dark">Dark: <a href="#">link</a></div>
</div>
Result

Tools

ToolFor
oklch.com (opens in a new tab)OKLCH picker that shows the sRGB and P3 gamut edges
Adobe Leonardo (opens in a new tab)scales generated from target contrast ratios, with themes
Radix Colors (opens in a new tab)ready 12-step scales with matching dark and P3 versions
Coolors (opens in a new tab)quick palette exploration and export
Huetone (opens in a new tab)editing scales in LCH/OKLCH with APCA checks
WebAIM contrast checker (opens in a new tab)WCAG 2 ratio for a pair
APCA calculator (opens in a new tab)Lc values
ColorBrewer (opens in a new tab)sequential, diverging and categorical data palettes
Chrome DevToolscontrast in the color picker, vision deficiency emulation, CSS Overview colors
culori (opens in a new tab), Color.js (opens in a new tab)convert, gamut-check and measure in TS (bun add culori)

Recipes

Tonal scale from one hue variable

When you want to re-theme by changing a hue; re-tune chroma if the new hue falls out of gamut.

:root {
  --brand-h: 260;
  --brand-50: oklch(0.97 0.01 var(--brand-h));
  --brand-100: oklch(0.93 0.02 var(--brand-h));
  --brand-200: oklch(0.87 0.05 var(--brand-h));
  --brand-300: oklch(0.8 0.09 var(--brand-h));
  --brand-400: oklch(0.71 0.13 var(--brand-h));
  --brand-500: oklch(0.62 0.18 var(--brand-h));
  --brand-600: oklch(0.55 0.2 var(--brand-h));
  --brand-700: oklch(0.48 0.17 var(--brand-h));
  --brand-800: oklch(0.41 0.15 var(--brand-h));
  --brand-900: oklch(0.35 0.12 var(--brand-h));
  --brand-950: oklch(0.27 0.09 var(--brand-h));
}
 
.theme-green { --brand-h: 150; }

Hover and active states from one token

When every button color needs matching interaction states without extra tokens.

.btn {
  --bg: var(--brand-600);
  background: var(--bg);
  color: white;
}
 
.btn:hover {
  background: oklch(from var(--bg) calc(l - 0.06) c h);
}
 
.btn:active {
  background: oklch(from var(--bg) calc(l - 0.12) c h);
}
 
.btn:disabled {
  background: oklch(from var(--bg) l calc(c * 0.3) h / 50%);
}

WCAG contrast check in TypeScript

For tests, token pipelines or a lint step that fails on low-contrast pairs.

contrast.ts
// sRGB channel (0-255) to linear light, per WCAG 2.2
const linear = (c8: number): number => {
  const c = c8 / 255;
  return c <= 0.04045
    ? c / 12.92
    : ((c + 0.055) / 1.055) ** 2.4;
};
 
export function luminance(hex: string): number {
  const m = /^#?([\da-f]{6})$/i.exec(hex);
  if (!m?.[1]) throw new Error(`Not a 6-digit hex: ${hex}`);
  const n = Number.parseInt(m[1], 16);
  const r = linear((n >> 16) & 255);
  const g = linear((n >> 8) & 255);
  const b = linear(n & 255);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
 
export function contrast(a: string, b: string): number {
  const [la, lb] = [luminance(a), luminance(b)];
  const hi = Math.max(la, lb);
  const lo = Math.min(la, lb);
  return (hi + 0.05) / (lo + 0.05);
}

Pass/fail and black-or-white text

Builds on contrast.ts; textOn is the TS fallback for contrast-color().

a11y-color.ts
import { contrast } from "./contrast.ts";
 
type Level = "AA" | "AAA";
 
const MIN: Record<Level, { text: number; large: number }> = {
  AA: { text: 4.5, large: 3 },
  AAA: { text: 7, large: 4.5 },
};
 
export const passes = (
  fg: string,
  bg: string,
  level: Level = "AA",
  large = false,
) => {
  const min = MIN[level][large ? "large" : "text"];
  return contrast(fg, bg) >= min;
};
 
export const textOn = (bg: string): "#000000" | "#ffffff" =>
  contrast("#000000", bg) >= contrast("#ffffff", bg)
    ? "#000000"
    : "#ffffff";
 
passes("#1c69e3", "#ffffff");       // true  (5.02:1)
passes("#3f82f0", "#ffffff");       // false (3.70:1)
textOn("#3f82f0");                  // "#000000" (5.66:1)

Audit an OKLCH scale

Prints hex, gamut and contrast for each step, so you can tune l and c before shipping.

audit-scale.ts
import { formatHex, inGamut, wcagContrast } from "culori";
 
const inSrgb = inGamut("rgb");
const hue = 260;
const steps = [
  [50, 0.97, 0.01], [100, 0.93, 0.02], [200, 0.87, 0.05],
  [300, 0.8, 0.09], [400, 0.71, 0.13], [500, 0.62, 0.18],
  [600, 0.55, 0.2], [700, 0.48, 0.17], [800, 0.41, 0.15],
  [900, 0.35, 0.12], [950, 0.27, 0.09],
] as const;
 
const rows = steps.map(([step, l, c]) => {
  const color = { mode: "oklch", l, c, h: hue } as const;
  return {
    step,
    hex: formatHex(color),
    srgb: inSrgb(color),
    onWhite: wcagContrast(color, "#ffffff").toFixed(2),
  };
});
 
console.table(rows);   // bun audit-scale.ts

Dark-mode token mapping

Semantic tokens pick a primitive per scheme; components never reference primitives directly.

theme.css
:root {
  color-scheme: light dark;   /* follow the OS */
  --bg: light-dark(var(--gray-50), var(--gray-950));
  --surface: light-dark(white, var(--gray-900));
  --fg: light-dark(var(--gray-950), var(--gray-100));
  --fg-muted: light-dark(var(--gray-600), var(--gray-400));
  --border: light-dark(var(--gray-200), var(--gray-800));
  --accent: light-dark(var(--blue-600), var(--blue-400));
  --on-accent: light-dark(white, var(--gray-950));
}
 
/* a manual toggle overrides the OS */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
 
body { background: var(--bg); color: var(--fg); }

Vivid brand color on wide-gamut screens

sRGB for everyone, a more saturated P3 version where the display can show it.

:root {
  --brand: oklch(0.55 0.2 260);    /* in sRGB */
}
 
@media (color-gamut: p3) {
  :root {
    --brand: oklch(0.55 0.245 260); /* P3 only */
  }
}

References