Accessibility skill 1 of 12

Colour Contrast SKILL.md: Fix WCAG 2.2 Contrast Failures in CSS

The Colour Contrast skill makes Claude, Cursor or another AI agent find text, borders and focus rings that fall below WCAG 2.2 contrast ratios, then fix them at the token level. A read-only checker and a browser probe measure every pair, so each ratio is computed, shown rounded down and never judged by eye.

colour-contrast/SKILL.md, version 1.0.0, 3,323 words, plus scripts/cc_check.py and scripts/probe.js.

An agent works on contrast in this order: run a read-only checker over the stylesheets, resolve what a static check cannot know (computed colours, gradients, inherited backgrounds) with a browser probe, choose a fix at the token level, apply it in approved batches, and re-run both tools. Every ratio is computed, never estimated by eye, and never rounded up.

It works on plain CSS, bundler output and <style> blocks. Tool notes and measurements are in references/appendix.md. Token-level recipes are in references/patterns.md.

When to use this skill, and when not to

Use it for text that is hard to read, a Lighthouse or axe color-contrast failure, a brand palette that must pass AA, form borders and focus rings that vanish, placeholder text, opacity or rgba text, dark mode, or text over an image.

RequestUse instead
Focus order, traps, skip links, focus not obscuredkeyboard-focus.md (this skill only checks the contrast of the focus indicator)
Roles, names, aria-*, aria-hiddenaria-names-roles.md
Labels, error text, form markuphtml-forms.md
Fluid font sizes, zoomfluid-type-spacing.md
Colour as the only cue (WCAG 1.4.1), colour-blindness simulationOut of scope. Report it if seen
APCA or WCAG 3 scoringOut of scope. WCAG 2.2 conformance uses the ratio below

Guardrails

  1. Propose, then change. Report and plan first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
  2. Brand colours are not yours to change. If the failing colour is a brand token, show two options (change the text colour, or change the background) with computed ratios and wait for a decision.
  3. Never edit vendor files. Do not change node_modules, vendor folders or CDN copies. Override from your own CSS.
  4. Data handled. The checker reads CSS files and prints selectors, colours and ratios. The probe reads computed styles and prints selectors, colours and ratios, never page text, form values, cookies or storage. Stylesheets can hold API keys or tokens in url(...) values and comments: never paste them into reports or prompts. Run the probe only on pages the user owns.
  5. Nothing is invented. Every threshold carries a source and date in this file. Anything version-dependent carries verify before relying on this. A colour pair not measured is reported as unmeasured.
  6. Not a conformance statement. A clean run means the checked pairs pass. It is not a WCAG conformance claim and not legal advice. Any claim of conformance, or any legal or procurement use, needs a human accessibility reviewer.
  7. Refuses to: round a ratio up, treat a Lighthouse score of 100 as conformance, change a brand token without approval, scan sites the user does not own, or report a pair as passing from a screenshot.

Inputs the agent must have before starting

Ask for every missing item. Do not assume.

InputAsk exactly
Stylesheets and HTML"List every stylesheet and the HTML that loads it, in load order."
Backgrounds"What is the page background colour in light mode and in dark mode?"
Target"Is AA enough, or does a contract or policy require AAA (7:1 text)?"
Themes"Which themes exist: light, dark, high contrast, a brand switcher?"
Locked colours"Which colour tokens are brand-locked and cannot change?"
Browser access"Can you run a short snippet in DevTools on a page you own, or give me a URL I may open?"
Approval"May I edit on a branch in batches, showing each diff first?"

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as colour-contrast/SKILL.md.

---
name: colour-contrast
description: "Audits and fixes colour contrast in CSS against WCAG 2.2: text under 4.5:1 (3:1 large), UI borders and focus rings under 3:1, alpha or opacity text, placeholders, dark-mode tokens and text on images. Runs a read-only checker and a browser probe, then proposes the nearest passing colour. Use for low contrast, WCAG contrast, Lighthouse or axe colour-contrast failures and brand colour accessibility. Do NOT use for focus order, ARIA, alt text, colour-blindness simulation or APCA."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/accessibility/colour-contrast/
  category: frontend-development
  subcategory: accessibility
  version: "1.0.0"
  created: "2026-10-29"
  updated: "2026-10-29"
---

# Colour Contrast: Find and Fix WCAG 2.2 Contrast Failures in CSS

An agent works on contrast in this order: run a read-only checker over the stylesheets, resolve what a static check cannot know (computed colours, gradients, inherited backgrounds) with a browser probe, choose a fix at the token level, apply it in approved batches, and re-run both tools. Every ratio is computed, never estimated by eye, and never rounded up.

It works on plain CSS, bundler output and `<style>` blocks. Tool notes and measurements are in `references/appendix.md`. Token-level recipes are in `references/patterns.md`.

## When to use this skill, and when not to

Use it for text that is hard to read, a Lighthouse or axe `color-contrast` failure, a brand palette that must pass AA, form borders and focus rings that vanish, placeholder text, `opacity` or `rgba` text, dark mode, or text over an image.

