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
| Model | Channels | Perceptual? | CSS | Use for |
|---|---|---|---|---|
| RGB / hex | red, green, blue (0–255) | no | #1c69e3, rgb(28 105 227) | storage, hand-off, what screens emit |
| HSL | hue, saturation, lightness | no | hsl(217 78% 50%) | quick tweaks; misleading lightness |
| HSV / HSB | hue, saturation, value | no | none (use hwb()) | design tool pickers |
| HWB | hue, whiteness, blackness | no | hwb(217 11% 11%) | "add white / add black" mixing |
| CIELAB | L*, a* (green–red), b* (blue–yellow) | mostly | lab(46% 14 -70) | device-independent color, print |
| LCH | lightness, chroma, hue (polar Lab) | mostly | lch(46% 71 281) | Lab with intuitive axes; blues drift purple |
| OKLab | L, a, b | yes (better) | oklab(0.55 -0.035 -0.197) | mixing, gradients, interpolation |
| OKLCH | lightness 0–1, chroma 0–~0.37, hue 0–360 | yes (better) | oklch(0.55 0.2 260) | UI palettes, scales, states |
| CMYK | cyan, magenta, yellow, key (black) | no | none in browsers | print 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
| Property | Benefit |
|---|---|
| Perceptual lightness | same L looks equally light across hues, so contrast is predictable |
| Independent channels | change L without the hue drifting (blue stays blue, unlike LCH) |
| Readable | oklch(0.55 0.2 260): you can guess the color from the numbers |
| Wide gamut | the same syntax reaches Display P3 colors when chroma is high |
| Programmatic | scales and hover states are arithmetic on L and C |
| Adopted | Tailwind 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
| Property | Meaning | OKLCH channel | HSL channel |
|---|---|---|---|
| Hue | position on the wheel (red, blue…) | h (degrees) | h |
| Chroma / saturation | colorfulness vs gray | c (absolute, 0 is gray) | s (relative to max, per hue) |
| Lightness / value | perceived brightness | l (0 black, 1 white) | l (math average, not perceived) |
| Temperature | warm (red–yellow) vs cool (green–blue) | hue range | hue range |
| Tint / shade / tone | add white / add black / add gray | raise l / lower l / lower c | mix |
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.
- 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)
- 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)
- 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:
| Color | HSL lightness | OKLCH l | Contrast on white |
|---|---|---|---|
hsl(60 100% 50%) yellow | 50% | 0.97 | 1.07:1 |
hsl(240 100% 50%) blue | 50% | 0.45 | 8.59:1 |
- 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(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.
| Scheme | Hues | Feel | UI use |
|---|---|---|---|
| Monochromatic | one hue, many l/c | calm, cohesive | product UI default: one brand hue + neutrals |
| Analogous | 3 neighbors within ~30–60° | harmonious, low tension | gradients, illustrations, brand accents |
| Complementary | opposite, 180° apart | high contrast, vibrant | one accent against the main hue; avoid equal amounts |
| Split-complementary | base + two hues at 180° ± 30° | contrast with less tension | brand + two accents |
| Triadic | 3 hues 120° apart | lively, balanced | illustration, playful brands; mute two of them |
| Tetradic (rectangle / square) | 4 hues, two complementary pairs | rich, hard to balance | data 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));
}- baseoklch(0.6 0.11 260)
- complementoklch(0.6 0.11 80)
- -30oklch(0.6 0.11 230)
- baseoklch(0.6 0.11 260)
- +30oklch(0.6 0.11 290)
- baseoklch(0.6 0.11 260)
- split-aoklch(0.6 0.11 50)
- split-boklch(0.6 0.11 110)
- baseoklch(0.6 0.11 260)
- triad-aoklch(0.6 0.11 20)
- triad-boklch(0.6 0.11 140)
Building a palette
| Role | Scale | Used for |
|---|---|---|
| Neutral (gray) | 11 steps, chroma 0–0.02 | backgrounds, surfaces, borders, text; most of the UI |
| Primary (brand) | 11 steps | primary actions, links, focus, selection |
| Accent | 1–2 scales, used sparingly | highlights, badges, marketing |
| Danger | red, hue ~25–30 | errors, destructive actions |
| Warning | amber, hue ~60–85 | caution, pending states |
| Success | green, hue ~145–150 | confirmations, positive deltas |
| Info | blue or cyan, hue ~215–260 | neutral 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
| Share | Role | In UI |
|---|---|---|
| ~60% | dominant | background and surfaces (neutrals) |
| ~30% | secondary | text, cards, navigation, secondary surfaces |
| ~10% | accent | primary buttons, links, highlights, key data |
- 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.
| Steps | Typical role | Contrast on white (this scale) |
|---|---|---|
| 50–100 | app background, subtle fills, hover on neutral rows | ~1.1–1.2 |
| 200–300 | borders, dividers, selected rows, disabled fills | ~1.5–1.9 |
| 400–500 | icons, solid fills with dark text, large text, focus rings | ~2.6–3.7 |
| 600–700 | solid buttons with white text, links, body-size colored text | ~5.0–6.7 |
| 800–950 | high-contrast text, headings, dark-mode surfaces | ~9–15 |
: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 */
}- 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.
: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.
- 50#fff2f0
- 100#ffe0db
- 200#ffc4bc
- 300#ff9b90
- 400#ff645a
- 500#e2403a
- 600#c2181d
- 700#9e010e
- 800#780108
- 900#540004
- 950#360002
- 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.
| Content | AA | AAA | SC |
|---|---|---|---|
| Body text | 4.5:1 | 7:1 | 1.4.3 / 1.4.6 |
| Large text (24 px, or 18.66 px bold) | 3:1 | 4.5:1 | 1.4.3 / 1.4.6 |
| UI component boundaries, icons, chart marks | 3:1 | none | 1.4.11 |
| Focus indicator vs unfocused state | none at AA | 3:1 | 2.4.13 |
| Disabled controls, decoration, logos | exempt | exempt | none |
| Pair | Ratio | Result |
|---|---|---|
#777777 on white | 4.47:1 | fails AA text |
#767676 on white | 4.54:1 | passes AA text |
blue-500 #3f82f0 on white | 3.70:1 | large text and icons only |
blue-600 #1c69e3 on white | 5.02:1 | passes AA text |
| black on blue-500 | 5.66:1 | passes: mid tones often need dark text |
Ratios are truncated, not rounded, as a checker should. From fail to AAA:
#9cbff8 on #ffffff · 1.87:1 · text fail · large fail#777777 on #ffffff · 4.47:1 · text fail · large AA#ffffff on #3f82f0 · 3.70:1 · text fail · large AA#000000 on #3f82f0 · 5.66:1 · text AA · large AAA#ffffff on #1c69e3 · 5.02:1 · text AA · large AAA#0f449a on #ffffff · 9.07:1 · text AAA · large AAAAPCA (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.
| Type | Cone affected | Confuses | Approx. prevalence (men) |
|---|---|---|---|
| Deuteranomaly | M (green) weak | red/green/brown, muted greens | ~5% (most common) |
| Deuteranopia | M missing | red and green | ~1% |
| Protanomaly | L (red) weak | red/green; reds look darker | ~1% |
| Protanopia | L missing | red and green; red looks near-black | ~1% |
| Tritanomaly / tritanopia | S (blue) | blue/green, yellow/pink | very rare, both sexes |
| Achromatopsia (monochromacy) | none work | all hues; sees lightness only | ~1 in 30,000 |
| Rule | Example |
|---|---|
| Never color alone (WCAG 1.4.1) | error = red + icon + message; required = text, not only a red asterisk |
| Differ in lightness, not just hue | status dots at clearly different l survive grayscale |
| Avoid red vs green as the only pair | use blue/orange, or red/blue, for good/bad |
| Underline links in body text | or 3:1 contrast with surrounding text plus a non-color cue on hover/focus |
| Label chart series directly | legends by color alone fail; add labels, shapes, dashes |
| Test with simulators | Chrome DevTools, Rendering, "Emulate vision deficiencies"; Sim Daltonism (macOS) |
Each pair next to a deuteranopia simulation (Machado et al. 2009, full severity):
- red#d62728
- green#2ca02c
- red, deutan#8b7c1f
- green, deutan#968838
- 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.
| Color | Common Western UI meaning | Caveats elsewhere |
|---|---|---|
| Red | error, danger, stop, urgency, sale | luck and celebration in China; East Asian stock tickers show gains in red |
| Green | success, go, money, nature | losses in some East Asian markets; political meanings vary |
| Blue | trust, calm, links, corporate | the most "neutral" brand color, so also the least distinctive |
| Yellow / amber | warning, caution, attention | low contrast on white; hard to use for text |
| Orange | energy, friendly calls to action | also used for warnings |
| Purple | premium, creative, magical | mourning in some countries (e.g. Thailand, Brazil) |
| Black | luxury, formality | mourning in much of the West |
| White | clean, empty, pure | mourning 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.
| Rule | Light | Dark |
|---|---|---|
| Page background | gray-50 or white | gray-950 (l ~0.15), not pure black |
| Elevation | shadows | lighter surfaces: 950, 900, 800 as layers rise |
| Body text | gray-950 | gray-100 (~l 0.93–0.97), not pure white |
| Muted text | gray-600 | gray-400 |
| Borders | gray-200 | gray-800, or white at ~10% alpha |
| Accent / links | blue-600 | blue-400: lighter, less chroma, still 4.5:1 |
| Text on accent | white | often dark (gray-950) on the lighter accent |
| Saturation | full | lower chroma; saturated colors vibrate on dark |
| Semantic colors | 600 steps | 400 steps for text, low-alpha fills for backgrounds |
- bg: gray-50#f8fafe
- surface#ffffff
- fg: gray-950#090a0c
- muted: gray-600#515255
- border: gray-200#e3e5e9
- accent: blue-600#1c69e3
- 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.
#70a1f2 on #090a0c · 7.60:1 · text AAA · large AAA#1c69e3 on #090a0c · 3.94:1 · text fail · large AAUse color-scheme so
native controls and scrollbars follow the theme.
Data visualization palettes
| Type | Data | Build it | Examples |
|---|---|---|---|
| Categorical (qualitative) | unordered groups | distinct hues at similar l and c; at most ~8, then group into "other" | Okabe–Ito, Tableau 10 |
| Sequential | ordered, low to high | one hue (or a few) with lightness changing monotonically | viridis, cividis, Blues |
| Diverging | two directions from a meaningful midpoint | two hues meeting at a light neutral | RdBu, 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.
- #440154
- #3b528b
- #21918c
- #5ec962
- #fde725
- #b2182b
- #ef8a62
- #fddbc7
- #f7f7f7
- #d1e5f0
- #67a9cf
- #2166ac
- #00007f
- #0000ff
- #00ffff
- #7fff7f
- #ffff00
- #ff0000
- #7f0000
/* 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 */
}- orange#e69f00
- sky blue#56b4e9
- bluish green#009e73
- yellow#f0e442
- blue#0072b2
- vermillion#d55e00
- reddish purple#cc79a7
- black#000000
Gamut & wide color
| Gamut | Size | Where |
|---|---|---|
| sRGB | the web default; everything hex, rgb(), hsl() can express | every screen |
| Display P3 | noticeably wider, mostly in greens, reds and oranges; contains sRGB | Apple devices, most recent phones and many laptops |
| Rec. 2020 | wider still | HDR video; few displays cover it |
| CSS | Does |
|---|---|
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
| Feature | Example | Baseline |
|---|---|---|
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 syntax | oklch(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 white | 2026 |
color-scheme | color-scheme: light dark | widely |
accent-color | tints checkboxes, radios, range, progress | widely |
@media (prefers-contrast: more) | stronger borders and text for users who ask | widely |
@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.
<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>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.
<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>light-dark() picks by the element's color-scheme, so one declaration serves both themes:
<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>Tools
| Tool | For |
|---|---|
| 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 DevTools | contrast 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.
// 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().
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.
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.tsDark-mode token mapping
Semantic tokens pick a primitive per scheme; components never reference primitives directly.
: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
- MDN:
<color>(opens in a new tab),oklch()(opens in a new tab),color()(opens in a new tab),color-mix()(opens in a new tab), Using relative colors (opens in a new tab),light-dark()(opens in a new tab),contrast-color()(opens in a new tab),color-gamut(opens in a new tab),forced-colors(opens in a new tab), Color contrast (opens in a new tab) - W3C: WCAG 2.2 (opens in a new tab) (relative luminance, 1.4.1, 1.4.3, 1.4.11), CSS Color Module Level 4 (opens in a new tab), CSS Color Module Level 5 (opens in a new tab)
- Björn Ottosson, A perceptual color space for image processing (opens in a new tab): the OKLab definition
- Evil Martians, OKLCH in CSS: why we moved from RGB and HSL (opens in a new tab)
- APCA documentation (opens in a new tab): Lc levels and use cases
- Okabe & Ito, Color Universal Design (opens in a new tab): the color-blind-safe palette
- Introduction to viridis (opens in a new tab): why perceptually uniform color maps
- Refactoring UI (opens in a new tab): building palettes with enough shades