Color Systems skill for Claude Code
OKLCH palettes where every pair passes contrast. Instead of picking hex codes by eye or defaulting to an indigo gradient, Claude generates perceptually even OKLCH scales, assigns them to semantic roles like muted text and input borders, and runs a bundled Python script that checks every pair against WCAG 2.2 thresholds. Dark mode gets its own mapping with lighter accents and stepped surfaces rather than an inversion.
- name
color-systems - size192 lines · ≈1,900 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds color palettes in OKLCH with perceptually even lightness steps, maps them to semantic roles (surface, text, border, accent, status), checks every pair against WCAG contrast, and derives a dark-mode mapping. Includes a stdlib script for contrast ratios, hex-OKLCH conversion, and gamut-safe scale generation. Use when choosing brand or UI colors, fixing low-contrast text, replacing a default purple gradient, or extending a palette. Try asking
- Build a color palette from our brand blue that passes AA everywhere
- Our gray text fails contrast audits. Fix the neutral scale without changing the look much
- Replace this purple gradient landing page palette with something that feels like our product
Use it when
- Choosing brand or product UI colors
- Accessibility audit flagged low-contrast text
- Extending a palette with status or chart colors
- Planning the color side of a dark theme
Skip it when
- You need the full token architecture across type, space and radius, use design-system-tokens instead
- The palette is fine and you need theme toggling mechanics, use dark-mode instead
How to check the result
Run scripts/color_tool.py contrast on every used text and background pair in both themes; it exits 0 only when all pairs meet the minimum ratio.
The common mistake it prevents
Using semi-transparent gray for muted text, which passes on white but drops below 4.5:1 on tinted surfaces and cards.
A palette is done when every hue has evenly stepped lightness, every text/background pair used in the product passes WCAG 2.2 AA, colors are referenced by role rather than by hue, and one accent does the talking. A palette chosen by eyeballing hex codes in a color picker is not done.
Ground rules
- Build scales in OKLCH. Equal steps in L look equal to people; equal steps in HSL do not (HSL yellow at 50% lightness is far brighter than blue at 50%).
- One accent hue for interactive elements. A second hue only with a job (e.g. data visualization, a distinct brand secondary used in illustration, never in buttons).
- Neutrals carry 80–90% of the interface. Tint them slightly toward the accent hue (chroma 0.005–0.02) so they do not look dead.
- Status colors (danger, warning, success, info) are semantic, not decorative. Never use red for a non-destructive accent.
- Color is never the only signal: pair it with an icon, text, or shape (WCAG 1.4.1).
Avoid the generic look
| Default reflex | Replace with |
|---|---|
Purple-to-blue gradient hero (#667eea → #764ba2) | A flat, confident canvas with one saturated accent; gradients only if they are subtle (ΔL ≤ 0.05) or carry brand meaning |
Pure #000 text on #fff | Near-black tinted neutral, e.g. oklch(0.21 0.015 H) on oklch(0.99 0.005 H) |
| Gray text at 40% opacity | An opaque muted neutral that still hits 4.5:1 |
| Rainbow of category colors on cards | Neutrals plus one accent; categories distinguished by label or icon |
| Neon accent on dark background everywhere | Lower chroma in dark mode (C × 0.8–0.9), raised lightness |
Tailwind default indigo-500 as the brand | Choose a hue from the product’s context; pick chroma deliberately |
Workflow
- [ ] 1. Collect constraints (brand colors, existing UI, audience, dark mode needed?)
- [ ] 2. Choose accent hue and chroma
- [ ] 3. Generate 11-step scales (accent, neutral, 4 status hues)
- [ ] 4. Map semantic roles for light theme
- [ ] 5. Map semantic roles for dark theme
- [ ] 6. Check every used pair; adjust lightness, not hue
- [ ] 7. Emit tokens and document usage
2. Choose the accent
- Start from the brand color if one exists; convert it to OKLCH and keep its hue.
- Chroma guide: 0.12–0.16 for calm product UI, 0.18–0.24 for expressive marketing. Very high chroma (>0.25) falls outside sRGB for most hues; it will clip.
- Hue sanity: blue 240–265, teal 180–200, green 140–155, amber 70–85, red 20–30, magenta 330–350. Yellow/amber hues cannot be both light and saturated with dark text contrast on white; use them as fills with dark text, not as text color.
3. Generate scales
Use the bundled script (stdlib only):
python3 scripts/color_tool.py scale 255 --chroma 0.16 --name blue
python3 scripts/color_tool.py scale 255 --chroma 0.015 --name neutral
python3 scripts/color_tool.py scale 25 --chroma 0.19 --name red
It prints steps 50–950 with OKLCH, hex, and contrast against white and black, reduces
chroma until each step fits sRGB, and emits a :root block. Lightness targets:
50 = 0.975 … 500 = 0.62 … 950 = 0.24. Tweak by hand when a hue looks off; keep
lightness, adjust chroma.
Convert existing colors before deciding:
python3 scripts/color_tool.py convert "#3b82f6" "oklch(0.62 0.21 260)"
4. Semantic mapping (light)
| Role | Step | Target |
|---|---|---|
bg.canvas | neutral-50 or white | — |
bg.surface | white or neutral-50 | Distinct from canvas by L 0.01–0.03 or by a border |
border.default | neutral-200 | Decorative; no contrast requirement |
border.strong (inputs) | neutral-400/500 | ≥ 3:1 against surface (WCAG 1.4.11) |
text.default | neutral-900 | ≥ 12:1 preferred |
text.muted | neutral-600 | ≥ 4.5:1 on every surface it appears on |
accent.default (fill) | accent-600 | White text on it ≥ 4.5:1 |
accent.hover | accent-700 | — |
accent.text (links) | accent-600/700 | ≥ 4.5:1 on canvas |
accent.subtle (selected row) | accent-50/100 | Text on it still ≥ 4.5:1 |
danger.text / danger.bg | red-700 / red-50 | ≥ 4.5:1 |
5. Dark mapping
Do not invert. Re-map:
- Canvas
oklch(0.16–0.20 0.01 H), not#000. Surfaces step up in lightness for elevation (0.20 → 0.24 → 0.28) because shadows barely read on dark. - Text default ≈ neutral-100 (L 0.93–0.95), not pure white, to reduce halation.
- Accent fill shifts lighter (accent-400/500) and slightly less chroma; check text on it — dark text may now be the correct pairing.
- Status backgrounds become low-L, low-chroma tints (L 0.25, C 0.04–0.06).
Full theme mechanics (toggles, flash prevention, images) live in dark-mode.
6. Check every pair
List the pairs the UI actually uses and check them in one run:
python3 scripts/color_tool.py contrast \
"#1f2937" "#ffffff" \
"#6b7280" "#f9fafb" \
"#ffffff" "oklch(0.54 0.154 255)" --min 4.5
Exit code 1 means a pair failed. Fix by moving lightness one step; keep hue so the palette stays coherent.
Thresholds (WCAG 2.2):
| Content | Minimum (AA) | Enhanced (AAA) |
|---|---|---|
| Body text < 24px (or < 18.66px bold) | 4.5:1 | 7:1 |
| Large text ≥ 24px or ≥ 18.66px bold | 3:1 | 4.5:1 |
| UI component boundaries, focus rings, icons that convey meaning | 3:1 | — |
| Disabled controls, decorative borders, logos | exempt | — |
APCA (proposed for WCAG 3) is useful as a second opinion for very light or very dark pairs where WCAG 2 ratios mislead, but AA compliance today is judged on WCAG 2 ratios.
7. Emit tokens
:root {
--blue-600: oklch(0.54 0.154 255);
--accent: var(--blue-600);
--accent-hover: oklch(0.46 0.138 255);
--text: oklch(0.21 0.015 255);
--text-muted: oklch(0.45 0.015 255);
}
/* hex fallback only if you must support browsers older than ~2023 */
@supports not (color: oklch(0 0 0)) {
:root { --accent: #236ec5; }
}
Derived states without new tokens (modern browsers):
.button:hover { background: oklch(from var(--accent) calc(l - 0.06) c h); }
.badge { background: color-mix(in oklch, var(--accent) 12%, transparent); }
Gotchas
- Opacity-based colors (
rgba(…, 0.6)) produce different contrast on every surface; compute against the real background or use an opaque token. - Text over images or gradients: check contrast at the lightest point of the image, or
add a scrim (
linear-gradient(to top, oklch(0 0 0 / 0.6), transparent)). - Focus rings must hit 3:1 against both the component and the surrounding background; a 2px ring plus 2px offset in the accent color usually works.
- P3 wide-gamut OKLCH values look different on sRGB screens; gamut-map with the script and treat the clipped hex as the real color for contrast checks.
- Charts need their own categorical palette with similar lightness so no series
dominates; see the dataviz guidance in
dashboard-ui.
Verification
- Every pair in the semantic table passes the
contrastcommand in both themes. - Grayscale screenshot test: the hierarchy (primary action, headings, muted text) still reads with color removed (DevTools rendering emulation: achromatopsia).
- Deuteranopia/protanopia emulation: errors and success remain distinguishable via icon or text.
Output contract
## Palette
Accent: oklch(0.54 0.154 255) — chosen because …
Scales: blue, neutral (tinted 255), red, amber, green, sky (50–950, CSS attached)
## Semantic roles
| Role | Light | Dark | Contrast (light / dark) |
|---|---|---|---|
## Rules
- One accent; status colors only for status
- Muted text never below neutral-600 on light surfaces Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/color_tool.py
#!/usr/bin/env python3
"""Color utilities for building contrast-safe palettes. Standard library only.
Commands:
contrast FG BG [FG BG ...] [--min 4.5] WCAG 2.x contrast ratio for each pair
convert COLOR [COLOR ...] hex <-> oklch conversion
scale HUE [--chroma 0.16] 11-step OKLCH scale (50..950), gamut-mapped
Colors: #rgb, #rrggbb, or oklch(L C H) with L as 0-1 or a percentage.
Exit code 1 if any contrast pair falls below --min.
"""
import argparse
import math
import re
import sys
STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
LIGHTNESS = [0.975, 0.94, 0.88, 0.80, 0.71, 0.62, 0.54, 0.46, 0.38, 0.30, 0.24]
CHROMA_MULT = [0.12, 0.28, 0.52, 0.78, 0.94, 1.0, 0.96, 0.86, 0.72, 0.58, 0.46]
def parse(color):
c = color.strip().lower()
m = re.fullmatch(r"#([0-9a-f]{3}|[0-9a-f]{6})", c)
if m:
h = m.group(1)
if len(h) == 3:
h = "".join(ch * 2 for ch in h)
return tuple(int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
m = re.fullmatch(r"oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?\s*(?:/\s*[\d.%]+)?\s*\)", c)
if m:
L = float(m.group(1)) / (100 if m.group(2) else 1)
r, g, b = oklch_to_srgb(L, float(m.group(3)), float(m.group(4)))
return clamp(r), clamp(g), clamp(b)
raise ValueError(f"unsupported color: {color!r} (use #rrggbb or oklch(L C H))")
def clamp(x):
return min(1.0, max(0.0, x))
def to_linear(x):
return x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4
def from_linear(x):
return 12.92 * x if x <= 0.0031308 else 1.055 * (x ** (1 / 2.4)) - 0.055
def oklch_to_srgb(L, C, H):
a, b = C * math.cos(math.radians(H)), C * math.sin(math.radians(H))
l_ = L + 0.3963377774 * a + 0.2158037573 * b
m_ = L - 0.1055613458 * a - 0.0638541728 * b
s_ = L - 0.0894841775 * a - 1.2914855480 * b
l, m, s = l_ ** 3, m_ ** 3, s_ ** 3
r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
return tuple(from_linear(v) if v >= 0 else -from_linear(-v) for v in (r, g, bl))
def srgb_to_oklch(rgb):
r, g, b = (to_linear(v) for v in rgb)
l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
l_, m_, s_ = (math.copysign(abs(v) ** (1 / 3), v) for v in (l, m, s))
L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
C = math.hypot(A, B)
H = math.degrees(math.atan2(B, A)) % 360
return L, C, H
def in_gamut(rgb, eps=1e-4):
return all(-eps <= v <= 1 + eps for v in rgb)
def to_hex(rgb):
return "#" + "".join(f"{round(clamp(v) * 255):02x}" for v in rgb)
def luminance(rgb):
r, g, b = (to_linear(v) for v in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def ratio(fg, bg):
a, b = luminance(fg), luminance(bg)
hi, lo = max(a, b), min(a, b)
return (hi + 0.05) / (lo + 0.05)
def verdict(r):
tags = []
tags.append("AAA" if r >= 7 else "AA" if r >= 4.5 else "AA-large/UI" if r >= 3 else "FAIL")
return tags[0]
def cmd_contrast(args):
vals = args.colors
if len(vals) % 2:
sys.exit("contrast needs FG BG pairs")
failed = 0
print(f"{'foreground':<26}{'background':<26}{'ratio':>7} level")
for fg, bg in zip(vals[0::2], vals[1::2]):
r = ratio(parse(fg), parse(bg))
flag = "" if r >= args.min else f" < {args.min}"
failed += r < args.min
print(f"{fg:<26}{bg:<26}{r:>7.2f} {verdict(r)}{flag}")
print(f"\n{failed} pair(s) below {args.min}:1" if failed else f"\nall pairs >= {args.min}:1")
return 1 if failed else 0
def cmd_convert(args):
for c in args.colors:
rgb = parse(c)
L, C, H = srgb_to_oklch(rgb)
note = ""
m = re.match(r"\s*oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)", c.lower())
if m:
raw = oklch_to_srgb(float(m.group(1)) / (100 if m.group(2) else 1), float(m.group(3)), float(m.group(4)))
if not in_gamut(raw):
note = " (outside sRGB: hex is clipped)"
print(f"{c:<28} {to_hex(rgb)} oklch({L:.3f} {C:.3f} {H:.1f}){note}")
return 0
def cmd_scale(args):
white, black = (1.0, 1.0, 1.0), (0.0, 0.0, 0.0)
print(f"{'step':<6}{'oklch':<26}{'hex':<10}{'vs white':>9}{'vs black':>9}")
css = []
for step, L, k in zip(STEPS, LIGHTNESS, CHROMA_MULT):
C = args.chroma * k
rgb = oklch_to_srgb(L, C, args.hue)
while not in_gamut(rgb) and C > 0:
C = max(0.0, C - 0.002)
rgb = oklch_to_srgb(L, C, args.hue)
rgb = tuple(clamp(v) for v in rgb)
ok = f"oklch({L:.3f} {C:.3f} {args.hue:g})"
print(f"{step:<6}{ok:<26}{to_hex(rgb):<10}{ratio(rgb, white):>9.2f}{ratio(rgb, black):>9.2f}")
css.append(f" --{args.name}-{step}: {ok};")
print("\n:root {\n" + "\n".join(css) + "\n}")
return 0
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("contrast")
c.add_argument("colors", nargs="+")
c.add_argument("--min", type=float, default=4.5)
v = sub.add_parser("convert")
v.add_argument("colors", nargs="+")
s = sub.add_parser("scale")
s.add_argument("hue", type=float)
s.add_argument("--chroma", type=float, default=0.16)
s.add_argument("--name", default="hue")
args = p.parse_args()
try:
return {"contrast": cmd_contrast, "convert": cmd_convert, "scale": cmd_scale}[args.cmd](args)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.