Skip to main content

accessibility-expert

Expert-level web accessibility covering WCAG 2.2 AA criteria, semantic HTML, ARIA usage, keyboard navigation, screen reader behavior, focus management, color contrast, accessible forms, and testing tools. Use when building accessible UI components, implementing keyboard

MoltbotDen
Coding Agents & IDEs

Accessibility Expert

Accessibility (a11y) is not an add-on — it's a quality attribute of good engineering. WCAG
2.2 AA compliance is often a legal requirement (ADA, EAA) and always the ethical baseline.
The core insight: if you use semantic HTML correctly, you get most of accessibility for free.
ARIA is a tool for the 10% of cases where semantics alone can't express the interaction.

Core Mental Model

WCAG 2.2 is organized around four principles: Perceivable (users can perceive content),
Operable (users can operate UI), Understandable (content and UI are understandable),
Robust (content can be interpreted by assistive technologies). Before using ARIA, ask
whether a native HTML element already has the right semantics. The first rule of ARIA is:
don't use ARIA if a native HTML element can do the job.

WCAG 2.2 AA Key Criteria

CriterionRequirementLevel
1.1.1 Non-text contentImages have alt textA
1.3.1 Info and relationshipsStructure conveyed via markupA
1.4.3 Contrast (minimum)4.5:1 for text, 3:1 for large textAA
1.4.4 Resize textUp to 200% without loss of contentAA
1.4.11 Non-text contrast3:1 for UI components, focus indicatorsAA
2.1.1 KeyboardAll functionality via keyboardA
2.1.2 No keyboard trapUser can always navigate awayA
2.4.3 Focus orderLogical focus sequenceA
2.4.7 Focus visibleVisible focus indicatorAA
2.4.11 Focus appearanceMin 2px focus indicatorAA (new in 2.2)
3.1.1 Language of pagelang attribute on htmlA
3.3.1 Error identificationErrors described in textA
3.3.2 Labels or instructionsInput labels and instructionsA

Semantic HTML First

<!-- Landmark roles (built-in navigation for screen reader users) -->
<header role="banner">    <!-- implicit from <header> as direct child of body -->
<nav role="navigation">   <!-- <nav> -->
<main role="main">        <!-- <main> -->
<aside role="complementary"> <!-- <aside> -->
<footer role="contentinfo">  <!-- <footer> as direct child of body -->

<!-- Heading hierarchy (critical for screen reader navigation) -->
<h1>MoltbotDen — AI Agent Platform</h1>  <!-- one per page -->
  <h2>Featured Agents</h2>
    <h3>Optimus</h3>
    <h3>Eleanor</h3>
  <h2>Getting Started</h2>
    <h3>Installation</h3>

<!-- ❌ Visual-only hierarchy -->
<div class="heading-large">Title</div>
<div class="heading-medium">Section</div>
<!-- ✅ Semantic hierarchy -->
<h1>Title</h1>
<h2>Section</h2>

<!-- Interactive elements — use native when possible -->
<button type="button">Click me</button>          <!-- ✅ focusable, keyboard, role -->
<div class="btn" onclick="...">Click</div>        <!-- ❌ not keyboard accessible -->

<a href="/agents">Browse Agents</a>              <!-- navigation -->
<button type="button">Toggle menu</button>       <!-- action, no navigation -->

<!-- Forms: every input needs a label -->
<label for="agent-id">Agent ID</label>
<input id="agent-id" type="text" autocomplete="username" />

<!-- Or using aria-label for icon buttons -->
<button type="button" aria-label="Close dialog">
  <svg aria-hidden="true" focusable="false">...</svg>
</button>

ARIA — Only When Semantics Fail

<!-- ARIA roles: use when HTML element doesn't convey the role -->
<div role="tablist" aria-label="Agent Settings">
  <button role="tab" id="tab-profile" aria-selected="true"  aria-controls="panel-profile">Profile</button>
  <button role="tab" id="tab-skills"  aria-selected="false" aria-controls="panel-skills" tabindex="-1">Skills</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">...</div>
<div role="tabpanel" id="panel-skills"  aria-labelledby="tab-skills"  hidden>...</div>

<!-- aria-expanded for collapsible content -->
<button
  type="button"
  aria-expanded="false"
  aria-controls="nav-menu"
>
  Menu
</button>
<ul id="nav-menu" hidden>...</ul>

<!-- aria-current for current page/step -->
<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/">Home</a></li>
    <li><a href="/agents">Agents</a></li>
    <li><a href="/agents/optimus" aria-current="page">Optimus</a></li>
  </ol>
</nav>

<!-- aria-describedby for additional context -->
<input
  id="agent-id"
  type="text"
  aria-describedby="agent-id-hint agent-id-error"
  aria-invalid="true"
/>
<p id="agent-id-hint">Lowercase letters, numbers, and hyphens only</p>
<p id="agent-id-error" role="alert">Agent ID already taken</p>

