CrawlCheck

Guides · 2026-09-25 · By · 2 views

How to test text color contrast on hover and keyboard focus

Static contrast checkers measure a page at rest. The failures people hit happen on hover, on keyboard focus and after scripts run. Six steps to test them, and the four causes you will find.

Put the page into each state for real, then measure. Use a headless browser to move the pointer onto each distinct link and button and to give each one keyboard focus, turn off transitions first, open closed content, and measure every text instance against the composited background behind it, including transparent layers and parent opacity. Normal text needs 4.5:1, large text 3:1, and focus indicators 3:1 against what surrounds them. Re-run the same sweep on the fixed build.

Most contrast checks measure a page at rest. The failures people actually hit appear only when something happens: a pointer lands on a button, keyboard focus moves, a script draws a form, a tab opens. This guide shows how to test text and focus contrast in those states, with the measurements and traps from running the test on our own site.

Key points
  • WCAG 2.1 AA asks for 4.5:1 for normal text, 3:1 for large text, and 3:1 for focus indicators and other UI parts against the colours next to them.
  • Hover and focus styles exist only while the state exists, so a checker has to create each state in a browser before it measures.
  • The background that counts is the composited colour behind the text, including transparent layers and parent opacity.
  • The most common hover failure is a general rule for article links beating a button’s own hover rule.
  • Group elements by tag and class and test two of each; on our site that meant about 26 elements per page.

What contrast ratio does WCAG 2.1 require? #

The contrast ratios WCAG 2.1 asks for1:13:14.5:17:121:1large text, focus rings, UI parts (AA)normal text (AA)normal text (AAA)black on whiteAa 4.54:1 — just passesAa 2.85:1 — failsAa 1.18:1 — a 12% tint
The scale runs from 1:1 (no contrast) to 21:1 (black on white). Swatches show fixed colours so the ratios stay true in either theme: mid-grey #767676 on white is the darkest common grey that passes for normal text.
WhatWCAG 2.1 AA minimumNotes
Normal text4.5 : 1Under 24px, or under about 18.7px when bold (1.4.3)
Large text3 : 124px and up, or about 18.7px and up when bold (1.4.3)
Focus indicator and UI parts3 : 1Against the colours next to them (1.4.11)
Disabled controls and pure decorationNo minimumCrossed-out content that still carries meaning is not decoration

The ratio comes from the relative luminance of the two colours, (L1 + 0.05) / (L2 + 0.05) with the lighter colour on top, where luminance weights green most, red less and blue least. You do not need to compute it by hand, but it explains two surprises: a blue that looks dark can still be light by luminance, and a colour with transparency has no ratio at all until it is blended with what is behind it.

Step 1: measure the colour that is really behind the text #

Finding the colour that is really behind the textWHAT A SIMPLE CHECKER READSOne pair of valuestext colourvs the element’s ownbackground-colorWHAT THE READER SEESLayer 1page#ffffffLayer 2card, 80% whiteover the pageLayer 3parentopacity 0.6Composite every layer from the page up, then the text colour (with its own alpha) on top.result = alpha × foreground + (1 − alpha) × backgroundrepeat per layer; parent opacity fades text and background togetherA text colour that passes against its own box can fail once the layers beneath it are counted.
Contrast is measured between two colours a reader actually sees. Transparent layers and opacity change both of them.

The background of a piece of text is rarely its own background-color. Walk up the parents, composite every semi-transparent layer from the page up, and multiply in every parent’s opacity. A text colour with alpha, such as a 12 percent tint, must be composited over that result before you compute the ratio. A 12 percent teal tint over white comes out at about 1.2:1, which is why a border tint used as a text colour passes a checker that ignores alpha and fails every reader.

Step 2: put the page into each state for real #

The state sweep, in order1 Freezeno transitions2 Revealscroll-in, open3 Hovertwo per group4 Focuskeyboard mode5 Measurecomposited6 Re-runon the fixEach state is created for real in a browser, then measured; a state you never create is never tested.
The order matters: animation off before measuring, closed content opened before hovering, and the same sweep re-run on the fixed build.

