Guides · 2026-09-25 · By VSNARY | Emmanuel Orta · 0 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 that hurt people most often appear only when something happens: a pointer lands on a button, keyboard focus moves, a script draws a form. This guide shows how to test those states, with the traps we hit testing our own site.
The numbers you are testing against #
| What | WCAG 2.1 AA minimum | Notes |
|---|---|---|
| Normal text | 4.5 : 1 | Under 24px, or under about 18.7px when bold |
| Large text | 3 : 1 | 24px and up, or about 18.7px and up when bold |
| Focus indicator and UI parts | 3 : 1 | Against the colours next to them (1.4.11) |
| Disabled controls and pure decoration | No minimum | Crossed-out content that still carries meaning is not decoration |
Step 1: measure the colour that is really behind the text #
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. Skipping this step is how a border tint used as a text colour passes a checker and fails a reader.
Step 2: put the page into each state for real #
Hover and focus styles only exist while the element is hovered or focused, so a checker has to create the state. A headless browser can do it: 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 #
A focus outline has to be visible against the surface it sits on. A site-wide teal outline is fine on a white page and invisible on a teal box. For each focused element, read outline-color and compare it with the background around the element, not behind the text. Also flag any element whose focus changes nothing at all: no outline, no shadow, no colour change.
The four causes you will probably find #
| Symptom | Usual cause | Fix |
|---|---|---|
| Button text vanishes on hover | A more specific general rule (for example, links inside articles) sets the text colour | Give 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 hover | A site-wide button:hover background out-ranks a single-class rule | Set the component’s own hover background |
| Text is faint everywhere in one theme | A border or tint token is used as a text colour | Use text tokens for text; test both themes |
| Labels in charts fail in dark mode | Chart colours are hard-coded for a light background | Map 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.
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.
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 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. Also check that focus changes something visible at all.
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.
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.
Does disabled or crossed-out text need to pass?
Disabled controls and pure decoration are exempt. Crossed-out text that still tells the reader something, such as an item that is missing, is content and should pass.
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.
Related findings
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.