| Request | Use instead |
|---|---|
| Focus order, traps, skip links, focus not obscured | `keyboard-focus.md` (this skill only checks the contrast of the focus indicator) |
| Roles, names, `aria-*`, `aria-hidden` | `aria-names-roles.md` |
| Labels, error text, form markup | `html-forms.md` |
| Fluid font sizes, zoom | `fluid-type-spacing.md` |
| Colour as the only cue (WCAG 1.4.1), colour-blindness simulation | Out of scope. Report it if seen |
| APCA or WCAG 3 scoring | Out of scope. WCAG 2.2 conformance uses the ratio below |

## Guardrails

1. **Propose, then change.** Report and plan first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Brand colours are not yours to change.** If the failing colour is a brand token, show two options (change the text colour, or change the background) with computed ratios and wait for a decision.
3. **Never edit vendor files.** Do not change `node_modules`, vendor folders or CDN copies. Override from your own CSS.
4. **Data handled.** The checker reads CSS files and prints selectors, colours and ratios. The probe reads computed styles and prints selectors, colours and ratios, never page text, form values, cookies or storage. Stylesheets can hold API keys or tokens in `url(...)` values and comments: never paste them into reports or prompts. Run the probe only on pages the user owns.
5. **Nothing is invented.** Every threshold carries a source and date in this file. Anything version-dependent carries **verify before relying on this**. A colour pair not measured is reported as unmeasured.
6. **Not a conformance statement.** A clean run means the checked pairs pass. It is not a WCAG conformance claim and not legal advice. Any claim of conformance, or any legal or procurement use, needs a human accessibility reviewer.
7. **Refuses to:** round a ratio up, treat a Lighthouse score of 100 as conformance, change a brand token without approval, scan sites the user does not own, or report a pair as passing from a screenshot.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Stylesheets and HTML | "List every stylesheet and the HTML that loads it, in load order." |
| Backgrounds | "What is the page background colour in light mode and in dark mode?" |
| Target | "Is AA enough, or does a contract or policy require AAA (7:1 text)?" |
| Themes | "Which themes exist: light, dark, high contrast, a brand switcher?" |
| Locked colours | "Which colour tokens are brand-locked and cannot change?" |
| Browser access | "Can you run a short snippet in DevTools on a page you own, or give me a URL I may open?" |
| Approval | "May I edit on a branch in batches, showing each diff first?" |

## Workflow

### Step 0: Confirm scope

List files, themes and locked tokens. Minified input is fine for the checker, but edits go in source files.

### Step 1: Run the checker

```
python3 scripts/cc_check.py a.css b.css --page-bg '#fff' --dark-bg '#121212' | head -40
```

Read only its output. Do not read the script. The first line gives counts, then errors, warnings and info, so `head -40` never hides the totals. `--aaa` switches to 7:1 and 4.5:1. `--strict` exits 1 on warnings. Exit 0 means no errors.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `text-low` | error | A text and background pair in one rule is under its threshold. The line shows the nearest passing colour | Step 4 |
| `text-low-assumed-bg` | warn | Only `color` is set, so the page background was assumed | Step 3 to confirm, then step 4 |
| `placeholder-low` | error | `::placeholder` text under 4.5:1 | Step 4 |
| `ui-low` | warn | Outline or focus colour under 3:1 against its background (SC 1.4.11) | Step 6 |
| `ui-border-low` | info | Control border under 3:1. Counts only if the border is the sole cue that this is a control | Step 6 |
| `alpha-text` | info | Alpha or opacity in use. The blended ratio passes today | Re-test if the background changes |
| `image-bg` | info | Gradient or image background. The text check was skipped | Step 5 |
| `unresolved` | info | `oklch()`, `color-mix()`, `currentcolor` or an unknown variable | Step 3 |

The checker does not follow the cascade or inheritance. It compares `color` with `background-color` (or `background`) declared in the same rule, resolves `var()` and `:root` variables, blends alpha and `opacity`, and skips `:disabled`, `[disabled]` and `[aria-disabled]` selectors. Dark values come from `@media (prefers-color-scheme: dark)` blocks. Large text is `font-size` of 24px or more, or 18.66px or more at weight 600 or above.

### Step 2: Know the thresholds

| What | Minimum | Source |
|---|---|---|
| Normal text (SC 1.4.3, AA) | 4.5:1 | WebAIM, W3C |
| Large text: 18pt (24px), or 14pt bold (18.66px) | 3:1 | WebAIM |
| UI component boundaries and states, meaningful graphics, focus indicator (SC 1.4.11, AA) | 3:1 against adjacent colours | W3C Understanding 1.4.11 |
| AAA text (SC 1.4.6) | 7:1, or 4.5:1 large | WebAIM |
| Rounding | None. 4.4999 fails. 2.999 fails | W3C, WebAIM |

Exempt: disabled controls, logos and wordmarks, purely decorative graphics, text that duplicates adjacent visible text, and user-agent default control styling the author has not changed. Hover states need not reach 3:1 but must not lower the contrast of the control. Formula: `(L1 + 0.05) / (L2 + 0.05)`, with `L = 0.2126 R + 0.7152 G + 0.0722 B` on linearised sRGB channels (cutoff 0.04045). The scripts implement it. Do not re-derive it by hand.

### Step 3: Resolve what the checker could not

Needed when the report holds `unresolved`, `text-low-assumed-bg` or `image-bg`, or when colours come from a framework at runtime.