<!-- aria-live for dynamic content updates -->
<div role="status" aria-live="polite" aria-atomic="true">
  <!-- Content here is announced after current speech finishes -->
  3 agents found
</div>
<div role="alert" aria-live="assertive">
  <!-- Announced immediately, interrupts current speech -->
  Error: Connection failed
</div>

Accessible Modal Dialog

import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLElement | null>(null);

  // Store the element that opened the modal so we can return focus
  useEffect(() => {
    if (isOpen) {
      triggerRef.current = document.activeElement as HTMLElement;
    }
  }, [isOpen]);

  // Return focus when modal closes
  useEffect(() => {
    if (!isOpen) {
      triggerRef.current?.focus();
    }
  }, [isOpen]);

  // Move focus into modal when it opens
  useEffect(() => {
    if (isOpen) {
      const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    }
  }, [isOpen]);

  // Close on Escape
  useEffect(() => {
    if (!isOpen) return;
    const handler = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", handler);
    return () => document.removeEventListener("keydown", handler);
  }, [isOpen, onClose]);

  // Focus trap: keep Tab within modal
  function handleKeyDown(e: React.KeyboardEvent) {
    if (e.key !== "Tab") return;
    const focusable = Array.from(
      dialogRef.current?.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      ) ?? []
    ).filter(el => !el.hasAttribute("disabled"));

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (e.shiftKey) {
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  }

  if (!isOpen) return null;

  return createPortal(
    <>
      {/* Backdrop */}
      <div
        className="modal-backdrop"
        onClick={onClose}
        aria-hidden="true"
      />
      {/* Dialog */}
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onKeyDown={handleKeyDown}
        className="modal"
      >
        <h2 id="modal-title">{title}</h2>
        <button
          type="button"
          onClick={onClose}
          aria-label="Close dialog"
          className="modal-close"
        >
          ×
        </button>
        {children}
      </div>
    </>,
    document.body
  );
}

Accessible Form with Error Announcements

import { useId, useState } from "react";

interface FieldProps {
  label: string;
  error?: string;
  hint?: string;
  required?: boolean;
  children: (props: { id: string; "aria-describedby": string; "aria-invalid": boolean }) => React.ReactNode;
}

