web-accessibility — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited web-accessibility (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Applies to: Any website or web app | Updated: March 2026
A practical reference for building accessible websites - covering WCAG 2.2 criteria, semantic HTML, ARIA, forms, touch, and testing.
Answer these questions before generating accessibility code. Each answer changes which sections apply.
Q: Where are your users? Default: worldwide - EU/UK requirements (EAA, PSBAR) will be flagged as applicable throughout this guide. Options: US only | Europe/EU | UK | Worldwide
Q: What kind of product is this? Default: company/marketing website Options: Company/marketing website | Online shop/e-commerce | Web app/SaaS | Media/video site | Document tool
Q: Will people use this on mobile phones or tablets as well as desktop? Default: yes - touch target sizing and pointer accessibility sections apply.
Q: Are you building with a component library? Default: none Options: Radix UI | shadcn/ui | MUI | Chakra UI | Other | None - if a library is detected in package.json, note which accessibility features it handles natively so you don't duplicate them.
Q: Do you need to comply with a specific standard for legal or contract reasons? Default: WCAG 2.2 AA - current best-practice baseline. Options: WCAG 2.1 AA | WCAG 2.2 AA | Section 508 | EN 301 549 | Not sure
AI assistant: Use these answers to prioritize. For EU products: apply EAA compliance notes. For mobile: apply touch target and pointer sections. If a known accessible component library is detected, note which patterns it handles so you don't duplicate effort.
Accessibility is a legal requirement across most major markets. Non-compliance carries real risk - fines, market access restrictions, and litigation.
| Jurisdiction | Law / Standard | Scope | Notes |
|---|---|---|---|
| EU | European Accessibility Act (EAA) | Private-sector e-commerce, banking, transport, streaming. Enforceable since June 28, 2025. | Standard: EN 301 549 v3.2.1, which references WCAG 2.1 AA; WCAG 2.2 strongly recommended. Non-compliance = market access restrictions. |
| US | ADA Title III | Private-sector websites and apps | ~4,000 - 4,500 lawsuits/year; e-commerce and hospitality are primary targets. Overlay-only solutions have been named in lawsuits as insufficient. |
| US Federal | Section 508 | Federal agencies and contractors | References WCAG 2.0 AA. Agencies procuring software must meet this baseline. |
| UK | Web Accessibility Regulations 2018 + Equality Act 2010 | Public sector: WCAG 2.2 AA required. Private sector: Equality Act applies. | PSBAR (Public Sector Bodies Accessibility Regulations) requires a published accessibility statement. |
Overlay warning: Single-script accessibility overlays (AccessiBe, UserWay, etc.) do not achieve WCAG conformance and have been directly challenged in ADA litigation. Flag this clearly if a user asks about "automatic" or "one-line" accessibility fixes.
WCAG (Web Content Accessibility Guidelines) uses three conformance levels:
alt)Target WCAG 2.2 Level AA as your default baseline.
On WCAG 3.0: It is a research-stage Working Draft with no published adoption timeline. It uses a different scoring model and will not replace WCAG 2.x for the foreseeable future. Do not use it as a current compliance target.
WCAG 2.2 was published October 2023. These criteria are not in most existing guides or tools. They represent the highest-value additions to implement.
The focus indicator must meet two conditions simultaneously:
A 1px dotted border fails both. This goes beyond "don't remove outline."
/* Fails 2.4.11 - thin, low-contrast */
:focus {
outline: 1px dotted #999;
}
/* Passes 2.4.11 - thick, high-contrast */
:focus-visible {
outline: 3px solid #005fcc; /* Check #005fcc vs background meets 3:1 */
outline-offset: 2px;
}Any interaction that uses drag-and-drop, slider, or map pan must have a single-pointer (click/tap) alternative. Drag is not a safe assumption - users with motor disabilities cannot rely on it.
<!-- Sortable list: provide up/down buttons alongside drag handles -->
<li>
<span class="drag-handle" aria-hidden="true">⠿</span>
Item label
<button aria-label="Move item up">↑</button>
<button aria-label="Move item down">↓</button>
</li>Interactive targets must be at least 24×24 CSS pixels, or have spacing such that the total clickable zone (target + surrounding space) reaches 24×24.
Best practice: 44×44px (Apple HIG), 48×48px (Material Design).
/* Minimum compliant - 24×24px */
.icon-btn {
min-width: 24px;
min-height: 24px;
}
/* Best practice - 44×44px tap target */
.icon-btn {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}Login and signup flows cannot rely solely on a cognitive test (e.g. image CAPTCHA with no alternative). Acceptable alternatives: passkeys, magic link via email, CAPTCHA with an audio alternative, or copy-paste support in the password field.
Do not block clipboard paste in password fields - this breaks password managers and fails this criterion.
Multi-step forms must not ask for the same information twice unless it is essential to re-enter. Auto-populate or display previously entered data.
<!-- Step 2: shipping address - if billing address was entered in step 1 -->
<label>
<input type="checkbox" id="same-as-billing" />
Same as billing address
</label>If a help mechanism (chat widget, phone number, help link, contact form) appears on more than one page, it must appear in the same relative location on every page.
WCAG 2.2 removed criterion 4.1.1 Parsing. Modern browsers reliably handle malformed HTML; it no longer represents an accessibility barrier. Automated tools that still report 4.1.1 violations are reporting against a deprecated criterion. Remove it from test baselines if you're targeting WCAG 2.2.
Semantic HTML is the highest-value accessibility improvement - it is free, requires no ARIA, and works with all assistive technologies.
<html lang="en"> <!-- Always declare language; use lang="fr" etc. for other languages -->
<head>
<title>Page Title - Site Name</title> <!-- Unique, descriptive title per page -->
</head>
<body>
<header>
<nav aria-label="Main">...</nav>
</header>
<main> <!-- One <main> per page - screen readers jump here -->
<h1>Page Heading</h1>
<section aria-labelledby="section-heading">
<h2 id="section-heading">Section Title</h2>
</section>
</main>
<aside aria-label="Related links">...</aside>
<footer>...</footer>
</body>For inline content in a different language, add the lang attribute on the element - screen readers use this to switch pronunciation:
<p>The French say <span lang="fr">bonjour</span> as a greeting.</p>Native HTML elements carry built-in keyboard support, roles, and states. Prefer them over custom implementations. Use <button> for anything that triggers an action, <a href> for navigation - never a <div onclick>.
One <h1> per page; don't skip heading levels. Use headings to convey document structure, not visual size.
All interactive elements must be reachable and operable with a keyboard alone. Users navigate with Tab, Shift+Tab, Enter, Space, and arrow keys.
Provide a "skip to main content" link as the first focusable element - lets keyboard users bypass repeated navigation:
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- nav, header -->
<main id="main-content">...</main>.skip-link {
position: absolute;
top: -100%;
left: 0;
}
.skip-link:focus {
top: 0; /* Visible on focus, hidden otherwise */
}Focus must follow a logical order - top-to-bottom, left-to-right, matching visual layout. Never use tabindex values greater than 0; they override the natural tab order and are nearly impossible to maintain.
<!-- Correct: tabindex="0" adds non-interactive elements to tab order -->
<div tabindex="0" role="region" aria-label="Interactive map">...</div>
<!-- Wrong: breaks tab order across the page -->
<button tabindex="3">...</button> <!-- Do not do this -->| Component | Expected keys |
|---|---|
| Button | Enter, Space to activate |
| Link | Enter to follow |
| Checkbox | Space to toggle |
| Radio group | Arrow keys to move between options |
| Dropdown/Select | Arrow keys to navigate, Enter to select, Escape to close |
| Dialog/Modal | Escape to close; focus trapped inside while open |
| Tabs | Arrow keys to switch tabs |
| Accordion | Enter/Space to expand/collapse |
ARIA (Accessible Rich Internet Applications) adds semantic meaning when native HTML elements are insufficient. Use it as a last resort.
Do not use ARIA if a native HTML element already has the semantics you need.
Use aria-label when there is no visible text label:
<button aria-label="Close dialog">✕</button>
<!-- Multiple navs on the same page need distinguishing labels -->
<nav aria-label="Main">...</nav>
<nav aria-label="Breadcrumb">...</nav>aria-label text must match or begin with the visible button text (WCAG 2.5.3 Label in Name) - otherwise voice control users who say "click Close" cannot activate it.
Use aria-labelledby to reference existing visible text, which avoids duplication:
<section aria-labelledby="billing-heading">
<h2 id="billing-heading">Billing</h2>
</section>Points to supplementary description - announced after the element's name and role:
<input type="password" id="pw" aria-describedby="pw-hint" />
<p id="pw-hint">Must be at least 8 characters with one number.</p>Toggle between "true" and "false" as the element opens and closes:
<button aria-expanded="false" aria-controls="menu">Menu</button>
<ul id="menu" hidden>...</ul>Hides decorative elements from screen readers. Never apply to focusable elements - a hidden-but-focusable element creates an invisible, unannounced interactive target:
<span aria-hidden="true">→</span> <!-- Decorative arrow -->Announces dynamic content updates. Use polite for the vast majority of cases - it waits until the screen reader finishes the current sentence before reading the update. Reserve assertive for genuine, time-critical errors that require interrupting the user immediately:
<!-- Polite: search results count, cart updates, status messages -->
<div aria-live="polite" aria-atomic="true" id="status"></div>
<!-- Assertive: session timeout warnings, payment failures - use sparingly -->
<div aria-live="assertive" id="critical-error"></div>aria-atomic="true" re-reads the entire region when any part changes, rather than just the changed portion. Use it when partial announcements would be confusing (e.g. "3 results" vs "Showing 3 results for 'shoes'").
<div role="alert">Error: email is required.</div> <!-- Implies aria-live="assertive" -->
<div role="status">3 results found.</div <!-- Implies aria-live="polite" -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Delete</h2>
</div>Every <img> must have an alt attribute:
<!-- Informative: describe what the image conveys -->
<img src="chart.png" alt="Bar chart showing 40% increase in Q4 sales" />
<!-- Decorative: empty alt so screen readers skip it -->
<img src="divider.png" alt="" />
<!-- Functional (inside a link or button): describe the action -->
<a href="/home"><img src="logo.svg" alt="Go to homepage" /></a>
<!-- Never omit alt - screen readers fall back to the filename -->
<img src="hero-image-v2-final.jpg" /> <!-- Wrong --><!-- Decorative -->
<svg aria-hidden="true" focusable="false">...</svg>
<!-- Meaningful -->
<svg role="img" aria-label="Upload file">...</svg><track kind="captions">)<video controls>
<source src="video.mp4" type="video/mp4" />
<track kind="captions" src="captions.vtt" srclang="en" label="English" default />
</video>Forms are one of the most common accessibility failure points.
Every input needs a visible, associated label. Never use placeholder as a label - it disappears on input and typically fails contrast requirements:
<!-- Explicit association via for/id -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email" />
<!-- Implicit association (label wraps input) -->
<label>
Email address
<input type="email" name="email" autocomplete="email" />
</label>Add autocomplete tokens to personal data fields. This enables password managers, browser autofill, and assistive technology for users with cognitive disabilities:
<input type="text" name="fname" autocomplete="given-name" />
<input type="text" name="lname" autocomplete="family-name" />
<input type="email" name="email" autocomplete="email" />
<input type="tel" name="phone" autocomplete="tel" />
<input type="password" name="password" autocomplete="current-password" />
<input type="password" name="new-pw" autocomplete="new-password" />For native <input> elements, the required attribute is sufficient - it is announced by screen readers and triggers native validation. Add aria-required="true" only for custom components that don't use native form elements:
<!-- Native input: required alone is sufficient -->
<label for="name">Full name <span aria-hidden="true">*</span></label>
<input type="text" id="name" required />
<p>Fields marked <span aria-hidden="true">*</span> are required.</p>
<!-- Custom component (e.g. a styled combobox): aria-required needed -->
<div role="combobox" aria-required="true" ...>...</div>Link errors to their input using aria-describedby. Set aria-invalid="true" when an error exists; remove it when the value becomes valid:
<label for="email">Email</label>
<input type="email" id="email" aria-describedby="email-error" aria-invalid="true" />
<p id="email-error" role="alert">Enter a valid email address.</p>Group related inputs (radio buttons, checkboxes) with <fieldset> and <legend> - the legend is announced before each option, providing essential context:
<fieldset>
<legend>Preferred contact method</legend>
<label><input type="radio" name="contact" value="email" /> Email</label>
<label><input type="radio" name="contact" value="phone" /> Phone</label>
</fieldset>Don't ask for the same data twice. Pre-populate fields using previously entered values. Show a summary of entered data before the final submit step.
Interactive targets must be at least 24×24 CSS pixels. Best practice is 44×44px:
/* Best practice: 44×44px tap target, even for small visual elements */
.btn-icon {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Touchscreen-specific adjustments */
@media (pointer: coarse) {
.btn-icon {
min-width: 48px;
min-height: 48px;
}
}pointer: coarse targets touchscreens and devices where the pointing device has limited accuracy. Use it for any touch-specific spacing or sizing overrides.
Tooltips, sub-menus, and dropdowns that appear on hover or focus must meet three conditions:
/* Wrong: tooltip disappears when pointer leaves trigger */
.trigger:hover .tooltip { display: block; }
/* Correct: tooltip stays open when pointer moves into it */
.trigger:hover .tooltip,
.tooltip:hover {
display: block;
}Also expose tooltip content via keyboard/focus - not hover alone.
Color alone must never be the only way to convey information - always pair color with text, icons, or patterns.
| Text type | Minimum ratio |
|---|---|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 |
| Large text (≥ 18pt / ≥ 14pt bold) | 3:1 |
| UI components and graphics | 3:1 |
| Decorative text, disabled UI | No requirement |
<!-- Wrong: color alone marks the error -->
<input style="border-color: red" />
<!-- Correct: color + icon + text -->
<input style="border-color: red" aria-describedby="err" />
<p id="err">Email is required.</p>Semi-transparent colors for text are unreliable for contrast compliance. The effective contrast of rgba(R,G,B,0.5) depends on every background layer beneath it, including parent elements, stacked components, and page backgrounds. You cannot evaluate the contrast of a semi-transparent color in isolation.
The problem: rgba(16,185,129,0.5) (50% emerald-500) on white resolves to approximately #87DCCA, which has a contrast ratio around 1.9:1 against white. On a dark background the same token may pass. The color looks "present" visually but fails silently.
Rule: Never use semi-transparent rgba() values for text content. Use fully opaque color tokens instead. If you need a "muted" appearance, pick a specific opaque value and measure its contrast directly.
/* Wrong: contrast is unpredictable across backgrounds */
color: rgba(16, 185, 129, 0.5);
color: rgba(239, 68, 68, 0.5);
/* Correct: fully opaque, measurable value */
color: #059669; /* emerald-600, 4.5:1 on white */
color: #dc2626; /* red-600, 5.9:1 on white */A color that passes contrast on one background will fail on another. This is the most common real-world failure pattern in multi-section layouts where sections use different background colors.
Example: slate-500 (#64748B)
Rule: When a color token is used in more than one context, verify contrast against each background it appears on. Do not assume a token that passes on white will pass on tinted section backgrounds.
Measured contrast ratios for slate shades against common backgrounds. AA requires 4.5:1 for normal text, 3:1 for large text and UI components.
| Slate shade | Hex value | On white #FFFFFF | On slate-50 #F8FAFC | On slate-100 #F1F5F9 |
|---|---|---|---|---|
| slate-400 | #94A3B8 | 2.85:1 - FAIL | 2.65:1 - FAIL | 2.45:1 - FAIL |
| slate-500 | #64748B | 4.48:1 - FAIL (AA) | 4.17:1 - FAIL | 3.86:1 - FAIL |
| slate-600 | #475569 | 6.32:1 - PASS | 5.88:1 - PASS | 5.45:1 - PASS |
| slate-700 | #334155 | 9.52:1 - PASS | 8.86:1 - PASS | 8.20:1 - PASS |
| slate-800 | #1E293B | 13.3:1 - PASS | 12.4:1 - PASS | 11.5:1 - PASS |
| slate-900 | #0F172A | 17.4:1 - PASS | 16.2:1 - PASS | 15.0:1 - PASS |
Key takeaways:
slate-400 and slate-500 fail AA for normal text on all common backgrounds. Use them only for decorative, non-informational content or large text (3:1 threshold).slate-600 is the minimum safe value for normal text on white and light tinted backgrounds.Bright brand colors are optimized for visual identity, not readability. Saturated greens, teals, and ambers that look bold and confident often fail AA contrast for normal-sized text, particularly on light backgrounds.
Example: emerald-500 (#10B981)
Pattern: two tokens for one brand color
Define separate tokens for text use and decorative use. Never use the decorative/icon token on body-size text without checking contrast.
/* Token pair for a brand green */
--color-brand-decorative: #10B981; /* emerald-500: icons, borders, backgrounds, large headings */
--color-brand-text: #059669; /* emerald-600: inline text, small labels - 4.5:1 on white */// Wrong: brand green on small text
<span className="text-emerald-500">Status: Active</span> {/* 3.8:1 on white - FAIL */}
// Correct: darker shade for text use
<span className="text-emerald-600">Status: Active</span> {/* 4.5:1 on white - PASS */}
// Decorative use (icon, badge background) - emerald-500 is fine here
<span className="bg-emerald-500 text-white rounded px-2">Active</span>Check every brand color at the size and background where it actually appears. "It looks bold" is not a contrast measurement.
Never remove focus outlines without a replacement. WCAG 2.4.11 (new in 2.2) requires a minimum 2px perimeter area and 3:1 contrast between focused and unfocused states:
/* Wrong: removes indicator for everyone */
* { outline: none; }
/* Correct: styled focus that meets 2.4.11 */
:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
border-radius: 2px;
}Use :focus-visible rather than :focus - it shows the indicator for keyboard users but suppresses it for mouse clicks, matching user expectations.
When a dialog opens, move focus into it. When it closes, return focus to the trigger element. Trap focus within the dialog while it is open:
const trigger = document.getElementById('open-dialog-btn');
const dialog = document.getElementById('dialog');
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function openDialog() {
dialog.removeAttribute('hidden');
dialog.querySelector(focusableSelectors).focus();
}
function closeDialog() {
dialog.setAttribute('hidden', '');
trigger.focus(); // Return focus to what opened the dialog
}When content updates dynamically (search results, filtered lists, notifications), announce it or move focus:
// Option A: announce via live region (no focus move - least disruptive)
statusEl.textContent = `${results.length} results found.`; // aria-live="polite" on statusEl
// Option B: move focus to the new content region
resultsContainer.setAttribute('tabindex', '-1');
resultsContainer.focus();Some users experience vestibular disorders triggered by motion. Always respect the OS-level reduced-motion preference.
The prefers-reduced-motion media query and the 5-second animation threshold in WCAG 2.2.2 apply specifically to auto-playing, blinking, or scrolling content that runs in parallel with other content (carousels, looping animations, auto-advancing banners). Short CSS transitions (button hover states, accordion expand) are not subject to the 5-second rule.
/* Apply animations by default */
.animated {
transition: transform 0.3s ease;
}
/* Remove or reduce motion when the user has requested it */
@media (prefers-reduced-motion: reduce) {
.animated {
transition: none;
}
}Provide pause/stop controls for any auto-playing animation or carousel. Do not autoplay video or audio with sound.
No single tool catches everything. Use this order - each layer catches what the previous misses.
Run first - fast and catches obvious violations. Automated tools catch approximately 30 - 40% of WCAG issues.
Accessibility tree inspection: Chrome DevTools > Elements panel > Accessibility tab shows the computed accessible name, role, and state for any element. Firefox has a dedicated Accessibility Inspector panel. Use these to debug unexpected announcements - more diagnostic than running axe alone.
Tab through every interactive flow without a mouse:
Updated pairings (as of 2026):
| Screen reader | Platform | Browser | Free? |
|---|---|---|---|
| NVDA | Windows | Chrome (primary), Firefox | Yes |
| JAWS | Windows | Chrome | No - most common enterprise SR |
| Narrator | Windows | Edge | Yes (built-in) |
| VoiceOver | macOS / iOS | Safari | Yes (built-in) |
| TalkBack | Android | Chrome | Yes (built-in) |
Basic test: navigate by headings (H in NVDA/JAWS), by landmarks (D), and by form elements (F). Verify every announcement makes sense without visual context.
Apply the WCAG 1.4.12 text spacing bookmarklet (sets line-height: 1.5, letter-spacing: 0.12em, word-spacing: 0.16em, paragraph-spacing: 2em). No content should overlap or be clipped.
Bookmarklet source: stevefaulkner's text spacing bookmarklet
Flag these actively when encountered in code review or generation:
<input type="date">.required.<html lang> set correctly; lang attribute on inline foreign-language content<title> on every page<h1> per page; no skipped heading levels<main>, <nav>, <header>, <footer>, <aside><img> have alt (empty for decorative)aria-label or aria-hiddentabindex values greater than 0prefers-reduced-motion respectedautocomplete tokens (name, email, address, card details)aria-live or focus management@media (pointer: coarse) rules for touchscreen spacing~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.