CrawlCheck

Agent view · Guides · 2026-09-25

How to test text color contrast on hover and keyboard focus

This is the response this server returns to a client that asks for Accept: text/markdown at https://crawlcheck.io/guides/how-to-test-color-contrast-in-hover-and-focus-states: fetched when you opened this page, shown byte for byte. An assistant, a crawler that negotiates markdown, or anything reading our feed gets exactly this. The markdown leaves signed (RFC 9421, key in our key directory), so a crawler can prove the page it read is the page this server sent; the check is shown below.

status200
content-typetext/markdown; charset=utf-8
varyAccept
link<https://crawlcheck.io/llms.txt>; rel="describedby"; type="text/plain", <https://crawlcheck.io/.well-known/api-catalog>; rel="service-desc"; type="application/linkset+json", <https://crawlcheck.io/guides/how-to-test-color-contrast-in-hover-and-focus-states>; rel="cite-as", <https://crawlcheck.io/.well-known/http-message-signatures-directory>; rel="http-message-signatures-directory"
cache-controlpublic, max-age=900, stale-while-revalidate=2400
bytes23,026
words3,665
sha-256a78d4e58c8d7f1472679359652b756b7601bd86b0a58a72298ea52e2e7d06735
repr-digestsha-256=:p41OWMjX8UcmeTWWUrdWt2Ab2GsKWKcimOpS4ufQZzU=: ← matches the body shown
signature-inputsig1=("@authority";req "@path";req "repr-digest" "content-type");created=1794464049;expires=1794467649;keyid="1hHmE3fo1rg5Ig70wuFtJ5wBc7G-sUTgUJkxyPWFRps";alg="ed25519";nonce="Dvi5b3ipqnMu4Xazz1_T9g";tag="crawlcheck-page"
signaturesig1=:0XFkpO1xc5DDMbIK4OvLHzEOW4Sf0KmqD9viUfEpEVeI4uO+uU/jzujT2uHSXKP6AChIsgsZliMwtmRni5zBCw==:
verifiedyes — signature by key 1hHmE3fo1rg5Ig70… from https://crawlcheck.io/.well-known/http-message-signatures-directory (directory valid), valid for 3599 more s. Checked here exactly as a crawler would; redo it yourself: https://crawlcheck.io/api/tool/wba
Guides · 2026-09-25 · By [VSNARY | Emmanuel Orta](/author) · 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

Check my site

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

On this page

- What did the test find on our own site?
- What contrast ratio does WCAG 2.1 require?
- Step 1: measure the colour that is really behind the text
- Step 2: put the page into each state for real
- Step 3: turn off animation first
- Step 4: open the things that are closed
- Step 5: check the focus ring against what is around it
- A minimal sweep in a headless browser
- The same grey in four places
- Why does button text disappear on hover?
- Step 6: verify the fix the same way you found the fault
- Can an automated scan check contrast?
- What a clean result means

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 rest | Where | Worst contrast |
| --- | --- | --- |
| Step numbers drawn teal on a teal disc | the forms product page | 1.0 : 1 |
| Registry badges faded to 55% opacity | 1,116 badges on the registry pages | 2.0 : 1 |
| Figure labels hard-coded in light-theme colours | charts and figures, dark mode | 2.2 : 1 |
| Gold labels on a pale background | light mode | 3.4 : 1 |
| Grey “faint” text | light mode | 4.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 use | State | Worst contrast | Cause |
| --- | --- | --- | --- |
| “Get it fixed” and “Fix it all” | hover | 1.13 : 1 | a link-hover rule overrode the button’s own hover colour |
| “Scan now” and “Scan your own domain” | hover | 1.77 : 1 | the same rule, on the teal buttons |
| The Share button on the demo report | hover | 1.0 : 1 | a site-wide button hover painted teal behind teal text |
| Directory filter chips and form channel rows | hover | 1.0 : 1 | the same site-wide rule |
| The focus ring in the teal box | keyboard focus | 1.61 : 1 | a teal ring on a teal box |
| Inactive steps in a walkthrough | after a click | 1.5 : 1 | faded to 30% opacity |
| A lead-form button | rendered by script | 3.38 : 1 | white 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 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.