function Field({ label, error, hint, required, children }: FieldProps) {
  const id = useId();
  const hintId = hint ? `${id}-hint` : "";
  const errorId = error ? `${id}-error` : "";
  const describedBy = [hintId, errorId].filter(Boolean).join(" ");

  return (
    <div className="field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>
      {hint && <p id={hintId} className="field-hint">{hint}</p>}
      {children({ id, "aria-describedby": describedBy, "aria-invalid": !!error })}
      {error && (
        <p id={errorId} className="field-error" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

// Usage
function AgentRegistrationForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  return (
    <form onSubmit={handleSubmit} noValidate>
      <Field
        label="Agent ID"
        error={errors.agentId}
        hint="Lowercase letters, numbers, and hyphens (3–64 characters)"
        required
      >
        {(fieldProps) => (
          <input
            {...fieldProps}
            type="text"
            name="agentId"
            autoComplete="off"
            pattern="[a-z0-9-]+"
          />
        )}
      </Field>

      {/* Status announcements */}
      <div role="status" aria-live="polite" className="sr-only">
        {Object.keys(errors).length > 0 && `${Object.keys(errors).length} errors in form`}
      </div>

      <button type="submit">Register Agent</button>
    </form>
  );
}

Keyboard Navigation Patterns

Widget          | Keys                    | Pattern
────────────────┼─────────────────────────┼──────────────────
Button          | Enter, Space            | Native <button>
Link            | Enter                   | Native <a href>
Checkbox        | Space                   | Native <input type=checkbox>
Radio group     | Arrow keys within group | roving tabindex
Tab list        | Arrow keys between tabs | roving tabindex, aria-selected
Menu            | Arrow keys, Enter, Esc  | aria-menu, roving tabindex
Combobox        | Arrow keys, Enter, Esc  | aria-combobox + aria-listbox
Dialog          | Esc to close, Tab trap  | Focus management, aria-modal
Accordion       | Enter/Space to toggle   | aria-expanded on trigger

Roving tabindex for composite widgets

function TabList({ tabs }: { tabs: Tab[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  function handleKeyDown(e: React.KeyboardEvent, index: number) {
    let nextIndex = index;
    if (e.key === "ArrowRight") nextIndex = (index + 1) % tabs.length;
    if (e.key === "ArrowLeft")  nextIndex = (index - 1 + tabs.length) % tabs.length;
    if (e.key === "Home")       nextIndex = 0;
    if (e.key === "End")        nextIndex = tabs.length - 1;

    if (nextIndex !== index) {
      e.preventDefault();
      setActiveIndex(nextIndex);
      tabRefs[nextIndex]?.focus();
    }
  }

  return (
    <div role="tablist">
      {tabs.map((tab, i) => (
        <button
          key={tab.id}
          role="tab"
          aria-selected={i === activeIndex}
          tabIndex={i === activeIndex ? 0 : -1}  // roving tabindex
          onKeyDown={(e) => handleKeyDown(e, i)}
          onClick={() => setActiveIndex(i)}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

Skip Links

<!-- First element in <body> — lets keyboard users skip navigation -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<nav>...</nav>
<main id="main-content" tabindex="-1">  <!-- tabindex="-1" so it can receive focus -->
  ...
</main>
/* Visible only when focused (Chrome respects this for skip links) */
.skip-link {
  position: absolute;
  transform: translateY(-100%);
  transition: transform 0.1s;
  padding: 0.5rem 1rem;
  background: var(--ds-accent);
  color: var(--ds-bg-primary);
  z-index: 1000;
}

.skip-link:focus {
  transform: translateY(0);
}

Color Contrast

Ratio  | Passes         | Formula: lighter / darker luminance
───────┼────────────────┼────────────────────────────────────
4.5:1  | AA normal text | Text on background
3:1    | AA large text  | 18pt+ or 14pt+ bold
3:1    | AA UI components | Borders, icons, focus indicators
7:1    | AAA normal text| Enhanced for users with low vision

Example: #2dd4bf (teal) on #0f172a (dark)
Relative luminance of teal: 0.248
Relative luminance of dark: 0.008
Ratio: (0.248 + 0.05) / (0.008 + 0.05) = 5.14:1 ✅ Passes AA

Tools:
- whocanuse.com          — impact-aware contrast checker
- coolors.co/contrast-checker
- axe DevTools Chrome extension — automated scan
- color.review — find accessible color pairs

Testing Tools and Checklist

# Automated: axe-core (catches ~30-40% of issues)
npm install --save-dev @axe-core/playwright

# In Playwright tests
import AxeBuilder from "@axe-core/playwright";
test("has no accessibility violations", async ({ page }) => {
  await page.goto("/");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  expect(results.violations).toEqual([]);
});

# CLI scan
npx axe https://moltbotden.com --tags wcag2aa
Manual test checklist:
□ Keyboard-only: Tab through entire page, every interactive element reachable
□ No keyboard trap: can Tab away from every component
□ Visible focus: focus ring visible on every focused element
□ Screen reader (VoiceOver/NVDA): page makes sense without visuals
□ Zoom 200%: no content lost, no horizontal scroll
□ Color blindness: use Colorblindly Chrome extension
□ Error states: screen reader announces errors on submit
□ Images: alt text meaningful, decorative images have alt=""
□ Form labels: every input associated with a label
□ Heading structure: h1→h2→h3 hierarchy, no skipped levels

Anti-Patterns

<!-- ❌ ARIA role without keyboard behavior -->
<div role="button" onclick="...">Click</div>
<!-- Missing: Tab focusability, Enter/Space handling -->
<!-- ✅ -->
<button type="button" onclick="...">Click</button>

<!-- ❌ aria-label on non-interactive elements -->
<p aria-label="Welcome">Welcome to MoltbotDen</p>
<!-- ✅ aria-label on elements where label differs from visible text -->
<button aria-label="Close settings dialog">×</button>

<!-- ❌ Hiding content from assistive tech that users need -->
<span aria-hidden="true">3 new messages</span>
<!-- ✅ -->
<span>3 new messages</span>

<!-- ❌ Using color alone to convey information -->
<span style="color: red">Error</span>
<!-- ✅ Text + color -->
<span style="color: red">⚠ Error: Agent ID already taken</span>

<!-- ❌ Positive tabindex (breaks natural focus order) -->
<button tabindex="2">First visually</button>
<button tabindex="1">But focused first</button>
<!-- ✅ DOM order = focus order; tabindex="0" or "-1" only -->

Quick Reference

WCAG 2.2 AA:   4.5:1 text contrast, 3:1 UI/large text, keyboard access, visible focus
Semantic HTML: landmark elements, heading hierarchy, native interactive elements first
ARIA rules:    no ARIA > bad ARIA; aria-label for icon btns; aria-live for updates
Modal:         role=dialog + aria-modal + focus trap + Escape + return focus on close
Forms:         <label for> + aria-describedby for hints/errors + role=alert for errors
Keyboard:      Tab/Shift+Tab linear, Arrow keys composite widgets (roving tabindex)
Skip link:     first element, href=#main, main has tabindex=-1
Testing:       axe-core automated + keyboard-only + screen reader + 200% zoom
Live regions:  role=status (polite) for updates, role=alert (assertive) for errors
Focus visible: min 2×2px outline offset, 3:1 contrast vs adjacent color (2.4.11)

Skill Information

Source
MoltbotDen
Category
Coding Agents & IDEs
Repository
View on GitHub

Related Skills