Hover and focus styles only exist while the element is hovered or focused, so the checker has to create the state. A headless browser can: move the real mouse to the centre of each link and button, measure, move away; press a key once so the browser treats the next focus as keyboard focus, then focus each element and measure. You do not need every element. Group elements by tag and class and test two of each group; on our site that came to about 26 per page, and 1,878 hovers across 36 pages in two themes.

Measure the hovered element and its parent. Many hover rules change a card and its children together, and some change a sibling.

Step 3: turn off animation first #

If the site animates colour changes, a measurement taken mid-transition reads a colour halfway between two states. Emulate reduced motion, or inject *{transition:none!important;animation:none!important} before you measure. The same applies to elements that fade in as they scroll into view: reveal them first, or the checker records invisible text that no reader ever sees.

Step 4: open the things that are closed #

Collapsed <details> blocks, inactive tabs and hidden steps are real content. Open every <details>, activate each tab in turn, and measure again. On our own pages, clicking through a four-step walkthrough revealed that inactive steps were faded to 30 percent and read at about 1.5 to 1.

Step 5: check the focus ring against what is around it #

The same focus ring on two surfacesFocused linkRING VS WHITE: 5.47:1 — VISIBLEFocused linkRING VS TEAL BOX: 1:1 — INVISIBLE
A site-wide focus colour is tested against the surface around each focused element, not once against the page. Colours are fixed so the ratios hold in either theme.

A focus outline has to be visible against the surface it sits on. A site-wide teal outline that reaches 5.47:1 on a white page has no contrast at all on a teal box. For each focused element, read outline-color (and box-shadow, which many designs use instead) and compare it with the background around the element, not behind the text. Also flag any element whose focus changes nothing visible: no outline, no shadow, no colour change. That fails 2.4.7, Focus Visible, whatever its colours.

A minimal sweep in a headless browser #

The whole method fits in a short script. This sketch uses Playwright; the same steps work in Puppeteer or Selenium:

await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({ content: '*{transition:none!important;animation:none!important}' });
for (const el of await page.$$('a, button, [role=button]')) {
  const box = await el.boundingBox(); if (!box) continue;
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  results.push(await el.evaluate(measureContrast));   // composited colours, then the ratio
}
await page.keyboard.press('Tab');                      // switch the browser into keyboard-focus mode
for (const el of await page.$$('a, button, input, select, textarea')) {
  await el.focus(); results.push(await el.evaluate(measureFocusRing));
}

measureContrast is the part that needs care: read the computed text colour, walk up the parents compositing each background and opacity, and only then compute the ratio. Sample two elements per tag-and-class group rather than every element, and record the selector, state, theme and both colours for every failure, so the fix can be traced to a rule.

The same grey in four places #

A colour has no contrast of its own; it only has contrast against something. Mid-grey #767676 shows why surfaces matter: 4.54:1 on white passes for normal text, 4.13:1 on a light-grey card (#f3f4f6) fails, 3.67:1 on a slightly darker panel (#e5e7eb) fails, and 3.91:1 on a near-black dark-mode background (#111827) also fails. One token, four surfaces, one pass. That is why every test should run in both themes and on every surface a component can appear on.

Why does button text disappear on hover? #