| What | WCAG 2.1 AA minimum | Notes |
| --- | --- | --- |
| Normal text | 4.5 : 1 | Under 24px, or under about 18.7px when bold (1.4.3) |
| Large text | 3 : 1 | 24px and up, or about 18.7px and up when bold (1.4.3) |
| [Focus indicator](/glossary/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 |

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 #

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

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](/glossary/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? #

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.

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

## 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](/blog/accessibility-structure-nine-things-a-screen-reader-needs-before-contrast-matters). The same release habit catches broken links: [the internal link audit](/guides/how-to-audit-internal-links-and-dead-anchors) also belongs after every change to shared templates.

Written by

[Emmanuel Orta](/author) · VSNARY

I build CrawlCheck and write everything published here, from measurements the verification layer took. Where a post got something wrong, the correction stays in the post.

[Every post, and where I got it wrong →](/author) · [Follow on LinkedIn →](https://www.linkedin.com/in/emmanuelorta)

Share this finding: [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcrawlcheck.io%2Fguides%2Fhow-to-test-color-contrast-in-hover-and-focus-states%3Futm_source%3Dlinkedin%26utm_medium%3Dsocial%26utm_campaign%3Dshare)[X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fcrawlcheck.io%2Fguides%2Fhow-to-test-color-contrast-in-hover-and-focus-states%3Futm_source%3Dx%26utm_medium%3Dsocial%26utm_campaign%3Dshare&text=How%20to%20test%20text%20color%20contrast%20on%20hover%20and%20keyboard%20focus)[Email](mailto:?subject=How%20to%20test%20text%20color%20contrast%20on%20hover%20and%20keyboard%20focus&body=https%3A%2F%2Fcrawlcheck.io%2Fguides%2Fhow-to-test-color-contrast-in-hover-and-focus-states)[Follow Emmanuel on LinkedIn](https://www.linkedin.com/in/emmanuelorta)

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

[2026-08-28 Free AI visibility checkers: how to tell if one is any good What free AI visibility checkers really test, why a 100% score can still come with a fix to buy, and four checks a free tool must](/guides/free-ai-visibility-checkers-what-they-measure)[2026-08-28 Do AI crawlers render JavaScript? Check what they see Assume AI crawlers do not run JavaScript. A one-command test shows whether your content is in the raw HTML, and why text inside a](/guides/do-ai-crawlers-render-javascript)[2026-09-01 Dead link or blind spot? When a checker's failure is its own Our sameAs check called a live profile page dead because our own fetch failed. It now reports such targets as unverifiable. How to](/blog/the-scanner-that-cannot-fetch-its-own-host)

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.

[Fix it all — $100](https://buy.stripe.com/4gM00j7OjawhdLEdyR2VG0v)
[What it covers](/pricing#fix) · [scan first, it is free](/)

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.

[Add to Chrome — free](https://chromewebstore.google.com/detail/plgjengobhbdmbgckiiaaflgiepdbcpi?utm_source=crawlcheck.io&utm_medium=post)
[What it does](/extension)

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](/submit-finding#send)
[How it works](/submit-finding) · a reply within about a week

Spotted a mistake or a bug on this page? [Report an error](/report-error?k=article). Confirmed errors are fixed and logged on the [corrections page](/corrections).

## 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](/api/comment/signed)). Those appear at once, marked *verified*.

Send for review

Writing about this? [Facts, live figures and marks](/press) — every number on that page is dated and traceable to a scan.

[All guides](/guides) · [The dataset](/data) · [How the dataset works](/policy)

We read your site the way crawlers do, from our own network, and every report says exactly what was asked for and what came back. [Who runs this](/author) · [Press kit](/press) · [Videos](/videos) · [Evidence for disputes](/evidence) · [YouTube](https://www.youtube.com/@crawlcheck) · [hello@crawlcheck.io](mailto:hello@crawlcheck.io)

#### How we measure, and our own machine files

CrawlCheck reads every site as itself. Its crawler-identity tests send named user-agent strings from its own network, never from an operator’s published range, so a site that verifies crawlers can tell them apart; every report says so. Results reflect what an anonymous non-browser client receives. [What we collect and how to opt out](/policy) · [how every report is timestamped](/proof) · [who runs this](/author) · [press kit](/press) · [hello@crawlcheck.io](mailto:hello@crawlcheck.io).

**Second vantage:** the [browser extension](/extension) measures what crawlers are served from your own address and after your JavaScript runs — the half this server cannot see. From 1.0.0 it sends the domain you ask about, on one button, and anonymous usage counts you can switch off; from 1.0.3 a footer switch turns off the hostname lookup for the grade on the icon; from 1.0.4 a share switch, on by default, sends a signed Two-vantages reading (status, size, words and a hash per identity; never page text); from 1.0.16 chips send the hostnames of search results and AI citations while the chips and grade switches are on; from 1.0.17 a readings switch, on by default, sends one signed page reading a day (numbers, the hostname and the page address with the query removed; never in incognito; at most 40 a day) and only while the share and grade switches are also on. Earlier versions send nothing.

**Our own machine layer for the agentic web:** [llms.txt](/llms.txt) · [llms-full.txt](/llms-full.txt) · [agents.md](/agents.md) · [SKILL.md](/SKILL.md) · [entitymap.json](/entitymap.json) · [media kit](/.well-known/media-kit.json) · [MCP server card](/.well-known/mcp/server-card.json) · [API catalog](/.well-known/api-catalog) · [OpenAPI](/openapi.json) · [robots.txt](/robots.txt) · [sitemap.xml](/sitemap.xml) · [security.txt](/.well-known/security.txt) — every one named in robots.txt, which is exactly what we check for on yours.

**Registry:** [Agents](/registry/agents) · [Entity](/registry/entity) · [AI policy](/registry/aipolicy) | **Pages:** [IP ranges](/data/ip-ranges) · [Conduct: Google notebooklm](/agents/conduct/google-notebooklm) | **Certificates:** [lab2-clean.crawlcheck.io](/certificate/lab2-clean.crawlcheck.io) · [sudowrite.com](/certificate/sudowrite.com) | **Datasets:** [Oklahoma](/datasets/state/oklahoma) · [Technologies: Datadog rum](/datasets/technologies/datadog-rum) · [Problems: Robots legacy agent only](/datasets/problems/robots-legacy-agent-only) | **AI crawlers:** [Applebot extended vs Google-Extended](/ai-crawlers/compare/applebot-extended-vs-google-extended) | **Guides:** [Indexnow what it does who reads it and how to check it worked](/guides/indexnow-what-it-does-who-reads-it-and-how-to-check-it-worked)

[Contact](/contact) · [Partnerships](/partnerships) · [Terms of service](/terms) · [Refund policy](/refunds) · [Trust center](/trust) · [Report an error](/report-error)

Same body as a file: /guides/how-to-test-color-contrast-in-hover-and-focus-states.md · as data: /feed.json · what we tell agents site-wide: /llms.txt, /agents.md. To see any other site this way, from your own browser, after its JavaScript runs: the browser extension.