Claude
Skills
Sign in
Back

accessibility-journey

Included with Lifetime
$97 forever

Audit keyboard navigation by tabbing through a page, capturing focus state at each stop, and running axe-core for WCAG violations.

Security

What this skill does


# Accessibility Journey

Perform a complete keyboard navigation audit by tabbing through every focusable element on a page. At each tab stop, capture the focused element's properties, check for visible focus indicators, and record the tab order. Combine this with an axe-core WCAG audit for a comprehensive accessibility report.

## When to Use

- Auditing a page for WCAG 2.1 AA keyboard accessibility compliance
- Verifying that all interactive elements are reachable via Tab key
- Checking for missing focus indicators (outline, box-shadow) on focusable elements
- Detecting focus traps where keyboard users get stuck in a component
- Validating tab order matches the expected visual reading order
- Reviewing heading hierarchy and ARIA attribute correctness
- QA before accessibility certification or remediation planning

## Prerequisites

- Playwright MCP server connected with a browser session available
- Target page must be publicly accessible or already authenticated
- Page should be fully loaded (all interactive elements rendered)
- axe-core CDN must be reachable (or the page must already include axe-core)

## Workflow

### Step 1: Navigate to the Target Page

```
browser_navigate({ url: "https://example.com/page" })
```

Wait for the page to be ready:

```
browser_wait_for({ time: 3 })
```

### Step 2: Inject axe-core

Use `browser_evaluate` to load axe-core from CDN. This provides automated WCAG violation detection.

```javascript
browser_evaluate({
  function: `() => {
    return new Promise((resolve, reject) => {
      if (window.axe) {
        resolve('axe-core already loaded');
        return;
      }
      const script = document.createElement('script');
      script.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js';
      script.onload = () => resolve('axe-core loaded: v' + window.axe.version);
      script.onerror = () => reject('Failed to load axe-core from CDN');
      document.head.appendChild(script);
    });
  }`
})
```

### Step 3: Run axe-core Audit

Use `browser_evaluate` to execute the full axe-core scan. This runs asynchronously and returns all violations, incomplete checks, and passes.

```javascript
browser_evaluate({
  function: `() => {
    return axe.run().then(results => {
      return {
        violations: results.violations.map(v => ({
          id: v.id,
          impact: v.impact,
          description: v.description,
          helpUrl: v.helpUrl,
          nodes: v.nodes.length,
          targets: v.nodes.slice(0, 5).map(n => ({
            target: n.target.join(' '),
            html: n.html.substring(0, 120),
            failureSummary: n.failureSummary
          }))
        })),
        incomplete: results.incomplete.map(i => ({
          id: i.id,
          impact: i.impact,
          description: i.description,
          nodes: i.nodes.length
        })),
        violationCount: results.violations.length,
        incompleteCount: results.incomplete.length,
        passCount: results.passes.length
      };
    });
  }`
})
```

### Step 4: Initialize Tab Journey Tracker

Use `browser_evaluate` to set up the tracking data structure before starting the tab loop.

```javascript
browser_evaluate({
  function: `() => {
    window.__tabJourney = [];
    window.__tabStartTime = performance.now();

    // Reset focus to the beginning of the document
    document.body.focus();
    document.activeElement?.blur?.();

    return {
      activeElement: document.activeElement?.tagName || 'BODY',
      journeyInitialized: true
    };
  }`
})
```

### Step 5: Tab Loop -- Press Tab and Capture Focus State

Repeat the following cycle up to 50 times or until focus returns to `<body>` or wraps around to the first element.

**5a: Press Tab**

```
browser_press_key({ key: "Tab" })
```

**5b: Capture focused element details**

Use `browser_evaluate` to inspect `document.activeElement` and check for focus indicator visibility.