1. Ask the user to run `scripts/probe.js` in DevTools on a page they own and paste back the summary line and the table. Do not ask for a screenshot or the page text.
2. The probe turns every computed colour (including `oklch()` and `color-mix()`) into sRGB through a canvas, blends parent backgrounds and `opacity`, and lists failing elements plus any element with a background image or gradient.
3. A gap of 0.01 or less between probe and checker is rounding, because the canvas stores alpha in 8 bits. A larger gap means the cascade differs from the static reading: use the probe.
4. No browser access: report the pair as unmeasured. Do not guess.

### Step 4: Choose the fix

| Situation | Rule |
|---|---|
| The failing colour is a custom property used in 3 or more rules | Change the token once, at its definition |
| Free choice between text and background | Darken or lighten the text. Keep the surface colour, so layouts and brand blocks do not shift |
| A brand-locked text colour | Offer the nearest passing shade for text on that surface only (large headings may pass at 3:1), and wait |
| Text on a coloured button or badge | Change the text colour first. If the brand fill is locked and neither black nor white passes, change the fill |
| Checker suggests a colour | Re-measure it with `python3 scripts/cc_check.py --pair FG BG`. The suggestion keeps hue and saturation and moves lightness until the ratio passes |
| Several passing shades exist | Pick the smallest visible change, then check it in dark mode (step 7) |

Single pairs: `python3 scripts/cc_check.py --pair '#777' '#fff' --suggest` prints the ratio rounded down, the exact ratio, PASS or FAIL and the nearest passing colour. Add `--large` for 3:1, `--ui` for 3:1, `--aaa` for AAA.

### Step 5: Alpha, opacity, gradients and images

| Case | Rule |
|---|---|
| `rgba()`, `hsla()`, 8-digit hex or `opacity` on text | Measure the blended colour. The checker does this. A semi-transparent text colour changes ratio whenever the background changes, so prefer a solid colour |
| `opacity` on a parent | It lowers every child. Remove it from containers that hold text |
| Text over a gradient | Test the lowest-contrast spot, not the average. Use the probe flag, then sample the lightest stop behind the text |
| Text over a photo | Add a solid or gradient scrim behind the text, then re-measure the lightest area under the text. Do not assume the scrim passes |
| Placeholder text | Needs 4.5:1 like other text. Never use it as the only label (`html-forms.md`) |

### Step 6: Non-text contrast and states

Check each of these, in every theme: default text, hover, focus, active, visited links, selected, checked, error and success text, placeholder, input border (only when it is the sole cue), focus indicator, icon-only buttons, chart lines and legend marks, and form validation icons.

| Rule | Reason |
|---|---|
| Focus ring: 3:1 against both the element and the page behind it | The ring sits between two colours. The W3C notes thin shapes render fainter because of anti-aliasing, so allow a margin |
| Two-colour ring (dark line, light halo) when backgrounds vary | One colour cannot pass on both light and dark surfaces. Recipes: `references/patterns.md` |
| Links in body text without underline | Need 3:1 against the surrounding text and a non-colour cue on hover and focus (WebAIM) |
| Hover colours need no 3:1 | W3C Understanding 1.4.11. They must not reduce contrast of the other states |
| Disabled controls | Exempt. Do not "fix" them, and do not hide required information in them |

### Step 7: Themes

Run the checker with `--dark-bg` set to the dark surface, and once per extra theme file. A token redefined under `prefers-color-scheme: dark` must be re-measured: a shade that passes on white often fails on `#121212`. For a manual theme switch (a `[data-theme]` attribute), pass that theme's CSS and background in a separate run, because the checker only reads media queries.

### Step 8: Verify

1. Re-run the checker. Errors must be 0. Each warning and info line is either fixed or listed with a reason.
2. Re-run the probe on the changed page, in each theme. Failing count must be 0 and every manual line (images, gradients) must carry a measured number.
3. Spot-check the three largest changes with `--pair`.
4. Run the existing test suite and any visual regression tests.
5. Tell the user what automation cannot see: axe reports gradients, pseudo-element backgrounds and overlapping elements as "incomplete" (Deque), so those need a human check.

### Step 9: Report

Use the report template. A short list of file, line, issue and fix. Do not echo unchanged CSS back.

## Worked example

An invented stylesheet of 15 lines. The agent runs the checker with the defaults (white page, dark `#121212`).

```
5 errors, 2 warnings, 3 info
page.css:4  error  text-low  .hint: #777777 on #ffffff = 4.47, need 4.5. Nearest passing text colour #747474
page.css:7  error  text-low  .badge: #8c8c8c on #ffffff = 3.35, need 4.5 (alpha/opacity blended). Nearest passing text colour #757575
page.css:8  error  text-low  .hero h2: #999999 on #ffffff = 2.84, need 3.0 (large text). Nearest passing text colour #949494
page.css:9  error  text-low  .sub: #919191 on #ffffff = 3.13, need 4.5 (alpha/opacity blended). Nearest passing text colour #757575
page.css:15  error  text-low  .hint: #666666 on #121212 = 3.26, need 4.5. Nearest passing text colour #7d7d7d
page.css:6  warn  ui-low  .btn:focus-visible: outline #9ec5fe on #ffffff = 1.76, need 3.0 (focus or UI indicator, SC 1.4.11)
page.css:11  warn  text-low-assumed-bg  input::placeholder: #a0a0a0 on #ffffff = 2.61, need 4.5. Nearest passing text colour #757575
```

