Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Browser accessibility testing using Playwright and @axe-core/playwright. Keyboard scans, contrast verification, and accessibility tree snapshots.
.claude/skills/community-access-playwright-testing/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 102% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 58% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 257% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 73% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 92% | 0% |
Reusable knowledge module for browser-based accessibility testing using Playwright and @axe-core/playwright.
| Tool | Purpose | Requires @axe-core/playwright | |------|---------|------------------------------| | run_playwright_keyboard_scan | Tab-order traversal, keyboard trap detection | No | | run_playwright_state_scan | Click triggers, scan revealed content with axe-core | Yes | | run_playwright_viewport_scan | Multi-viewport axe-core + touch target measurement | Yes | | run_playwright_contrast_scan | Computed-style contrast ratio after CSS cascade | No | | run_playwright_a11y_tree | Browser accessibility tree snapshot | No |
javascriptimport AxeBuilder from '@axe-core/playwright'; const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) .analyze();
javascriptconst results = await new AxeBuilder({ page }) .include('.modal-content') .withTags(['wcag2a', 'wcag2aa']) .analyze();
javascriptconst results = await new AxeBuilder({ page }) .include('#hero-image') .withRules(['image-alt']) .analyze(); expect(results.violations).toEqual([]);
javascriptawait page.click('[aria-expanded="false"]'); await page.waitForSelector('.accordion-content', { state: 'visible' }); const results = await new AxeBuilder({ page }) .include('.accordion-content') .analyze();
javascriptconst tabStops = []; for (let i = 0; i < maxTabs; i++) { await page.keyboard.press('Tab'); const info = await page.evaluate(() => { const el = document.activeElement; return { tagName: el?.tagName, role: el?.getAttribute('role'), name: el?.getAttribute('aria-label') || el?.textContent?.trim().slice(0, 50), id: el?.id, tabIndex: el?.tabIndex }; }); tabStops.push(info); }
A keyboard trap is detected when the same element receives focus after consecutive Tab presses:
javascriptlet trapCount = 0; let lastSelector = ''; for (const stop of tabStops) { const currentSelector = `${stop.tagName}#${stop.id}`; if (currentSelector === lastSelector) { trapCount++; if (trapCount >= 3) { /* TRAP DETECTED */ } } else { trapCount = 0; } lastSelector = currentSelector; }
javascriptawait page.click('[data-modal-trigger]'); await page.waitForSelector('[role="dialog"]', { state: 'visible' }); const focusedRole = await page.evaluate(() => document.activeElement?.closest('[role="dialog"]') ? 'inside-dialog' : 'outside-dialog' ); // focusedRole should be 'inside-dialog'
javascripttest('modal traps focus correctly', async ({ page }) => { await page.goto(url); await page.click('[data-open-modal]'); await page.waitForSelector('[role="dialog"]', { state: 'visible' }); // Focus should be inside the dialog const inDialog = await page.evaluate(() => document.activeElement?.closest('[role="dialog"]') !== null ); expect(inDialog).toBe(true); // Tab through dialog — should not escape for (let i = 0; i < 20; i++) { await page.keyboard.press('Tab'); const stillInDialog = await page.evaluate(() => document.activeElement?.closest('[role="dialog"]') !== null ); expect(stillInDialog).toBe(true); } // Escape should close and return focus to trigger await page.keyboard.press('Escape'); const focusedId = await page.evaluate(() => document.activeElement?.id); expect(focusedId).toBe('modal-trigger-id'); });
javascripttest('skip link moves focus to main content', async ({ page }) => { await page.goto(url); await page.keyboard.press('Tab'); // Focus skip link await page.keyboard.press('Enter'); // Activate it const focusedId = await page.evaluate(() => document.activeElement?.id); expect(focusedId).toBe('main-content'); });
yamlname: Accessibility Tests on: [push, pull_request] jobs: a11y: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps chromium - name: Start dev server run: npm run dev & env: CI: true - name: Wait for server run: npx wait-on http://localhost:3000 --timeout 30000 - name: Run accessibility tests run: npx playwright test tests/a11y/ - uses: actions/upload-artifact@v4 if: failure() with: name: a11y-test-results path: test-results/
javascript// playwright.config.js (a11y section) export default { testDir: './tests/a11y', use: { baseURL: process.env.BASE_URL || 'http://localhost:3000', // Use Chromium only — @axe-core/playwright is Chromium-validated browserName: 'chromium', }, webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, };
javascriptlet _playwrightAvailable = null; async function isPlaywrightAvailable() { if (_playwrightAvailable !== null) return _playwrightAvailable; try { await import('playwright'); _playwrightAvailable = true; } catch { _playwrightAvailable = false; } return _playwrightAvailable; }
| Playwright | @axe-core/playwright | Available Scans | |------------|---------------------|-----------------| | Yes | Yes | All 5 tools (keyboard, state, viewport, contrast, tree) | | Yes | No | 3 tools (keyboard, contrast, tree) | | No | — | None — fall back to code review + axe-core CLI |
When unavailable:
yamlPlaywright not installed. Behavioral testing (keyboard traversal, dynamic states, responsive viewport, rendered contrast) is unavailable. Install: npm install -D playwright @axe-core/playwright && npx playwright install chromium
When partially available:
yaml@axe-core/playwright not installed. State scanning and viewport scanning are unavailable. Keyboard, contrast, and accessibility tree scans will proceed. Install: npm install -D @axe-core/playwright
| WCAG SC | Description | Playwright Tool | |---------|-------------|-----------------| | 1.3.1 | Info and Relationships | a11y tree, state scan | | 1.4.3 | Contrast (Minimum) | contrast scan | | 1.4.6 | Contrast (Enhanced) | contrast scan | | 1.4.10 | Reflow | viewport scan | | 2.1.1 | Keyboard | keyboard scan | | 2.1.2 | No Keyboard Trap | keyboard scan | | 2.4.3 | Focus Order | keyboard scan | | 2.4.7 | Focus Visible | keyboard scan | | 2.5.5 | Target Size (Enhanced) | viewport scan | | 2.5.8 | Target Size (Minimum) | viewport scan | | 4.1.2 | Name, Role, Value | a11y tree, state scan |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 12,000 | 8,554 | -29% | 1 | 1 | 0% | 2,395 | 3,790 | +58% | 0 | 0 | — |
case-02 | fail→fail | 20,369 | 12,115 | -41% | 1 | 1 | 0% | 3,974 | 4,392 | +11% | 0 | 0 | — |
case-03 | fail→pass | 11,693 | 11,778 | +1% | 1 | 1 | 0% | 2,266 | 4,583 | +102% | 0 | 0 | — |
case-04 | pass→pass | 12,361 | 5,366 | -57% | 1 | 1 | 0% | 2,190 | 3,039 | +39% | 0 | 0 | — |
case-05 | pass→pass | 12,161 | 4,052 | -67% | 1 | 1 | 0% | 2,193 | 2,780 | +27% | 0 | 0 | — |
case-06 | pass→pass | 12,286 | 7,825 | -36% | 1 | 1 | 0% | 2,088 | 3,475 | +66% | 0 | 0 | — |
case-07 | pass→fail | 8,598 | 9,314 | +8% | 1 | 1 | 0% | 1,782 | 3,885 | +118% | 0 | 0 | — |
case-08 | fail→fail | 10,057 | 9,469 | -6% | 1 | 1 | 0% | 1,975 | 3,964 | +101% | 0 | 0 | — |
case-09 | fail→pass | 16,105 | 11,733 | -27% | 1 | 1 | 0% | 2,750 | 4,337 | +58% | 0 | 0 | — |
case-10 | pass→pass | 6,060 | 5,517 | -9% | 1 | 1 | 0% | 1,086 | 2,965 | +173% | 0 | 0 | — |
case-11 | fail→pass | 4,341 | 3,491 | -20% | 1 | 1 | 0% | 759 | 2,708 | +257% | 0 | 0 | — |
case-12 | fail→pass | 7,903 | 2,178 | -72% | 1 | 1 | 0% | 1,366 | 2,366 | +73% | 0 | 0 | — |
case-13 | fail→pass | 9,327 | 6,340 | -32% | 1 | 1 | 0% | 1,685 | 3,232 | +92% | 0 | 0 | — |
case-14 | pass→pass | 15,925 | 5,167 | -68% | 1 | 1 | 0% | 2,903 | 2,938 | +1% | 0 | 0 | — |
case-15 | pass→pass | 10,756 | 3,227 | -70% | 1 | 1 | 0% | 1,934 | 2,616 | +35% | 0 | 0 | — |
case-16 | fail→fail | 7,589 | 3,558 | -53% | 1 | 1 | 0% | 1,504 | 2,688 | +79% | 0 | 0 | — |
case-17 | fail→pass | 10,761 | 2,853 | -73% | 1 | 1 | 0% | 1,655 | 2,582 | +56% | 0 | 0 | — |
case-18 | fail→pass | 14,334 | 7,551 | -47% | 1 | 1 | 0% | 2,538 | 3,474 | +37% | 0 | 0 | — |
case-19 | pass→pass | 13,387 | 4,202 | -69% | 1 | 1 | 0% | 2,476 | 2,801 | +13% | 0 | 0 | — |
case-20 | pass→pass | 8,016 | 5,947 | -26% | 1 | 1 | 0% | 1,304 | 3,090 | +137% | 0 | 0 | — |
case-21 | pass→pass | 15,679 | 13,524 | -14% | 1 | 1 | 0% | 2,545 | 4,389 | +72% | 0 | 0 | — |
case-22 | pass→pass | 13,155 | 12,081 | -8% | 1 | 1 | 0% | 2,443 | 4,388 | +80% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +27 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.