For developers
Five accessibility defects we find on well-built sites, and the one CSS line behind half of them
Two news sites on Next.js and Tailwind, 26 pages each, built with care: landmarks, alt text, keyboard support. Still 1,041 contrast failures, skipped headings and 20-pixel links. Where they came from, how to find them, and the fixes that took them to zero.
By Conformance DeskPublished 9 min read
Two regional news sites. Both built this year on Next.js, React and Tailwind 4 by people who cared: landmarks on every page, skip links, alt text on every image, keyboard-operable menus, visible focus rings, dark mode. The kind of site that passes a quick Lighthouse run and makes its owner feel done. We tested 26 pages of each with our pipeline (axe-core, HTML_CodeSniffer and our own probes in a real browser, then a criterion-by-criterion assessment reviewed by an analyst) and found 1,041 text contrast failures between them, skipped headings on 23 pages, links too small to tap on 48, a search field with no name, and two pages with two main landmarks each.
None of that is a judgement on the builders. These are the defects that survive good practice, because good practice does not include measuring. Here are the five that account for almost everything, what caused each, and what fixed it. After two rounds of fixes, a live axe pass found zero violations on every tested page of both sites.
1. The brand accent colour, used as text
The single biggest line item: 928 contrast failures on 16 pages of one site, 113 on 23 pages of the other. Nearly all of them were one design decision each. The first site's coral accent, used for category labels on every article in every list, measured 3.27:1 against its warm off-white page and 3.5:1 against white. The second site's footer set its copyright line in a grey that measured 2.35:1 on the dark footer, and its active navigation chip in red on a pale pink at 4.41:1, nine hundredths short.
WCAG 1.4.3 asks for 4.5:1 for normal text and 3:1 for large text. Accent colours are chosen on a white artboard for how they look as a block, then used at eleven pixels in uppercase with letter spacing, where they have to work as text. Nobody measures the second use.
| Where | Colour pair | Measured | Fix | After |
|---|---|---|---|---|
| Category labels, every article card | coral on off-white | 3.27:1 | two steps darker coral for text only | 5.07:1 |
| Footer copyright line | mid grey on navy | 2.35:1 | the footer's body grey | 6.96:1 |
| Active nav item | red on pale pink | 4.41:1 | a darker red on the same chip | 5.91:1 |
| Guide card eyebrows | gold on white | 3.34:1 | darker gold for text, hero gradient unchanged | 5.49:1 |
| Weather strip units | grey at 60 percent opacity | 2.8:1 | the same grey at full opacity | 7.6:1 |
The last row is the quiet one. Opacity multiplies the failure: a grey that reads 7.6:1 on white becomes 2.8:1 at 60 percent opacity, and a coral that passes at 5.07:1 fell to 4.19:1 inside a ticker whose links ran at 88 percent. Treat opacity on text as a colour change and measure it.
What to do on your own site: list every text colour token and every background it is used on, measure each pair at the smallest size it appears, and keep the list in the design system. A darker accent for text, two steps down the scale, is invisible to the brand and visible to the reader.
2. The CSS line that cancelled every link utility
This one is worth the whole article. Both sites had a base rule in their global stylesheet:
a { color: inherit; text-decoration: none; }
Sensible on its own: links take their colour from their context and designers add emphasis where they want it, with Tailwind utilities like text-white, text-[#CBD5E1], underline or hover:text-white. Except that none of those utilities had ever applied to a link on either site, since launch.
The cause is CSS cascade layers. Tailwind 4 puts its base styles, components and utilities into named layers. A rule written outside any layer, like the one above, is unlayered, and the cascade gives unlayered declarations priority over every layered declaration, whatever their specificity. So the base a rule beat text-white on the "All" filter chip (which rendered ink on red at 3.69:1), beat text-[#CBD5E1] on the footer's RSS button (4.03:1), and removed the underline from the credit link in the footer, which a tool then correctly read as a link told apart from its sentence by colour alone.
The fix is one line of wrapping:
@layer base { a { color: inherit; text-decoration: none; } }
Inside the base layer, the rule still applies by default and every utility wins over it, which is what the utilities were written to do.
How to find it: open the page, pick a link that carries a colour class, and compare the class with the computed colour in the inspector. If they differ, look for an unlayered rule on the same property. A site built on Tailwind 4 that migrated a stylesheet from an older setup is the usual victim. Two other sites of ours built from the same template did not have the fault, because their base rules were already inside @layer base; it was a per-site accident, which is why it survived.
3. Links too small to tap
WCAG 2.2 added 2.5.8, Target Size (Minimum), at Level AA: pointer targets at least 24 by 24 CSS pixels, with exceptions for inline links inside a sentence, for targets with enough clear space around them, and for sizes set by the browser. Our probes measured every link on every page and found the same three families on both sites: footer column links at 20 pixels tall, headline ticker links at 20, and a top bar of utility links at 16. Arrow links ("View all") and tag clouds at 16 to 20. Forty-eight of the fifty-two pages had at least one.
The fix is padding, not font size. A footer link in a flex column gets py-0.5 (two pixels each side) and its 20-pixel line becomes a 24-pixel target; the column gap shrinks by the same amount and the layout does not move. Ticker and arrow links become inline-flex items-center min-h-6. The text does not change. Inline links inside paragraphs are left alone, as the criterion allows.
Measure it rather than guess: a script that lists every anchor with a bounding box under 24 pixels tall takes ten lines and finds all of these in one run.
4. Headings chosen by a component, not by the page
Both sites render article cards with a shared component, and the component hard-codes its headline as an h3. On the home page, under a "Latest" h2, that is right. On the category, tag, search and archive pages, where the cards follow the page's h1 directly, every card skips a level, which is what axe's heading-order rule reported on 23 pages. The footer's column headings were h4 on both sites, so a page with only an h1 jumped from 1 to 4.
Skipping a level is not itself a WCAG failure, and we assessed it under 1.3.1 as a quality note rather than a defect. It matters because screen-reader users navigate by the heading outline, and an outline that goes h1, h3, h3, h3 says the page has sections it does not have. The fix is a headingLevel prop on the card, set to h2 by the pages where cards follow the title, and footer headings set to h2 so they fit under any page. The visual styling is a class and did not change.
5. White text over a photo, with a gradient between
Each site leads with a hero: a photo, a dark gradient overlay, a white headline. The overlay is an absolutely positioned sibling of the text, which is the normal way to build it. axe measured one headline at 1.07:1, white against the page's off-white, because it could not see the photo or the gradient as the text's background. A tool cannot measure text over a gradient at all, and a person cannot promise that every photo an editor picks will be dark enough behind the words.
The fix that satisfies both: put the text block on its own near-opaque band (a solid colour at about 80 percent alpha on the text's own wrapper), and lighten the full-bleed overlay above it so the photo shows through where there is no text. The headline now has a background a tool can measure (about 12:1) and a reader can rely on, and the hero looks the way it did.
The ones that did not make the top five
- A search field with only a placeholder. The placeholder disappears when you type and is not a name for assistive technology (3.3.2, 4.1.2). A visually hidden label, a
nameattribute and a visible submit button fixed it on both sites. - Links in guide text told apart by colour alone (1.4.1). One CSS rule underlining class-less links inside article content.
- Scrollable tables and strips that keyboard users could not reach (2.1.1).
tabindex="0",role="group"and a name on the scroll wrapper. Usegroup, notregion: two tables on one page become duplicate landmarks otherwise. - Two main landmarks on guide pages. A guide layout rendered its own
maininside the site's. One became adiv. - Tickers outside any landmark. Wrapped in a named section so no content sits outside the page structure.
- Autocomplete on a letters form (1.3.5).
autocomplete="name"and"email".
And one on our own site, found while verifying the fixes: a comparison table scrolls inside a wrapper, but the wrapper was not positioned, so the visually hidden labels inside the table (which are absolutely positioned) took their position from the page and sat 713 pixels out to the right. On a phone the whole page scrolled sideways to reach text nobody could see. position: relative on the scroll wrapper contained them. Measuring your own site is the only way to find that kind of thing.
What the tools got wrong
Honesty cuts both ways. Our probes reported a keyboard trap on a page with ten identical checkboxes and on long lists of similar article links, and a change of context on typing in the search field. An analyst checked each by keyboard: focus moved through the checkboxes and the lists without obstruction, and the search field changed nothing until Enter. All three were withdrawn before the reports were released, and the reports say so. Automated findings are evidence to be checked, not verdicts, which is why a person reviews every report before it goes out and why every report states what was and was not tested.
The method, if you want to repeat it
- Test every template and flow, not a sample of pretty pages, in a real browser at desktop and phone widths, with two rules engines.
- Measure: contrast per colour pair, target size per link, heading outline per page, document width at 390 pixels.
- Fix the cause, not the instance. One token, one base rule, one component prop, one wrapper.
- Re-run the same measurement on the live site after the deploy. The second round on these two sites found the CSS layer fault that the first round could not have seen, because the first round's fixes were being cancelled by it.
- Write down what was not tested. Zero automated violations is a true statement about the rules; it is not a statement about screen-reader usability, and the report should not pretend otherwise.
If you would rather have the measuring done for you, that is the WCAG findings report: every page in scope tested the same way, every criterion assessed with remarks, a remediation plan ordered by user impact, reviewed by an accessibility analyst before release.
Sources
- 1.W3C, Understanding Success Criterion 1.4.3: Contrast (Minimum)
- 2.W3C, Understanding Success Criterion 2.5.8: Target Size (Minimum)
- 3.W3C, Understanding Success Criterion 1.3.1: Info and Relationships
- 4.W3C, Understanding Success Criterion 1.4.1: Use of Color
- 5.W3C, Understanding Success Criterion 2.1.1: Keyboard
- 6.W3C, Understanding Success Criterion 3.3.2: Labels or Instructions
- 7.MDN Web Docs, @layer (CSS cascade layers)
- 8.Tailwind CSS, Adding custom styles: using cascade layers
- 9.Deque Systems, axe-core rules
- 10.Conformance Desk, Methodology: what is tested and what is not
Questions this article answers
Why would text colour classes on links not apply in Tailwind 4?
Tailwind 4 puts its utilities in CSS cascade layers. A plain rule written outside any layer, such as a { color: inherit }, beats every layered rule regardless of specificity, because unlayered styles always win over layered ones. Move the base rule into @layer base and the utilities win again.
Does a gradient overlay make white text over a photo pass contrast?
Not reliably, and no tool can measure it. axe reports text over a gradient or an image as needing review, or, if the overlay is a sibling rather than an ancestor of the text, measures the text against the page colour and fails it. Put the text block on its own near-opaque background and both the reader and the tool get a real number.
What is the minimum target size in WCAG 2.2?
Success criterion 2.5.8 (Level AA) asks for pointer targets of at least 24 by 24 CSS pixels, with exceptions for targets that have enough spacing around them, inline links within a sentence, targets whose size is set by the browser, and cases where a particular size is essential. A 20-pixel-tall footer link in a tight column fails; the same link with four pixels of vertical padding passes.
Is skipping a heading level a WCAG failure?
Not by itself. WCAG 1.3.1 requires that structure conveyed visually be available programmatically, and 2.4.6 requires descriptive headings; neither forbids an h3 after an h1. axe reports it as a best-practice rule because a skipped level is the commonest sign that a heading was chosen for its size, not its place in the outline, and screen-reader users navigate by that outline.
How did you get both sites to zero violations?
Two rounds. The first fixed what the report listed: colours, the search form, link sizes, landmarks, heading levels, scrollable regions, autocomplete. A live axe pass after that deploy showed what the first round had not reached, including the unlayered CSS rule; the second round fixed those. Zero violations on every tested page means the automated rules found nothing; the report still states that manual assistive-technology testing was not part of the method.