OffOn is a platform for open source enthusiasts. We want everyone to be able to read, browse, and contribute, regardless of disability, assistive technology, or device. This document explains what we support today, how we test, and how to tell us when we get it wrong.
- Target: WCAG 2.2 Level AA across every page on offon.dev, with selected WCAG 2.2 Level AAA criteria applied wherever practical. AA is the floor, not the goal.
- Both color modes: light and dark mode must meet contrast and focus requirements. We do not ship a feature that only works in one mode.
- Keyboard first: every interactive element is reachable and operable from the keyboard alone.
- No motion traps: we honor
prefers-reduced-motionand avoid auto-playing animation that the user did not request.
The following WCAG 2.2 Level AAA criteria are actively targeted on this site:
| Criterion | What we do |
|---|---|
| 1.4.6 Contrast (Enhanced) | Body text targets 7:1; large text targets 4.5:1 in both modes. |
| 2.4.9 Link Purpose (Link Only) | Every link must make sense without surrounding context. Card links use aria-label with the specific item name. Ambiguous text like "via email" is rewritten or wrapped in a descriptive label. |
| 2.4.10 Section Headings | Headings are used to organize all content sections, including sidebar sub-sections which use <h3>. |
| 2.4.13 Focus Appearance (Enhanced) | Focus indicators target a minimum 2px perimeter, ≥3:1 contrast against the unfocused state, and a minimum enclosed area. Verified by full keyboard traversal in e2e/a11y.spec.ts (dark and light modes). |
| 3.1.3 Unusual Words | Technical terms (e.g. devcontainer) are defined on first use via <abbr title="…"> or inline expansion. |
| 3.1.4 Abbreviations | Abbreviations are expanded on first use per page: <abbr title="…"> for inline HTML; written out in full for plain-text contexts (e.g. "Site Reliability Engineers (SREs)"). |
| 3.1.5 Reading Level | General copy targets plain language. Technical content is inherent to the subject; abbreviations and unusual words are expanded on first use. Challenge-specific content is authored by contributors and may be technical by nature. |
- Skip-to-content link as the first focusable element on every page.
- Visible focus rings on all interactive elements, in both light and dark mode.
- Semantic landmarks: one
<main id="main-content">, plus<nav>,<header>,<footer>,<section>, and<article>where appropriate. The site-wide<nav aria-label="Main">inNavbar.astrois wrapped in<header>so it provides the banner landmark. Never place the primary nav outside a<header>element. - Heading rules: one
<h1>per page with no skipped heading levels. Never place a heading element (<h1>-<h6>) inside a<summary>element (which hasrole="button"); the ARIA spec forbids heading semantics in a button role. Use a screen-reader-only heading before<details>for document outline, and a<span>inside<summary>for the visible label. - One
<h1>per page with no skipped heading levels (see heading rules above). - Meaningful
alttext on informational images, emptyalt=""paired witharia-hidden="true"on decorative ones. - Screen reader announcement of links that open in a new tab.
- Color contrast verified at 7:1 for body text and 4.5:1 for large text (both WCAG AAA), and 3:1 for UI controls, in both modes.
- Tested with axe-core on every pull request preview, in both light and dark mode.
- Self-hosted fonts so users on restricted networks are not locked out.
- Google Analytics is opt-in only via the consent banner. No tracking runs until the user accepts.
- Modern evergreen browsers: Chrome, Edge, Firefox, Safari (current and previous major versions).
- Mobile web on iOS Safari and Android Chrome.
- Screen readers we test against during manual spot checks: VoiceOver on macOS and iOS, NVDA on Windows.
- The site is fully static and served from GitHub Pages, so it works without JavaScript for reading content. Some interactive features (theme toggle, consent banner, filtering) require JavaScript.
- We do not currently provide captions or transcripts because the site does not host video or audio. If we add media, captions and transcripts will ship with it.
- The community discussion content is hosted on a separate Discourse instance and follows its own accessibility status.
If you find a barrier that is not listed here, please report it using the link below. We treat this list as evidence-based, not aspirational.
All automated checks run in e2e/a11y.spec.ts against the production build via Playwright on every pull request. The PR preview workflow blocks on these scans.
| Check | WCAG | Notes |
|---|---|---|
| axe-core (dark mode) | Full tag set: wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa, best-practice |
Never reduce this tag set. |
| axe-core (light mode) | same | .light class set via localStorage before navigation. |
| axe-core (forced colors) | same minus color-contrast |
Emulates Windows High Contrast Mode. color-contrast excluded: emulation fires the media query but does not remap computed colors, producing false positives. |
| Touch target minimum size | 2.5.8 | Every non-inline interactive element in the viewport is ≥24×24px. |
| Focus ring traversal (dark + light) | 2.4.7, 2.4.13 | Tabs through every focusable element on every page; fails any element with no outline or box-shadow on :focus-visible. |
| Skip link | 2.4.1 | First Tab stop is the skip link; activating it moves focus to #main-content. Tested on a representative route sample, both on a direct load and after arriving by a real link click. The link-click cases matter: nothing may move focus on load, or the skip link stops being the first Tab stop on every in-site navigation while the direct-load tests still pass. |
| Keyboard trap detection | 2.1.2 | Tabs through every page; detects repeating focus patterns (cycle length 1–5) that exclude the page's first focusable element, indicating focus is stuck. |
| Context change on focus | 3.2.1 | Tabs through every page; fails if the URL changes after a Tab press (navigation triggered by focus). |
| Zoom/reflow | 1.4.10 | Viewport set to 384px (equivalent to 200% zoom on 768px); asserts no horizontal scrollWidth overflow. |
| Very small text | — | No visible text node below 10px (WAVE "very small text" threshold). |
Automated axe passes are necessary but not sufficient. Automated tools catch roughly 30–40% of real-world accessibility issues. Manual testing is required for every interactive component.
For every UI change, step through each persona below and verify the listed checks. These are the failure modes axe-core cannot catch. A change is not done until all six personas pass.
Simulates a user who navigates entirely by audio output.
- Spot-check the changed flow with VoiceOver (macOS/iOS) or NVDA (Windows). See the screen reader quick reference below.
- Every interactive element is announced with its role and accessible name.
- Dynamic content updates (filters, route changes, consent state) are announced without forcibly moving focus.
- Heading order matches the visual reading order with no skipped levels.
aria-current="page"is set on the active nav link, paired with a visible indicator (color change or underline). Never rely onaria-currentalone — it is invisible to sighted users.- No vague or duplicate link text ("view", "read more", "click here" alone).
Simulates a user who operates the page with Tab, Shift+Tab, Enter, Space, and arrow keys only.
- Tab through the entire changed flow without a mouse. Every interactive element is reachable.
- Tab order follows a logical visual reading order.
- No focus traps. Every trap-like UI (modal, dropdown) has a working Escape exit.
- Focus ring is visible on every interactive element in both light and dark mode.
- After dynamic changes (filter apply, modal close, route change), focus lands on a logical element — not reset to the top of the page.
- Skip link is the first focusable element and is visible on focus.
Simulates a user navigating with browser zoom at 200% and 400%.
- At 400% zoom: no horizontal scrolling required. Content reflows into a single column (WCAG 1.4.10).
- Text is not truncated or clipped at high zoom.
- No sticky/fixed elements that consume more than half the viewport height at 400%.
- Focus ring remains clearly visible at high zoom.
- Verify at 375px, 768px, and 1280px widths against the production build.
- Windows High Contrast Mode: interactive states (hover, focus, disabled) are visible. Do not rely solely on
background-coloror semi-transparent borders.
Simulates a user sensitive to clutter, inconsistent UI, and unpredictable behaviour.
- Page title clearly reflects where the user is in the site.
- Multi-step flows (e.g. challenge instructions) have a visible indication of progress or position.
- UI controls are consistent: the same action uses the same element and label across all pages.
- No auto-advancing UI (carousels, auto-dismiss toasts) that interrupts reading without user intent.
- CTA labels clearly state the outcome ("Start the Beginner level" not "Go").
- Error states identify the problem in plain language and suggest a fix — no technical "fail" messages.
Simulates a user for whom animations can trigger motion sickness.
- Every animation and transition in the changed code is gated by
@media (prefers-reduced-motion: no-preference)in CSS, or bywindow.matchMedia('(prefers-reduced-motion: reduce)')in JS. - No parallax or scroll-linked motion effects.
- No auto-playing animations triggered without user intent.
- No content that flashes more than three times per second (WCAG 2.3.1).
Simulates a user navigating under high cognitive load.
- System status is clear after every action: the user knows whether something succeeded, is loading, or failed.
- Consent and theme state are visually obvious at a glance (the banner or the toggle reflect the current state).
- No session timeouts on this static site — confirm no new timed behaviour has been introduced.
- The purpose of every CTA is unambiguous without surrounding context.
Use these commands to run through a changed flow without a full-session setup.
| Task | Keys |
|---|---|
| Toggle VoiceOver on/off | Cmd+F5 |
| Read next / previous item | VO+→ / VO+← |
| Navigate to next heading | VO+Cmd+H (and Shift to go back) |
| List all headings | VO+U, then select Headings |
| List all landmarks | VO+U, then select Landmarks |
| List all links | VO+U, then select Links |
| Activate a link or button | VO+Space |
| Enter / exit a web area | VO+Shift+↓ / VO+Shift+↑ |
| Stop speaking | Ctrl |
| Task | Keys |
|---|---|
| Toggle NVDA on/off | Ctrl+Alt+N (installer default) |
| Read next / previous item | ↓ / ↑ (browse mode) |
| Navigate to next heading | H (and Shift+H to go back) |
| Navigate to next landmark | D (and Shift+D to go back) |
| List elements dialog | NVDA+F7 |
| Activate a link or button | Enter |
| Toggle browse/focus mode | NVDA+Space |
| Stop speaking | Ctrl |
What to verify in every changed flow:
- Every interactive element is announced with its role (button, link, etc.) and accessible name.
- Dynamic content updates (state changes, filter results) are announced without forcibly moving focus.
- No phantom tab stops appear (e.g. from SVGs missing
focusable="false"or decorative elements missingaria-hidden). - Heading order matches the visual reading order.
If something on offon.dev blocks you or is hard to use, please tell us.
- Preferred: Open an accessibility issue. The form prompts for the page, your assistive technology, and severity, which helps us reproduce and prioritize.
- Email: offondev@gmail.com if you cannot or prefer not to use GitHub.
We aim to acknowledge accessibility reports within five working days and to provide a workaround or fix timeline in the same response.
| Severity | Definition |
|---|---|
| Critical | Blocks a user from completing a core task (reading content, navigating to a challenge, accepting consent). |
| High | Significant difficulty, but a workaround exists. |
| Medium | Inconsistent or annoying experience that does not block the task. |
| Low | Minor issue with minimal impact on usability. |
Every UI change must pass the checklist below before the PR is submitted. See AGENTS.md for project conventions.
Apply this to every component you write or modify.
- Normal text (under 18px / non-bold under 14px): minimum 7:1 (WCAG AAA).
- Large text (18px+ or bold 14px+): minimum 4.5:1 (WCAG AAA).
- UI components and focus indicators: minimum 3:1 against adjacent colors.
- Focus indicators (WCAG 2.4.11): the focus indicator area must be at least as large as a 2px perimeter outline of the component, and the focused/unfocused contrast ratio must be at least 3:1.
- Never use
hsl(41 100% 60%)(#ffc034yellow) as text in light mode. Fails contrast. - Never place text on
bg-primarywithout verifying light mode contrast. - Never use
opacity-*on an element that contains visible text. Use an explicit CSS color token instead (e.g.text-[hsl(var(--text-faint))]). - Always verify contrast in both light and dark mode.
- Never rely on color alone to convey meaning. Always pair with text, icon, or pattern.
- Hover state contrast in light mode:
hover:text-primaryresolves to amber (#ffc034) on a light surface, which fails contrast. Never usehover:text-primaryon its own. Usehover:text-foreground dark:hover:text-primaryso light mode gets a dark, accessible color and dark mode gets the amber accent. Apply the same logic to any interactive element whose hover color differs by mode. - Icon/indicator colors in light mode: CSS variables like
--difficulty-builderare set to a pale tint in light mode (hsl(85 48% 75%)) and will be near-invisible on light surfaces. Do not use these variables for icon foreground colors. Use a hardcoded accessible value (e.g.#15803dfor green) that passes contrast in both modes.
- Pattern:
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm. Inline elements:ring-offset-1. - Always use
ring-ring, neverring-primary/xx. - Hover states must not change layout properties (padding, border, font-weight, width). Use color and opacity only.
Use the correct keys for each control type:
| Control | Required keys |
|---|---|
| Button | Enter, Space |
| Link | Enter |
| Checkbox | Space to toggle |
| Radio group | Arrow keys to move; Space to select |
| Dialog | Escape to close; focus trapped inside while open |
| Tab widget | Arrow keys between tabs; Enter/Space to activate |
| Combobox | Arrow keys in list; Enter to select; Escape to collapse |
- Every interactive element must be reachable and operable via keyboard.
- Tab order must follow a logical reading order.
- Never remove focus outlines.
- Modals must trap focus while open and return focus to the trigger on close. Apply
inert(andaria-hidden="true"for older AT) to all direct<body>children except the modal host.Escapemust close the modal and restore focus to the trigger. Focus must move to the first interactive element on open. No modal component is currently installed; implement the contract directly rather than pulling in a library speculatively. - Dropdown menus must use the Disclosure pattern:
<button aria-expanded="false" aria-controls="submenu-id">toggling a<ul id="submenu-id" hidden>.Escapemust close the dropdown and return focus to the trigger. Hover-triggered dropdowns must not vanish immediately — use a CSStransition-delayof at least 200ms. aria-haspopuprule: only usearia-haspopupwhen the controlled element has one of the five ARIA popup roles:menu(ortrue),listbox,tree,grid,dialog. For a disclosure button controlling arole="group"panel,aria-haspopupmust be omitted entirely.aria-expanded+aria-controlsis the complete and correct pattern. Using anyaria-haspopupvalue on arole="group"disclosure misleads AT about the expected interaction. Source: WAI-ARIA APG Disclosure Button pattern and WAI-ARIA 1.2 spec.- Focus management when closing by navigation vs. Escape: closing a menu because the user pressed Escape must restore focus to the trigger (WAI-ARIA APG Disclosure Navigation keyboard table). Closing because a navigation link was clicked must NOT restore focus to the trigger — the router/page owns focus after navigation. Passing
restoreFocus: false(or equivalent) from the link'sonClickhandler prevents the trigger from stealing focus from the incoming page. Never conflate the two close paths. - Inert coverage: when an overlay or mobile menu opens, apply
inert(andaria-hidden="true"for older AT) to ALL direct children of<body>except the element hosting the overlay. Targeting only named landmarks (#main-content,footer) leaves the consent banner, skip-nav, and any other body-level sibling reachable by AT. Iteratedocument.body.childrenand exclude the overlay's nearest body-child ancestor. Source: W3C APG Modal Dialog pattern; MDNinertattribute. - Composite widgets (toolbars, radio groups, tab lists) must use roving tabindex so only one item is in the tab stop at a time and arrow keys move within the group. See WAI-ARIA APG: Roving tabindex.
- If sticky headers or floating elements exist, ensure focused elements are not fully hidden behind them (WCAG 2.4.12 Focus Not Obscured). Add to CSS:
:focus-visible { scroll-margin-top: var(--sticky-header-height, 4rem); }
- Interactive elements must have a minimum touch target of 24x24px (WCAG 2.5.8). Prefer 44x44px for primary actions.
- Never rely on padding alone when the visible element is smaller than 24px.
- All animations and transitions must respect
prefers-reduced-motion. - Wrap motion in
@media (prefers-reduced-motion: no-preference)in CSS, or checkwindow.matchMedia('(prefers-reduced-motion: reduce)')before triggering JS-driven animation. - Also respect
prefers-contrast: more(users requiring higher contrast) andforced-colors: active(Windows High Contrast Mode) in any component that communicates state through color or opacity alone. See the Windows High Contrast Mode section.
- Use the correct element for the job (
<button>for actions,<a>for navigation,<nav>,<main>,<header>,<footer>,<article>,<section>). - Never use a
<div>or<span>as an interactive element. - Never use
role="menu"orrole="menuitem"on site navigation. These roles put screen readers into application mode where arrow keys replace Tab, breaking web navigation expectations. Use native<a>elements inside<nav>instead. - One
<h1>per page. No skipped heading levels. - A
<section>becomes a landmark (role="region") only when it has an accessible name, and it then appears in the screen reader's landmarks menu. Name it only when it is a genuinely distinct region worth a navigation shortcut, and name it witharia-labelledbypointing to a real visible heading. Never invent a syntheticaria-labelstring that no one sees — a generically named region is noise in the landmarks menu. If a block has no heading and is not a distinct region, use a plain<div>, not a<section>. Name an<article>by its heading viaaria-labelledbythe same way. - Never apply overline/label typography (
text-sm uppercase tracking-widest) to a heading tag. If the text is a genuine section heading, give it heading-appropriate typography. If it is purely decorative, use<span>or<p>. - Never use a non-heading tag for text visually styled as a heading. Promote it to the correct heading level.
- Every page's primary content must live inside a single
<main id="main-content">. Do not split content across multiple<main>elements. <html lang="en">is set insrc/layouts/Layout.astro. Never remove or change it. If a page includes content in another language, addlangto that element.- Tables must include
<caption>oraria-label, and header cells must usescope="col"orscope="row".
- Every
<img>must have analtattribute. No exceptions. - Meaningful images:
altdescribes the content or purpose. - Decorative images:
alt=""ANDaria-hidden="true"together. - Never use
aria-hidden="true"when alt text is present. - Set explicit
widthandheighton every<img>to prevent layout shift.
- Every
<a target="_blank">must reference the shared new-tab hint witharia-describedby="new-tab-hint". A single hidden<span id="new-tab-hint" hidden>opens in a new tab</span>is rendered once inLayout.astro. Do not fold "opens in a new tab" into the link text oraria-label, and do not add a per-linksr-onlyspan — the hint is an accessible description, not part of the name (padding the name breaks voice control, WCAG 2.5.3 Label in Name). - Never render a non-navigable URL as a link. Loopback / localhost / single-label hosts (e.g.
http://localhost:8080/) must be plain text — on the deployed site a link there points at the visitor's own machine. The adventure generator (annotateExternalLinks) unwraps these automatically; in JSX, write them as text, not<a>.
- Every link's text must describe its destination. Never use "click here", "read more", or "more" alone as link text.
- When link text is ambiguous in isolation (e.g. "View" repeated on every card), add
aria-labelon the<a>to provide a full, descriptive accessible name. - Every
<a>element must have an accessible name (text content,aria-label, oraria-labelledby). An<a>with no accessible name is invisible to screen readers and cannot be operated by voice control. - When using
aria-labelon a link, include the visible link text in the label. Voice control users speak visible text to activate links; if the accessible name does not contain that text, the link cannot be activated by voice (WCAG 2.5.3 Label in Name).
- Decorative icons paired with visible text:
aria-hidden="true". - Icon-only interactive elements:
aria-labelon the parent, noaria-hidden. - Never use raw Unicode characters (
→,♥,✓) to convey meaning. - Decorative separators between pill segments: use an empty
<span aria-hidden="true" className="inline-block w-px h-3 bg-current opacity-40" />instead of a text character.
-
Always add
focusable="false"to inline<svg>elements. Without it, Internet Explorer and some Edge versions make every SVG tab-focusable, creating phantom tab stops. -
Decorative SVGs (icons next to text, or purely visual):
aria-hidden="true"andfocusable="false". No<title>. -
Meaningful SVGs conveying information without adjacent text (e.g. standalone infographics): add
role="img", a<title>as the first child with a uniqueid, andaria-labelledbypointing to thatid.<svg role="img" aria-labelledby="chart-title" focusable="false"> <title id="chart-title">Monthly active contributors: 142</title> ... </svg>
-
Never use
aria-labeldirectly on<svg>. Support across assistive technologies is inconsistent. Use the<title>+aria-labelledbypattern instead. -
For icons from
unplugin-icons(lucide set): always passaria-hidden={true}when the icon is decorative (next to visible text). For icon-only buttons, putaria-labelon the parent<button>or<a>, not on the<svg>. -
Brand SVGs (e.g. LinkedIn in
Footer.astro): setaria-hidden="true"on the<svg>andaria-labelon the parent interactive element. Usefill="currentColor"so hover and theme color changes apply. See the Icons section ofstyleguide.md.
- Only add ARIA attributes when semantic HTML is not enough.
- Never use ARIA to paper over bad markup. Fix the markup first.
- Use
role="status"(implicitaria-live="polite") for non-urgent updates like form success messages. - Use
role="alert"(implicitaria-live="assertive") only for errors requiring immediate attention. Never usearia-live="assertive"for informational updates. - Use
aria-expandedon toggles that open/close UI. Keep the toggle's accessible name constant across states: name it for what it controls (e.g.aria-label="Menu") and letaria-expandedcarry open/closed. Never swap the name between "Open menu" / "Close menu" — that announces the state twice and can contradictaria-expanded. - Always add
aria-labeloraria-labelledbyto icon-only buttons. - Never use the
titleattribute to convey information. It is not exposed on touch, is unreliable for keyboard users, and is announced inconsistently across screen readers. The only acceptable use istitleon<iframe>. Use visible text,aria-label, oraria-describedbyinstead. - Disabled controls: use native
disabledon form fields (<input>,<select>,<textarea>) — it correctly governs whether the value submits. For buttons, preferaria-disabled="true"over nativedisabledwhen the control must stay discoverable in the focus order (a submit button, or an inactive-but-important control), so keyboard and screen reader users can still find it and learn why it is unavailable. Do not blanket-replacedisabledwitharia-disabled. When you usearia-disabled, suppress the action in JavaScript and style the state explicitly (it gets no user-agent dimming). See the/keyboardcommand for detail. - Preference toggles (theme, consent): announce state changes to screen readers with a
role="status"live region. Clear the region then set new text after a 50ms delay so screen readers detect the change as a mutation:region.textContent = ''→setTimeout(() => { region.textContent = 'Theme switched to dark mode'; }, 50).
The tooltip uses a custom abbr[data-title] implementation — at build time, src/lib/markdown-pipeline.mjs rewrites <abbr title="..."> elements to use data-title attributes, and src/layouts/Layout.astro includes a position:fixed JS portal that displays the tooltip text on hover/focus, clamped to the viewport.
All tooltip implementations on this site must satisfy three conditions:
- Dismissible: pressing
Escapemust close the tooltip without moving keyboard focus. Theposition:fixedJS portal handles this via a keydown listener. CSS-only tooltips (.md-inline abbr) cannot dismiss onEscape; this is a known limitation of the CSS path. - Hoverable: the cursor must be able to move from the trigger onto the tooltip without the tooltip closing. The JS portal satisfies this. Never add
pointer-events: noneto a tooltip element that users are expected to read. - Persistent: the tooltip must remain open as long as pointer or focus is within its bounds.
- Every
<input>,<select>, and<textarea>must have an associated<label>viafor/idpairing oraria-label. - Never use placeholder text as a substitute for a label.
- Every page must have a skip link as the first focusable element targeting
#main-content. - The skip link uses the
.skip-navclass insrc/styles/index.css. Never remove this class or its focus rules. - When adding a new page, always add
id="main-content" tabIndex={-1}to its<main>element. WithouttabIndex={-1}, activating the skip link scrolls the page but does not move keyboard focus -- the link is broken for keyboard users in Chromium and Safari.
Wide <pre> blocks (code, ASCII diagrams, command output) that may overflow horizontally must be keyboard-reachable so users who cannot use a mouse can scroll them:
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- makes this scrollable block keyboard-reachable per WCAG 2.1 SC 2.1.1
<pre tabIndex={0} aria-label="Architecture diagram" className="overflow-x-auto ...">- Add
tabIndex={0}so the block enters the tab order. - Add
aria-labeldescribing the content type (e.g."Architecture diagram","Terminal output"). - Suppress the
no-noninteractive-tabindexlint rule with a comment citing WCAG 2.1 SC 2.1.1. - Do not add
role—<pre>has no implicit ARIA role, and adding one (e.g.role="region") would create a spurious landmark in the document outline.
Hidden until found
- For collapsible sections that should remain discoverable by browser find-in-page and screen readers, use
hidden="until-found"instead ofdisplay: noneorvisibility: hidden. The browser auto-expands the section when the user searches for content inside it. - Do not use
hidden="until-found"for content that must be genuinely hidden until an explicit interaction (e.g. a confirmation dialog). Usedisplay: nonefor those cases.
- Never install third-party JavaScript "accessibility overlay" widgets that claim to auto-remediate WCAG issues at runtime. They do not reliably fix issues, frequently create new barriers for screen-reader users, and are not a substitute for genuine compliance work.
-
Test all interactive components with forced colors enabled.
-
Never rely solely on
background-colororborder-colorwith opacity to communicate interactive state. -
Use
@media (forced-colors: active)to restore visible borders where needed:@media (forced-colors: active) { .your-component { border: 1px solid ButtonText; } }
- Minimum visible text size is 12px (
text-xs). Do not usetext-[0.6rem]or smaller for any visible text. - Avatar initials and rank numbers that are
aria-hiddenare exempt.
- All page content (including
PageHeroandBottomCTA) must be inside<main id="main-content">.
Use this to identify which criterion applies before writing or reviewing code.
| Principle | Criterion | Level | What to verify |
|---|---|---|---|
| Perceivable (1.x) | 1.1.1 Non-text Content | A | Every image, icon, and non-text element has a text alternative or aria-hidden. |
| 1.3.1 Info and Relationships | A | Structure conveyed visually is also in markup (headings, lists, tables). | |
| 1.4.3 Contrast | AA | Normal text 4.5:1, large text 3:1 against background. | |
| 1.4.4 Resize Text | AA | Text readable at 200% zoom. Never user-scalable=no. |
|
| 1.4.6 Contrast (Enhanced) | AAA | Body text 7:1, large text 4.5:1 in both modes. | |
| 1.4.10 Reflow | AA | No horizontal scroll at 400% zoom (320px viewport). | |
| 1.4.11 Non-text Contrast | AA | UI components and focus indicators: 3:1 against adjacent colors. | |
| 1.4.13 Content on Hover or Focus | AA | Tooltip content triggered by hover or focus must be dismissible (Escape), hoverable (cursor can move onto the tooltip without it closing), and persistent (stays open while hovered or focused). | |
| Operable (2.x) | 2.1.1 Keyboard | A | All functionality is keyboard-accessible. |
| 2.1.2 No Keyboard Trap | A | Focus is never permanently trapped; Escape exits modals and dropdowns. |
|
| 2.3.1 Three Flashes | A | No content flashes more than 3 times per second. | |
| 2.4.1 Bypass Blocks | A | Skip link is the first focusable element on every page. | |
| 2.4.3 Focus Order | A | Tab order follows logical reading order. | |
| 2.4.7 Focus Visible | AA | All focusable elements have a visible focus indicator. | |
| 2.4.9 Link Purpose (Link Only) | AAA | Every link is unambiguous without surrounding context. Card and CTA links use aria-label. |
|
| 2.4.10 Section Headings | AAA | Headings organize all content sections, including sidebar sub-sections. | |
| 2.4.11 Focus Appearance | AA | Focus indicator is at least 2px, with 3:1 contrast against adjacent colors. | |
| 2.4.12 Focus Not Obscured | AA | Focused elements are not fully hidden by sticky headers or overlays. | |
| 2.5.3 Label in Name | A | Accessible name contains the visible text (required for voice control). | |
| 2.5.8 Target Size Minimum | AA | Touch targets are at least 24×24px. | |
| Understandable (3.x) | 3.1.1 Language of Page | A | <html lang="en"> is set. |
| 3.1.3 Unusual Words | AAA | Technical terms defined on first use via <abbr title="…"> or inline expansion. |
|
| 3.1.4 Abbreviations | AAA | Abbreviations expanded on first use per page. | |
| 3.1.5 Reading Level | AAA | General copy targets plain language; technical terms expanded on first use. | |
| 3.2.1 On Focus | A | Focusing an element must not trigger a context change (navigation, form submission, or any other automatic change). | |
| 3.3.1 Error Identification | A | Error messages identify the field and describe the error. | |
| 3.3.2 Labels or Instructions | A | Form fields have labels; placeholders are not substitutes. | |
| Robust (4.x) | 4.1.2 Name, Role, Value | A | ARIA roles and attributes are valid. Dynamic state (aria-expanded, aria-current) is kept in sync. |
Portions of this document are adapted from The Website Specification by Joost de Valk and contributors, licensed under CC BY 4.0. Changes have been made.
Structure and approach informed by ACCESSIBILITY.md by Mike Gifford and contributors, licensed under MIT.