Why button text disappears on hoverTWO RULES MATCH THE SAME LINK ON HOVERThe button’s rule.btn:hover { color: #fff }specificity 0,2,0A general article rule.post .body a:hover { color: #0f766e }specificity 0,3,1The general rule is more specific, so it wins: teal text on a teal button, 1:1.Fix: give the button’s hover rule equal or higher specificity and set its text colour explicitly.
The most common hover failure we find is not a bad colour choice. It is a general rule written for links in articles beating a component’s own hover rule.
SymptomUsual causeFix
Button text vanishes on hoverA more specific general rule (for example, links inside articles) sets the text colourGive the button’s hover rule an equal or higher specificity and set its text colour explicitly
A <button> styled as a chip or row turns solid on hoverA site-wide button:hover background out-ranks a single-class ruleSet the component’s own hover background
Text is faint everywhere in one themeA border or tint token is used as a text colourUse text tokens for text; test both themes
Labels in charts fail in dark modeChart colours are hard-coded for a light backgroundMap chart colours to theme tokens

Step 6: verify the fix the same way you found the fault #

Run the whole sweep again on the fixed build, before it ships, and again after. Our first draft of the fixes introduced new failures of its own: a focus rule that forced the text colour on filled buttons also caught an outline-style button and a text-only re-scan link, whose backgrounds do not change on focus. The pre-release run caught both, and the rule was removed before anything shipped. Test injected CSS with care, too: a style added at the end of the head can still lose to a more specific rule or to a style block later in the page, so an injected fix can look broken when the real one would work.

Can an automated scan check contrast? #

Only one that renders the page. Contrast depends on computed styles, layers and states, none of which exist in the HTML a server sends. That is why CrawlCheck’s accessibility section, which checks 21 WCAG 2.1 success criteria from the delivered HTML, lists 1.4.3 and 1.4.11 under “needs a rendered page” rather than claiming them. A rendered sweep like the one above is the right tool for contrast; a markup check is the right tool for names, labels, roles and structure. You need both, and neither replaces testing with real users.

What a clean result means #

A clean sweep means every text instance you rendered met its minimum in every state you created. Keep the script and run it on every release, not once a year: most of the failures we found were introduced by ordinary changes to shared rules, long after the original design passed review. It does not cover states you did not create, text inside images, or whether the page works with a screen reader. For that side, see the nine structural things a screen reader needs. For what we found running this on our own site, read we failed our own contrast check.

Every figure above came out of this scanner.

Point it at your own domain and see the same measurements, free.

Scan a domain — free

The main product

Found this on your own site? We fix it for $749.

Scan free to see where you stand. The fix is one site, every finding implemented and re-measured, with a sealed before and after.

Questions this post answers

How do I check contrast on hover?

Move a real pointer onto the element in a headless browser, wait for any transition to finish or disable transitions, then measure the text colour against the composited background behind it.

What contrast ratio does WCAG 2.1 require?

At level AA, 4.5 to 1 for normal text and 3 to 1 for large text (24px and up, or about 18.7px bold) under 1.4.3, and 3 to 1 for focus indicators and other UI components against adjacent colours under 1.4.11.

What contrast does a focus indicator need?

Under WCAG 2.1 (1.4.11) a focus indicator needs 3 to 1 against the colours next to it. Focus must also change something visible at all, under 2.4.7 Focus Visible.

Why does my contrast checker say pass when text is hard to read?

It may be ignoring transparency or parent opacity, or measuring the element's own background instead of the colour actually behind the text.

Why does button text disappear on hover?

Usually a more specific general rule, such as one for links inside articles, sets the text colour on hover and beats the button's own hover rule. Raise the button rule's specificity and set its text colour explicitly.

Do hidden tabs and collapsed sections need to pass contrast?

Yes, once they are shown. Open or activate them and measure again; they are real content.

Can a crawler or HTML scan check colour contrast?

No. Contrast depends on computed styles, layers and interaction states, which exist only in a rendered page. Use a rendered sweep for contrast and a markup check for names, labels and roles.

How many elements should I test on each page?

Group interactive elements by tag and class and test two of each group. That catches the pattern without testing hundreds of identical links.

Does a hover colour need 3:1 contrast against the normal state?

No. The W3C's Understanding document for 1.4.11 says colour changes between states of one component do not need 3:1 when the states are not shown next to each other, and treats hover styling as supplemental. What matters is that hovering does not make the component, or the text inside it, lose the required contrast against what surrounds it.

Why does keyboard focus skip links in Safari?

On a Mac, Safari's Tab key moves only between form controls by default. Press Option-Tab, or turn on Press Tab to highlight each item on a webpage in Safari's Advanced settings, before testing focus styles on links. Check focus rings in Chrome, Firefox and Safari, because each draws its default ring differently.

Related findings

How anything measured in this article was measured15client identitiesone second, one address5machine filesapex and www114named agentsresolved from robots.txt24sections scoredreach, read, quoteHow anything measured here was measured15 client identities5 machine files114 named agents24 sections scoredone second, one addressapex and wwwresolved from robots.txtreach, read, quote
No account, nothing installed, and the same sequence on every domain — which is what makes one scan comparable to another. Run it on your own site.

Comments

Comments are read before they appear. Nothing is published automatically, and no account is needed.

Writing about this? Facts, live figures and marks — every number on that page is dated and traceable to a scan.

All guides · The dataset · How the dataset works