Color Contrast and Keyboard Navigation Testing in CI
Color contrast and keyboard navigation are two of the most commonly failed WCAG criteria. Color contrast (SC 1.4.3) requires a 4.5:1 ratio for normal text and 3:1 for large text. Keyboard accessibility (SC 2.1.1) requires all functionality to be operable from a keyboard. Both are mechanically verifiable — which means both belong in CI.
This post covers automating contrast verification beyond what axe provides and writing reliable keyboard navigation tests.
Color Contrast: What Axe Catches and What It Misses
Axe's color-contrast rule catches straightforward cases: text on a solid background where both colors are computed from CSS. It misses:
- Text rendered over gradient backgrounds
- Text on images
- Text whose color depends on JavaScript state (hover, active, selected)
- Custom properties that axe can't resolve at scan time
For the cases axe misses, you need viewport-based pixel sampling.
Pixel-Level Contrast Verification with Playwright
import { test, expect, Page } from '@playwright/test';
async function getPixelColor(page: Page, x: number, y: number): Promise<[number, number, number]> {
return page.evaluate(([px, py]) => {
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
// Capture screenshot and sample pixel
const img = new Image();
return new Promise<[number, number, number]>(resolve => {
// Use html2canvas approach for actual rendered pixels
// In practice, use Playwright's screenshot + jimp/sharp
resolve([0, 0, 0]); // placeholder
});
}, [x, y]);
}
// More practical: screenshot + color sampling
import sharp from 'sharp';
async function samplePixelFromScreenshot(
screenshotBuffer: Buffer,
x: number,
y: number
): Promise<{ r: number; g: number; b: number }> {
const { data } = await sharp(screenshotBuffer)
.extract({ left: x, top: y, width: 1, height: 1 })
.raw()
.toBuffer({ resolveWithObject: true });
return { r: data[0], g: data[1], b: data[2] };
}
function relativeLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map(c => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function contrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
test('button text has sufficient contrast on hover', async ({ page }) => {
await page.goto('/');
const button = page.locator('[data-testid="primary-cta"]');
await button.hover();
// Screenshot the hovered state
const screenshot = await button.screenshot();
// Sample the center pixel (text area) and background
const textPixel = await samplePixelFromScreenshot(screenshot, 20, 12);
const bgPixel = await samplePixelFromScreenshot(screenshot, 5, 2);
const textLum = relativeLuminance(textPixel.r, textPixel.g, textPixel.b);
const bgLum = relativeLuminance(bgPixel.r, bgPixel.g, bgPixel.b);
const ratio = contrastRatio(textLum, bgLum);
expect(ratio).toBeGreaterThanOrEqual(4.5); // WCAG AA for normal text
});Axe for Contrast on Interactive States
Axe can check interactive states if you trigger them before running the scan:
import AxeBuilder from '@axe-core/playwright';
test('contrast passes on all interactive states', async ({ page }) => {
await page.goto('/components/buttons');
// Test default state
const defaultResults = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(defaultResults.violations).toHaveLength(0);
// Force hover state via CSS and re-scan
await page.addStyleTag({
content: 'button { --force-hover: true; } button:not(:hover) { /* same as hover */ }'
});
// More practical: use page.evaluate to force :hover
await page.locator('button.primary').first().hover();
const hoverResults = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.include('button.primary')
.analyze();
expect(hoverResults.violations).toHaveLength(0);
});Focus Indicator Visibility Testing
WCAG 2.2 SC 2.4.11 requires focus indicators to have a minimum area and contrast. Testing this requires checking that focused elements have a visible indicator with sufficient contrast:
test('focus indicators are visible on all interactive elements', async ({ page }) => {
await page.goto('/');
// Collect all focusable elements
const focusableSelectors = 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
const elements = await page.locator(focusableSelectors).all();
for (const element of elements.slice(0, 30)) {
await element.focus();
// Check that the element has a focus outline
const focusStyle = await element.evaluate(el => {
const styles = window.getComputedStyle(el);
return {
outline: styles.outline,
outlineWidth: styles.outlineWidth,
outlineOffset: styles.outlineOffset,
boxShadow: styles.boxShadow,
border: styles.border
};
});
// Element must have SOME visible focus indicator
const hasOutline = focusStyle.outlineWidth !== '0px' && focusStyle.outline !== 'none';
const hasBoxShadow = focusStyle.boxShadow !== 'none';
const hasBorder = focusStyle.border !== 'none';
if (!hasOutline && !hasBoxShadow && !hasBorder) {
const elementDesc = await element.evaluate(el =>
`${el.tagName} "${el.textContent?.trim().slice(0, 30)}" class="${el.className}"`
);
throw new Error(`No focus indicator on: ${elementDesc}`);
}
}
});Tab Order Verification
The Tab key must move focus in a logical sequence — top to bottom, left to right for LTR layouts. CSS order, position: absolute, and z-index can cause visual order to diverge from DOM order.
test('tab order matches visual reading order', async ({ page }) => {
await page.goto('/checkout');
// Focus the first element
await page.keyboard.press('Tab');
const tabOrder: Array<{ tag: string; text: string; top: number; left: number }> = [];
// Collect 20 tabstops
for (let i = 0; i < 20; i++) {
const elementInfo = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const bb = el.getBoundingClientRect();
return {
tag: el.tagName,
text: (el as HTMLElement).innerText?.trim().slice(0, 40) || el.getAttribute('aria-label') || '',
top: Math.round(bb.top),
left: Math.round(bb.left)
};
});
if (!elementInfo) break;
tabOrder.push(elementInfo);
await page.keyboard.press('Tab');
}
// Verify: no element should appear before one that's visually above it
for (let i = 1; i < tabOrder.length; i++) {
const prev = tabOrder[i - 1];
const curr = tabOrder[i];
// Allow 10px tolerance for elements on the same visual row
const sameRow = Math.abs(curr.top - prev.top) < 10;
if (!sameRow && curr.top < prev.top - 10) {
throw new Error(
`Tab order regression: "${curr.text}" (top: ${curr.top}) ` +
`comes after "${prev.text}" (top: ${prev.top}) but is visually above it`
);
}
}
});Skip Link Testing
Skip links let keyboard users bypass navigation and jump to main content. They must exist, must be focusable, and the target must actually receive focus.
test('skip to main content link works', async ({ page }) => {
await page.goto('/');
// Tab once — skip link should be the first focusable element
await page.keyboard.press('Tab');
const firstFocused = await page.evaluate(() => document.activeElement?.textContent?.trim());
// Skip link text varies — check for common patterns
expect(firstFocused).toMatch(/skip|jump|main content/i);
// Activate it
await page.keyboard.press('Enter');
// Verify focus moved to main content
const focusedAfter = await page.evaluate(() => {
const el = document.activeElement;
return el ? { tag: el.tagName, id: el.id, role: el.getAttribute('role') } : null;
});
expect(focusedAfter).not.toBeNull();
// Focus should be on main, or an element with id="main-content", or role="main"
const isMainContent =
focusedAfter!.tag === 'MAIN' ||
focusedAfter!.id === 'main-content' ||
focusedAfter!.id === 'content' ||
focusedAfter!.role === 'main';
expect(isMainContent).toBe(true);
});
test('skip link is visually hidden until focused', async ({ page }) => {
await page.goto('/');
// Before focus — skip link should not be visible
const skipLink = page.locator('a[href="#main-content"], a[href="#content"]').first();
// Check it exists in DOM but is off-screen
await expect(skipLink).toBeAttached();
const initialBox = await skipLink.boundingBox();
const isOffScreen = !initialBox || initialBox.x < -9000 || initialBox.y < -9000;
expect(isOffScreen).toBe(true);
// After Tab — skip link should appear
await page.keyboard.press('Tab');
await expect(skipLink).toBeVisible();
});Modal Focus Trap Testing
WCAG 2.1 SC 2.1.2 requires keyboard focus to stay within a modal dialog while it's open. Test both that focus is trapped and that Tab cycling works correctly:
test('modal traps focus while open', async ({ page }) => {
await page.goto('/app');
await page.locator('[data-testid="open-dialog"]').click();
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
// Tab through all focusable elements inside the dialog
const focusHistory: string[] = [];
let wrappedCount = 0;
for (let i = 0; i < 15; i++) {
const focused = await page.evaluate(() => {
const el = document.activeElement;
return el ? `${el.tagName}|${el.id}|${(el as HTMLElement).innerText?.slice(0, 20)}` : 'body';
});
// If we see body — focus escaped the dialog
if (focused === 'body') {
throw new Error('Focus escaped the modal dialog');
}
// Check if focus is still inside the dialog
const isInsideDialog = await dialog.evaluate((dialogEl, activeStr) => {
const [tag, id] = activeStr.split('|');
const active = document.activeElement;
return active ? dialogEl.contains(active) : false;
}, focused);
if (!isInsideDialog) {
throw new Error(`Focus left the dialog to: ${focused}`);
}
// Detect wrapping (same element appears twice)
if (focusHistory.includes(focused)) {
wrappedCount++;
if (wrappedCount >= 2) break; // Confirmed cycling — test passes
}
focusHistory.push(focused);
await page.keyboard.press('Tab');
}
expect(wrappedCount).toBeGreaterThanOrEqual(1);
});CI Configuration
Run contrast and keyboard tests as part of your standard accessibility suite:
# .github/workflows/a11y.yml
jobs:
keyboard-and-contrast:
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 chromium --with-deps
- name: Run keyboard and contrast tests
run: npx playwright test tests/accessibility/keyboard.spec.ts tests/accessibility/contrast.spec.ts
- uses: actions/upload-artifact@v4
if: failure()
with:
name: a11y-failures
path: test-results/Keyboard and contrast tests are faster than screen reader tests (no OS automation layer) and work on Linux CI runners. They should run on every PR. Keep the test count under 50 tests to maintain under 3-minute CI times — focus on critical flows rather than exhaustive coverage of every page.