axe-core checks static properties โ missing labels, colour contrast ratios, ARIA attribute values. But accessibility is also about interaction: can a keyboard user reach every interactive element? Does focus move logically through the page? Does a modal trap focus inside itself? Does the Escape key close popups? These interaction patterns require Cypress tests that simulate keyboard-only navigation and verify focus behaviour โ the 60% of accessibility that automated scanners cannot cover.
Keyboard Navigation and Focus Testing
Cypress’s .type() and .trigger() commands can simulate keyboard interactions, and cy.focused() tells you which element currently has focus.
// โโ TAB ORDER: verify focus moves through elements logically โโ
it('should tab through login form in correct order', () => {
cy.visit('/');
// First Tab โ username field
cy.get('body').tab(); // Requires cypress-plugin-tab or realPress
cy.focused().should('have.attr', 'data-test', 'username');
// Second Tab โ password field
cy.focused().tab();
cy.focused().should('have.attr', 'data-test', 'password');
// Third Tab โ login button
cy.focused().tab();
cy.focused().should('have.attr', 'data-test', 'login-button');
});
// Alternative without tab plugin โ use :focus pseudo-class
it('should show focus styles on interactive elements', () => {
cy.visit('/');
cy.get('[data-test="username"]').focus();
cy.focused().should('have.attr', 'data-test', 'username');
// Verify focus style is visible (outline or ring)
cy.focused().should('have.css', 'outline-style').and('not.eq', 'none');
});
// โโ MODAL FOCUS TRAP: focus must stay inside the modal โโ
it('should trap focus inside the modal when open', () => {
cy.visit('/shop');
cy.get('[data-cy="open-modal"]').click();
// Modal should be visible
cy.get('[data-cy="modal"]').should('be.visible');
// Focus should be inside the modal (first focusable element)
cy.focused().should('be.descendantOf', '[data-cy="modal"]');
// Tab through all modal elements โ focus should NOT leave the modal
cy.get('[data-cy="modal"]').within(() => {
// Tab to the last focusable element
cy.get('button, input, [tabindex]').last().focus();
// Tab again โ should cycle back to the first focusable element, not leave
cy.focused().tab();
cy.focused().should('be.descendantOf', '[data-cy="modal"]');
});
// Escape should close the modal
cy.get('body').type('{esc}');
cy.get('[data-cy="modal"]').should('not.exist');
// Focus should return to the element that opened the modal
cy.focused().should('have.attr', 'data-cy', 'open-modal');
});
// โโ SKIP LINK: keyboard users should be able to skip navigation โโ
it('should have a working skip-to-content link', () => {
cy.visit('/');
// Skip link is typically the first focusable element, hidden until focused
cy.get('body').tab();
cy.focused()
.should('contain.text', 'Skip to main content')
.and('have.attr', 'href', '#main-content');
// Activate skip link
cy.focused().type('{enter}');
// Focus should jump to the main content area
cy.focused().should('have.attr', 'id', 'main-content');
});
// โโ ARIA LIVE REGIONS: verify dynamic announcements โโ
it('should announce cart update to screen readers', () => {
cy.visit('/shop');
// Before adding item โ live region should be empty or have initial text
cy.get('[aria-live="polite"]').should('exist');
// Add item to cart
cy.get('[data-cy="add-to-cart"]').first().click();
// Live region should announce the update
cy.get('[aria-live="polite"]')
.should('contain.text', 'added to cart');
});
// โโ ARIA ATTRIBUTES: verify correct ARIA usage โโ
it('should have correct ARIA attributes on accordion', () => {
cy.visit('/faq');
// Accordion trigger should have aria-expanded
cy.get('[data-cy="accordion-trigger"]').first()
.should('have.attr', 'aria-expanded', 'false');
// Click to expand
cy.get('[data-cy="accordion-trigger"]').first().click();
cy.get('[data-cy="accordion-trigger"]').first()
.should('have.attr', 'aria-expanded', 'true');
// Panel should have matching aria-labelledby
cy.get('[data-cy="accordion-panel"]').first()
.should('have.attr', 'role', 'region')
.and('have.attr', 'aria-labelledby');
});
// โโ Key accessibility interactions to test โโ
const A11Y_INTERACTIONS = [
{ pattern: 'Tab order', test: 'Tab through page; verify focus order matches visual order' },
{ pattern: 'Focus visible', test: 'Focus each element; verify visible focus indicator (outline/ring)' },
{ pattern: 'Modal focus trap', test: 'Open modal; Tab cannot leave; Escape closes; focus returns' },
{ pattern: 'Skip link', test: 'First Tab reveals skip link; Enter jumps to main content' },
{ pattern: 'Escape key', test: 'Modals, dropdowns, tooltips close on Escape' },
{ pattern: 'Arrow keys', test: 'Menus, tabs, radio groups navigate with arrow keys' },
{ pattern: 'Enter/Space', test: 'Buttons activate on Enter and Space; links on Enter only' },
{ pattern: 'ARIA states', test: 'aria-expanded, aria-selected, aria-checked update on interaction' },
{ pattern: 'Live regions', test: 'Dynamic updates announced via aria-live="polite"' },
];
cy.focused().should('have.attr', 'data-cy', 'open-modal') after closing.cy.focused() command returns whichever element currently has keyboard focus. Use it to verify focus position after every Tab press, modal open/close, and skip link activation. It is the accessibility equivalent of cy.url() for navigation โ it tells you where the user “is” from a keyboard perspective at any point in the test..type('{tab}') does not actually move focus to the next element โ it types a Tab character into the focused element. For true Tab navigation, use the cypress-plugin-tab plugin: cy.focused().tab(). Alternatively, use cy.realPress('Tab') from cypress-real-events which sends native browser key events. Without these plugins, Tab navigation tests silently test the wrong thing.Common Mistakes
Mistake 1 โ Using .type(‘{tab}’) instead of a proper Tab plugin
โ Wrong: cy.get('#input').type('{tab}') โ types a tab character, does not move focus to the next element.
โ
Correct: cy.focused().tab() (with cypress-plugin-tab) or cy.realPress('Tab') (with cypress-real-events) โ simulates real Tab key navigation.
Mistake 2 โ Not testing focus return after modal close
โ Wrong: Testing that a modal opens and closes but not verifying where focus goes after close.
โ
Correct: After modal close, asserting that focus returned to the trigger element: cy.focused().should('have.attr', 'data-cy', 'modal-trigger').