The three info lines cover a gradient banner, a 1.60 input border and an `oklch()` text colour. The user confirms the muted grey is a token (`--muted`) and not brand-locked, and that the input has a visible label, so the border needs no change. The agent runs `scripts/probe.js` in Chromium 153: it reports the same four light-mode failures with ratios within 0.01 of the checker, passes the `oklch()` text, and flags the banner for a manual check.

One batch: `--muted` becomes `#747474` in light mode and `#7d7d7d` in dark mode, one edit that fixes both `.hint` lines. `.badge` and `.sub` stop using alpha and `opacity` and take `#595959` and `#767676`. `.hero h2` takes `#949494`. The focus outline becomes `#0b57d0` (6.38:1 on white, see below). The placeholder takes `#757575`. The re-run prints `0 errors, 0 warnings, 3 info`.

`python3 scripts/cc_check.py --pair '#0b57d0' '#ffffff' --ui` prints `ratio 6.38:1 (shown rounded down; exact 6.3862) need 3.0 -> PASS`. The banner needs the lightest gradient stop measured by hand, so the agent reports it as "unmeasured" and asks for the stop colours.

## Templates

### Report

```
Colour contrast report: <page or repo>, <date>
Target: AA | AAA. Themes: <list>. Locked tokens: <list>.
Checker before: N errors, N warnings. After: N errors, N warnings.
Probe: <ran on URL | not run>. Failing before: N. After: N.
Changes: <file:line  old -> new  ratio before -> after>   (one line each)
Left as is: <item  reason (exempt, locked, awaiting approval)>
Unmeasured: <gradients, images, states not tested>
Not a WCAG conformance claim. Human accessibility review needed before any conformance statement.
```

### Starting prompt

```
Use the colour-contrast skill. Stylesheets: <list>. Page background light: <hex>, dark: <hex>.
Target: <AA|AAA>. Locked brand tokens: <list>. I can run a DevTools snippet: <yes|no>.
Edit on a branch in batches of one token or 20 declarations and show each diff first.
```

## Failure modes and anti-patterns

| Mistake | How to spot it | Do instead |
|---|---|---|
| Rounding up: "4.48 is basically 4.5" | A report shows 4.5 for a failing pair. WebAIM's own example: `#777777` is 4.47 and fails | Compare the exact value. The scripts print rounded down and exact |
| Measuring the solid colour of semi-transparent text | `rgba()`, 8-digit hex or `opacity` in the rule, ratio looks fine | Blend over the real background (step 5) |
| Testing only the default state | Hover, focus, visited, error and placeholder never appear in the report | Walk the state list in step 6 |
| Trusting automation on images and gradients | axe marks the node incomplete, or the checker prints `image-bg` | Measure the lightest spot behind the text by hand |
| Fixing light mode only | The same token fails on the dark surface | Re-run with `--dark-bg` (step 7) |

## Quality bar

Run every item before handing over.

1. Checker errors are 0, and every warning and info line is fixed or listed with a reason.
2. Every changed colour has its before and after ratio in the report, taken from a script run.
3. No ratio in the report is rounded up.
4. Every text pair with alpha or `opacity` was measured after blending.
5. Light and dark themes were each run, or the report says which was not.
6. Gradient and image cases carry a measured number or the word "unmeasured".
7. No brand token changed without a recorded approval.
8. The report states that it is not a WCAG conformance claim.

## Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the visible text of pages I read.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 101 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 4,717 | Estimate | When the skill fires |
| C. `references/appendix.md` | 700 | Estimate | Only to check a claim or pick a tool |
| C. `references/patterns.md` | 486 | Estimate | Only when rewriting focus rings, tokens or scrims |
| Script output (typical run) | 681 | Estimate | Checker output measured on the worked example, plus the stated extra |
| D. Typical run (A + B + script output) | 5,499 | Estimate | Common path, no references |
| D. Worst case (A + B + all references + 40 lines of output + extra) | 8,212 | Estimate | Large input, every branch |
| E. Cheaper path | 5,199 | Estimate | Run the checker, fix flagged lines, skip the probe and the references |

Rival sizes, estimated from the visible text: thedaviddias color-contrast about 340 words (roughly 460 tokens), dylantarre color-contrast about 600 to 700 words (roughly 880 tokens), jkense wcag-audit-perceivable-color about 13 words visible. This skill costs more to load than all three. What it adds is a checker and a probe that measure the pairs instead of the agent reasoning about them, an unrounded ratio, alpha blending, dark-mode runs and a fix suggestion that is re-measured. On a 448,889-character stylesheet the checker took 1.42 seconds and the first 40 lines of output cost about 1,208 tokens. On a stylesheet under about 100 lines, read the file and skip the checker.

## Handoffs

`keyboard-focus.md` covers focus order, visible focus behaviour and focus not obscured. `aria-names-roles.md` covers accessible names. `html-forms.md` covers labels and error messages. `fluid-type-spacing.md` covers text size and zoom.

## Appendix

Tool facts, measured values and source notes are in `references/appendix.md`. Token recipes, focus-ring CSS and scrim patterns are in `references/patterns.md`. Read them only when a step points there.

## Sources