```javascript
browser_evaluate({
  function: `() => {
    const el = document.activeElement;
    if (!el || el === document.body) {
      return { reachedBody: true, journeyLength: window.__tabJourney.length };
    }

    const rect = el.getBoundingClientRect();
    const style = getComputedStyle(el);
    const outlineStyle = style.outlineStyle;
    const outlineWidth = parseFloat(style.outlineWidth) || 0;
    const outlineColor = style.outlineColor;
    const boxShadow = style.boxShadow;

    const hasOutline = outlineStyle !== 'none' && outlineWidth > 0;
    const hasBoxShadow = boxShadow && boxShadow !== 'none';
    const hasFocusIndicator = hasOutline || hasBoxShadow;

    const stop = {
      index: window.__tabJourney.length,
      tag: el.tagName.toLowerCase(),
      role: el.getAttribute('role') || '',
      ariaLabel: el.getAttribute('aria-label') || '',
      ariaLabelledBy: el.getAttribute('aria-labelledby') || '',
      text: (el.textContent || '').trim().substring(0, 60),
      href: el.getAttribute('href') || '',
      type: el.getAttribute('type') || '',
      tabIndex: el.tabIndex,
      id: el.id || '',
      className: String(el.className || '').substring(0, 60),
      rect: {
        top: Math.round(rect.top),
        left: Math.round(rect.left),
        width: Math.round(rect.width),
        height: Math.round(rect.height)
      },
      focusIndicator: {
        hasOutline: hasOutline,
        outlineStyle: outlineStyle,
        outlineWidth: outlineWidth,
        outlineColor: outlineColor,
        hasBoxShadow: hasBoxShadow,
        visible: hasFocusIndicator
      },
      isOffscreen: rect.top < 0 || rect.left < 0 ||
                   rect.bottom > window.innerHeight || rect.right > window.innerWidth,
      timestamp: Math.round(performance.now() - window.__tabStartTime)
    };

    window.__tabJourney.push(stop);

    // Check for focus trap (same element as previous 2 stops)
    const journey = window.__tabJourney;
    const isTrap = journey.length >= 3 &&
      journey[journey.length - 1].tag === journey[journey.length - 2].tag &&
      journey[journey.length - 1].id === journey[journey.length - 2].id &&
      journey[journey.length - 2].tag === journey[journey.length - 3].tag &&
      journey[journey.length - 2].id === journey[journey.length - 3].id;

    return {
      stop: stop,
      journeyLength: journey.length,
      focusTrapDetected: isTrap,
      reachedBody: false
    };
  }`
})
```

**5c: Take screenshot at each tab stop (optional, for detailed audits)**

```
browser_take_screenshot({ type: "png", filename: "tab-stop-{index}.png" })
```

Replace `{index}` with the current tab stop number.

**5d: Check termination conditions**

Stop the tab loop when any of these conditions is met:
- `reachedBody` is `true` (focus returned to body)
- `journeyLength` reaches 50
- `focusTrapDetected` is `true` (log it and break)
- The current element matches the first element in the journey (focus wrapped around)

### Step 6: Capture Accessibility Tree Snapshot

Use `browser_snapshot` to get the full accessibility tree as Playwright sees it.

```
browser_snapshot()
```

### Step 7: Extract Final Journey Report

Use `browser_evaluate` to compile the complete journey data and summary statistics.

```javascript
browser_evaluate({
  function: `() => {
    const journey = window.__tabJourney;

    const missingFocusIndicator = journey.filter(s => !s.focusIndicator.visible);
    const offscreenStops = journey.filter(s => s.isOffscreen);
    const interactiveElements = journey.filter(s =>
      ['a', 'button', 'input', 'select', 'textarea'].includes(s.tag)
    );
    const nonInteractiveTabStops = journey.filter(s =>
      !['a', 'button', 'input', 'select', 'textarea'].includes(s.tag) &&
      !s.role
    );

    // Check for skip link (first tab stop with href starting with #)
    const skipLink = journey.length > 0 && journey[0].tag === 'a' &&
                     journey[0].href.startsWith('#');

    // Extract heading hierarchy from page
    const headings = Array.from(docum

Related in Security