CrawlCheck

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

How to test text color contrast on hover and keyboard focus

Static contrast checkers measure a page at rest. On our own site, hover and focus found 65 more failure patterns than the at-rest check did. Six steps to test them, and the usual causes.

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.

Check your own site free

About a minute. No account, no card, nothing installed.

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, starting with what the test found when we ran it 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.
  • On our own site an at-rest check fixed the obvious failures; a hover and focus sweep then found 65 more failure patterns across 242 elements.
  • 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 did the test find on our own site? #

It started with one line of our own copy at the foot of every post, in a teal box: it measured 1.11 to 1 in one theme and 1.15 to 1 in the other, against a minimum of 4.5 to 1. The text had been given a faint tint meant for borders. That led to two passes over our own pages.

The first pass measured every piece of text at rest, in light and dark mode, against the composited background behind it: 55,118 measurements. Most failures traced back to a handful of design tokens, and fixing those fixed dozens of elements at once.

What failed at restWhereWorst contrast
Step numbers drawn teal on a teal discthe forms product page1.0 : 1
Registry badges faded to 55% opacity1,116 badges on the registry pages2.0 : 1
Figure labels hard-coded in light-theme colourscharts and figures, dark mode2.2 : 1
Gold labels on a pale backgroundlight mode3.4 : 1
Grey “faint” textlight mode4.3 : 1

The second pass drove a real browser across 36 pages in both themes: it clicked every tab and step control, hovered each distinct link and button (1,878 hovers) and gave each one keyboard focus (1,834 focuses). It found 65 failure patterns, across 242 elements, that the at-rest pass had not reported.

What failed in useStateWorst contrastCause
“Get it fixed” and “Fix it all”hover1.13 : 1a link-hover rule overrode the button’s own hover colour
“Scan now” and “Scan your own domain”hover1.77 : 1the same rule, on the teal buttons
The Share button on the demo reporthover1.0 : 1a site-wide button hover painted teal behind teal text
Directory filter chips and form channel rowshover1.0 : 1the same site-wide rule
The focus ring in the teal boxkeyboard focus1.61 : 1a teal ring on a teal box
Inactive steps in a walkthroughafter a click1.5 : 1faded to 30% opacity
A lead-form buttonrendered by script3.38 : 1white text on a mid-teal brand colour

The worst were the first two rows: our own scan and fix buttons were fine at rest and turned unreadable the moment a pointer landed on them. A check of the page at rest can never see that, because at rest nothing is hovered. The last row mattered beyond this site: the same lead form runs on other sites, so it now keeps its button text readable while keeping the brand hue. Of ten live configurations, three failed and were corrected. Every colour involved had been chosen by a person and looked right where it was chosen; the failures appeared only where two reasonable rules met on one element. The method below is how we found them, and how we confirmed zero failures before and after release.

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 a CrawlCheck scan, which reads accessibility from the delivered HTML, lists 1.4.3 and 1.4.11 as needing 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. The same release habit catches broken links: the internal link audit also belongs after every change to shared templates.

Share this finding:LinkedInXEmailFollow Emmanuel on LinkedIn

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

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

The main product

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

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

Free browser extension

Check this on any site you open, without leaving the page.

CrawlCheck for Chrome grades every site you visit A–F and shows, from your own browser, what search engines and AI assistants are actually served. Free, no account.

Write for the library

Measured something yourself? Submit an article.

Send your finding, the data and the method. A person reads every submission and replies by email; accepted articles are published under your name with a link to your study. Free to submit, and placement is never for sale.

Submit an article

How it works · a reply within about a week

Spotted a mistake or a bug on this page? Report an error. Confirmed errors are fixed and logged on the corrections page.

Comments

Comments are read before they appear. Nothing is published automatically, and no account is needed. An AI agent with a published signing key can post without the queue: it signs the request and the signature is checked against its own domain (how). Those appear at once, marked verified.

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