Last verified 29 October 2026 for every source.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- W3C, Understanding SC 1.4.11 Non-text Contrast: https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html
- WebAIM, Contrast and Color Accessibility: https://webaim.org/articles/contrast/
- WebAIM, The WebAIM Million 2026 (testing February 2026, page updated 30 March 2026): https://webaim.org/projects/million/
- Deque University, axe rule `color-contrast` (4.8): https://dequeuniversity.com/rules/axe/4.8/color-contrast
- Deque, axe-core rule descriptions: https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md
- Sara Soueidan, A guide to designing accessible, WCAG-conformant focus indicators: https://sarasoueidan.com/blog/focus-indicators/
- thedaviddias, color-contrast. Rival implementation: https://www.skills.sh/thedaviddias/front-end-checklist/color-contrast
- dylantarre, color-contrast. Rival implementation: https://skillselion.com/skills/dylantarre/design-system-skills/color-contrast
- jkense, wcag-audit-perceivable-color. Rival implementation: https://skills.cat/skills/jkense/agent-skills-wcag/wcag-audit-perceivable-color

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/accessibility/colour-contrast/

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.

Workflow

Step 0: Confirm scope

List files, themes and locked tokens. Minified input is fine for the checker, but edits go in source files.

Step 1: Run the checker

python3 scripts/cc_check.py a.css b.css --page-bg '#fff' --dark-bg '#121212' | head -40

Read only its output. Do not read the script. The first line gives counts, then errors, warnings and info, so head -40 never hides the totals. --aaa switches to 7:1 and 4.5:1. --strict exits 1 on warnings. Exit 0 means no errors.

CodeLevelMeaningFix
text-lowerrorA text and background pair in one rule is under its threshold. The line shows the nearest passing colourStep 4
text-low-assumed-bgwarnOnly color is set, so the page background was assumedStep 3 to confirm, then step 4
placeholder-lowerror::placeholder text under 4.5:1Step 4
ui-lowwarnOutline or focus colour under 3:1 against its background (SC 1.4.11)Step 6
ui-border-lowinfoControl border under 3:1. Counts only if the border is the sole cue that this is a controlStep 6
alpha-textinfoAlpha or opacity in use. The blended ratio passes todayRe-test if the background changes
image-bginfoGradient or image background. The text check was skippedStep 5
unresolvedinfooklch(), color-mix(), currentcolor or an unknown variableStep 3

The checker does not follow the cascade or inheritance. It compares color with background-color (or background) declared in the same rule, resolves var() and :root variables, blends alpha and opacity, and skips :disabled, [disabled] and [aria-disabled] selectors. Dark values come from @media (prefers-color-scheme: dark) blocks. Large text is font-size of 24px or more, or 18.66px or more at weight 600 or above.

Step 2: Know the thresholds

WhatMinimumSource
Normal text (SC 1.4.3, AA)4.5:1WebAIM, W3C
Large text: 18pt (24px), or 14pt bold (18.66px)3:1WebAIM
UI component boundaries and states, meaningful graphics, focus indicator (SC 1.4.11, AA)3:1 against adjacent coloursW3C Understanding 1.4.11
AAA text (SC 1.4.6)7:1, or 4.5:1 largeWebAIM
RoundingNone. 4.4999 fails. 2.999 failsW3C, WebAIM

Exempt: disabled controls, logos and wordmarks, purely decorative graphics, text that duplicates adjacent visible text, and user-agent default control styling the author has not changed. Hover states need not reach 3:1 but must not lower the contrast of the control. Formula: (L1 + 0.05) / (L2 + 0.05), with L = 0.2126 R + 0.7152 G + 0.0722 B on linearised sRGB channels (cutoff 0.04045). The scripts implement it. Do not re-derive it by hand.

Step 3: Resolve what the checker could not

Needed when the report holds unresolved, text-low-assumed-bg or image-bg, or when colours come from a framework at runtime.

  1. Ask the user to run scripts/probe.js in DevTools on a page they own and paste back the summary line and the table. Do not ask for a screenshot or the page text.
  2. The probe turns every computed colour (including oklch() and color-mix()) into sRGB through a canvas, blends parent backgrounds and opacity, and lists failing elements plus any element with a background image or gradient.
  3. A gap of 0.01 or less between probe and checker is rounding, because the canvas stores alpha in 8 bits. A larger gap means the cascade differs from the static reading: use the probe.
  4. No browser access: report the pair as unmeasured. Do not guess.

Step 4: Choose the fix

SituationRule
The failing colour is a custom property used in 3 or more rulesChange the token once, at its definition
Free choice between text and backgroundDarken or lighten the text. Keep the surface colour, so layouts and brand blocks do not shift
A brand-locked text colourOffer the nearest passing shade for text on that surface only (large headings may pass at 3:1), and wait
Text on a coloured button or badgeChange the text colour first. If the brand fill is locked and neither black nor white passes, change the fill
Checker suggests a colourRe-measure it with python3 scripts/cc_check.py --pair FG BG. The suggestion keeps hue and saturation and moves lightness until the ratio passes
Several passing shades existPick the smallest visible change, then check it in dark mode (step 7)

