---
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.