Single pairs: python3 scripts/cc_check.py --pair '#777' '#fff' --suggest prints the ratio rounded down, the exact ratio, PASS or FAIL and the nearest passing colour. Add --large for 3:1, --ui for 3:1, --aaa for AAA.

Step 5: Alpha, opacity, gradients and images

CaseRule
rgba(), hsla(), 8-digit hex or opacity on textMeasure the blended colour. The checker does this. A semi-transparent text colour changes ratio whenever the background changes, so prefer a solid colour
opacity on a parentIt lowers every child. Remove it from containers that hold text
Text over a gradientTest the lowest-contrast spot, not the average. Use the probe flag, then sample the lightest stop behind the text
Text over a photoAdd a solid or gradient scrim behind the text, then re-measure the lightest area under the text. Do not assume the scrim passes
Placeholder textNeeds 4.5:1 like other text. Never use it as the only label (html-forms.md)

Step 6: Non-text contrast and states

Check each of these, in every theme: default text, hover, focus, active, visited links, selected, checked, error and success text, placeholder, input border (only when it is the sole cue), focus indicator, icon-only buttons, chart lines and legend marks, and form validation icons.

RuleReason
Focus ring: 3:1 against both the element and the page behind itThe ring sits between two colours. The W3C notes thin shapes render fainter because of anti-aliasing, so allow a margin
Two-colour ring (dark line, light halo) when backgrounds varyOne colour cannot pass on both light and dark surfaces. Recipes: references/patterns.md
Links in body text without underlineNeed 3:1 against the surrounding text and a non-colour cue on hover and focus (WebAIM)
Hover colours need no 3:1W3C Understanding 1.4.11. They must not reduce contrast of the other states
Disabled controlsExempt. Do not "fix" them, and do not hide required information in them

Step 7: Themes

Run the checker with --dark-bg set to the dark surface, and once per extra theme file. A token redefined under prefers-color-scheme: dark must be re-measured: a shade that passes on white often fails on #121212. For a manual theme switch (a [data-theme] attribute), pass that theme's CSS and background in a separate run, because the checker only reads media queries.

Step 8: Verify

  1. Re-run the checker. Errors must be 0. Each warning and info line is either fixed or listed with a reason.
  2. Re-run the probe on the changed page, in each theme. Failing count must be 0 and every manual line (images, gradients) must carry a measured number.
  3. Spot-check the three largest changes with --pair.
  4. Run the existing test suite and any visual regression tests.
  5. Tell the user what automation cannot see: axe reports gradients, pseudo-element backgrounds and overlapping elements as "incomplete" (Deque), so those need a human check.

Step 9: Report

Use the report template. A short list of file, line, issue and fix. Do not echo unchanged CSS back.

Worked example

An invented stylesheet of 15 lines. The agent runs the checker with the defaults (white page, dark #121212).

5 errors, 2 warnings, 3 info
page.css:4  error  text-low  .hint: #777777 on #ffffff = 4.47, need 4.5. Nearest passing text colour #747474
page.css:7  error  text-low  .badge: #8c8c8c on #ffffff = 3.35, need 4.5 (alpha/opacity blended). Nearest passing text colour #757575
page.css:8  error  text-low  .hero h2: #999999 on #ffffff = 2.84, need 3.0 (large text). Nearest passing text colour #949494
page.css:9  error  text-low  .sub: #919191 on #ffffff = 3.13, need 4.5 (alpha/opacity blended). Nearest passing text colour #757575
page.css:15  error  text-low  .hint: #666666 on #121212 = 3.26, need 4.5. Nearest passing text colour #7d7d7d
page.css:6  warn  ui-low  .btn:focus-visible: outline #9ec5fe on #ffffff = 1.76, need 3.0 (focus or UI indicator, SC 1.4.11)
page.css:11  warn  text-low-assumed-bg  input::placeholder: #a0a0a0 on #ffffff = 2.61, need 4.5. Nearest passing text colour #757575

The three info lines cover a gradient banner, a 1.60 input border and an oklch() text colour. The user confirms the muted grey is a token (--muted) and not brand-locked, and that the input has a visible label, so the border needs no change. The agent runs scripts/probe.js in Chromium 153: it reports the same four light-mode failures with ratios within 0.01 of the checker, passes the oklch() text, and flags the banner for a manual check.

One batch: --muted becomes #747474 in light mode and #7d7d7d in dark mode, one edit that fixes both .hint lines. .badge and .sub stop using alpha and opacity and take #595959 and #767676. .hero h2 takes #949494. The focus outline becomes #0b57d0 (6.38:1 on white, see below). The placeholder takes #757575. The re-run prints 0 errors, 0 warnings, 3 info.

python3 scripts/cc_check.py --pair '#0b57d0' '#ffffff' --ui prints ratio 6.38:1 (shown rounded down; exact 6.3862) need 3.0 -> PASS. The banner needs the lightest gradient stop measured by hand, so the agent reports it as "unmeasured" and asks for the stop colours.

Templates

Report

Colour contrast report: <page or repo>, <date>
Target: AA | AAA. Themes: <list>. Locked tokens: <list>.
Checker before: N errors, N warnings. After: N errors, N warnings.
Probe: <ran on URL | not run>. Failing before: N. After: N.
Changes: <file:line  old -> new  ratio before -> after>   (one line each)
Left as is: <item  reason (exempt, locked, awaiting approval)>
Unmeasured: <gradients, images, states not tested>
Not a WCAG conformance claim. Human accessibility review needed before any conformance statement.

Starting prompt

Use the colour-contrast skill. Stylesheets: <list>. Page background light: <hex>, dark: <hex>.
Target: <AA|AAA>. Locked brand tokens: <list>. I can run a DevTools snippet: <yes|no>.
Edit on a branch in batches of one token or 20 declarations and show each diff first.

Failure modes and anti-patterns

MistakeHow to spot itDo instead
Rounding up: "4.48 is basically 4.5"A report shows 4.5 for a failing pair. WebAIM's own example: #777777 is 4.47 and failsCompare the exact value. The scripts print rounded down and exact
Measuring the solid colour of semi-transparent textrgba(), 8-digit hex or opacity in the rule, ratio looks fineBlend over the real background (step 5)
Testing only the default stateHover, focus, visited, error and placeholder never appear in the reportWalk the state list in step 6
Trusting automation on images and gradientsaxe marks the node incomplete, or the checker prints image-bgMeasure the lightest spot behind the text by hand
Fixing light mode onlyThe same token fails on the dark surfaceRe-run with --dark-bg (step 7)

Quality bar

Run every item before handing over.

  1. Checker errors are 0, and every warning and info line is fixed or listed with a reason.
  2. Every changed colour has its before and after ratio in the report, taken from a script run.
  3. No ratio in the report is rounded up.
  4. Every text pair with alpha or opacity was measured after blending.
  5. Light and dark themes were each run, or the report says which was not.
  6. Gradient and image cases carry a measured number or the word "unmeasured".
  7. No brand token changed without a recorded approval.
  8. The report states that it is not a WCAG conformance claim.

Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the visible text of pages I read.

ItemTokensStatusRead condition
A. Description (always loaded)101EstimateEvery session with the skill installed
B. SKILL.md body4,717EstimateWhen the skill fires
C. references/appendix.md700EstimateOnly to check a claim or pick a tool
C. references/patterns.md486EstimateOnly when rewriting focus rings, tokens or scrims
Script output (typical run)681EstimateChecker output measured on the worked example, plus the stated extra
D. Typical run (A + B + script output)5,499EstimateCommon path, no references
D. Worst case (A + B + all references + 40 lines of output + extra)8,212EstimateLarge input, every branch
E. Cheaper path5,199EstimateRun the checker, fix flagged lines, skip the probe and the references

Rival sizes, estimated from the visible text: thedaviddias color-contrast about 340 words (roughly 460 tokens), dylantarre color-contrast about 600 to 700 words (roughly 880 tokens), jkense wcag-audit-perceivable-color about 13 words visible. This skill costs more to load than all three. What it adds is a checker and a probe that measure the pairs instead of the agent reasoning about them, an unrounded ratio, alpha blending, dark-mode runs and a fix suggestion that is re-measured. On a 448,889-character stylesheet the checker took 1.42 seconds and the first 40 lines of output cost about 1,208 tokens. On a stylesheet under about 100 lines, read the file and skip the checker.

Handoffs

keyboard-focus.md covers focus order, visible focus behaviour and focus not obscured. aria-names-roles.md covers accessible names. html-forms.md covers labels and error messages. fluid-type-spacing.md covers text size and zoom.

Appendix

Tool facts, measured values and source notes are in references/appendix.md. Token recipes, focus-ring CSS and scrim patterns are in references/patterns.md. Read them only when a step points there.

Sources

Last verified 29 October 2026 for every source.

What this skill does

WebAIM’s 2026 survey of one million home pages (testing February 2026, WAVE API) found low contrast text on 83.9% of them, the most common failure. Many of those failures are one colour token used in dozens of rules, which an agent can fix once if it measures before it edits.

The skill runs scripts/cc_check.py over your CSS. It resolves custom properties, blends alpha and opacity, reads dark-mode blocks and prints each ratio rounded down, because W3C and WebAIM both say a ratio is never rounded up: 4.47 fails 4.5. For colours a static check cannot know, such as oklch() values and inherited backgrounds, it gives you a short probe to run in DevTools. It then proposes the nearest passing colour, re-measures it, and edits only in batches you approve.

Who it is for

Use it if you are a frontend developer, a theme builder or a designer who hands a palette to developers, or if you review colours chosen by an AI tool. It fits when Lighthouse or axe reports a colour-contrast failure, a brand palette must pass AA, placeholder text or focus rings are hard to see, or dark mode was added later. It does not cover colour-blindness simulation, APCA or colour as the only cue.

What is inside

The file is 3,323 words (version 1.0.0) with two scripts and two reference files. It holds:

  • Seven guardrails, including never changing a brand token without approval and never treating a Lighthouse score of 100 as conformance.
  • Seven inputs the agent must have first, with the exact question for each.
  • A workflow of steps 0 to 9, a table with one fix per checker code, and a thresholds table with sources.
  • Two standard-library tools: scripts/cc_check.py, a CSS checker with eight problem codes and a single-pair mode, and scripts/probe.js, a browser snippet that prints selectors and ratios but no page text.
  • A worked example with real output (5 errors and 2 warnings before, 0 after), with the probe run in Chromium 153.
  • A report template, a starting prompt, five failure modes and an eight-item quality bar.
  • Two reference files: token and focus-ring patterns, and an appendix with measured values and what axe cannot see.

The token costs below are estimates from the skill’s own count: words times 1.35 for prose and characters divided by 3.5 for code and tables. They are not from a model tokeniser.

ItemTokensStatus
Description (always loaded)101Estimate
SKILL.md body (when the skill fires)4,717Estimate
references/appendix.md, read only to check a claim700Estimate
references/patterns.md, read only when rewriting focus rings, tokens or scrims486Estimate
Typical run (description, body, checker and probe output)5,499Estimate
Cheaper path (checker, fix flagged lines, no probe, no references)5,199Estimate
Worst case (both references, 40 lines of checker output, 30 probe rows)8,212Estimate

This skill costs more to load than the three rivals I read, which are roughly 460 and 880 tokens for the visible text of two of them and a trigger line for the third. What it adds is a checker and a probe that measure the pairs, an unrounded ratio, alpha blending, dark-mode runs and a fix suggestion that is re-measured. On a 448,889-character stylesheet the checker took 1.42 seconds.

How to install and use it in Claude, Cursor and other agents

Claude. Keep the folder as colour-contrast/ with SKILL.md, scripts/ and references/ inside, and zip it. In Claude, open Customize, then Skills, then Upload a skill. In Claude Code, put the folder in ~/.claude/skills/ or .claude/skills/ in a project.

Cursor and other agents. Put the folder where your tool reads skills, or paste the body into its rules and keep the scripts beside your code. Then send the starting prompt from the Templates section.

Before you install. Read the scripts first. The checker reads CSS files and writes nothing. The probe reads computed styles on a page you own and prints selectors and numbers, never page text. The skill tells the agent to edit only on a branch in batches you approve. Install skills only from sources you trust.

A worked example

An invented 15-line stylesheet has a muted grey of #777777, semi-transparent badge text, a pale heading, a faint focus outline and a placeholder. The checker reports 5 errors, 2 warnings and 3 info lines. The grey measures 4.47 against 4.5 and fails, the rgba() badge text blends to #8c8c8c at 3.35, and the dark-mode grey #666666 on #121212 measures 3.26.

One token edit fixes both .hint lines, solid colours replace the alpha and opacity text, and the re-run prints 0 errors and 0 warnings. The probe, run in Chromium 153, agrees with the checker to within 0.01. The gradient banner stays “unmeasured” until someone supplies its lightest stop, and the skill reports that instead of guessing.

Common mistakes

The skill is built around five ways this goes wrong:

  • Rounding up. A report shows 4.5 for a failing pair. The skill prints each ratio rounded down, with the exact value.
  • Measuring the solid colour of transparent text. The ratio looks fine until the background changes. The skill blends first.
  • Testing only the default state. Hover, focus, visited, error and placeholder colours fail unseen. The skill walks a state list in every theme.
  • Trusting automation on images and gradients. axe marks these incomplete. The skill flags them and asks for a measured number.
  • Fixing light mode only. The same token fails on the dark surface. The skill runs the checker with a dark background too.

Keyboard Focus checks that focus is visible and not hidden, which is where a focus ring contrast fix gets tested. HTML Forms covers visible labels, so placeholder text is never the only label. Semantic HTML covers the markup these colours sit on.

Frequently asked questions

Normal text needs 4.5:1. Large text, which is 18pt (24px) or 14pt bold, needs 3:1, according to WebAIM. Interface components and meaningful graphics need 3:1 against adjacent colours under success criterion 1.4.11, the W3C says. AAA raises text to 7:1, or 4.5:1 for large text. The skill checks all of these.

No. WebAIM states that you cannot round a contrast ratio up to 4.5:1, and its example of #777777 at 4.47:1 fails. The W3C adds that 2.999:1 fails a 3:1 rule. The skill prints each ratio rounded down, with the exact value alongside, so a failing pair never looks like a pass.

It blends the colour over the real background before measuring, because transparency lets the background show through and lowers contrast, as WebAIM notes. In the worked example, text at rgba(0,0,0,0.45) on white blends to #8c8c8c and measures 3.35:1, which fails. The skill prefers a solid colour, which does not shift when the background changes.

Placeholder text is text, so it needs 4.5:1 and the checker reports it as placeholder-low. Disabled controls are exempt under the W3C rule for inactive components, so the skill skips them. A control border needs 3:1 only when the border is the sole cue that something is a control.

Test the lowest-contrast spot behind the text, not an average. Deque says axe finds gradients and pseudo-element backgrounds hard to evaluate, so the skill flags them and asks you to measure the lightest stop or image area. If you add a scrim, the skill re-measures the result instead of assuming it passes.

No. WCAG 2.2 conformance is measured with the ratio formula in the W3C documents, so that is what the scripts implement. The skill lists APCA and WCAG 3 scoring as out of scope, so a report uses one method. If your contract names APCA, ask a human accessibility reviewer how to apply it.

The description is about 101 tokens and loads in every session. The SKILL.md body is about 4,717 tokens when the skill fires. A typical run is about 5,499 tokens with checker and probe output, the cheaper path is about 5,199, and the worst case is about 8,212. All figures are estimates from the skill’s own count, not a model tokeniser.

Changelog

  • 1.0.0, 29 October 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.