This document summarizes the accessibility improvements made to implement proper focus management across all modals in the StellarEarn frontend application.
- GitHub Issue: #23
- Labels: frontend, accessibility, priority-low
Modals were not managing focus properly, creating accessibility issues for keyboard users and screen reader users. When a modal opened:
- Focus was not trapped within the modal
- Users could tab outside the modal to elements behind it
- Focus was not restored to the triggering element when the modal closed
- Keyboard navigation did not follow WAI-ARIA dialog best practices
Implemented focus trapping using the existing FocusTrap component (/components/a11y/FocusTrap.tsx) across all modal dialogs.
Focus trapping ensures that when a modal is open, keyboard focus (Tab/Shift+Tab) cycles only within the modal's interactive elements. This is essential for:
- Screen reader users
- Keyboard-only users
- Users with motor disabilities
- WCAG 2.1 Level A compliance (Success Criterion 2.4.3)
Before: Manual focus management without proper trapping
After: Wrapped modal content with <FocusTrap active={isOpen}>
import { FocusTrap } from '@/components/a11y/FocusTrap';
// Inside the modal JSX:
<FocusTrap active={isOpen}>
<div ref={modalRef} className="..." tabIndex={-1}>
{/* Modal content */}
</div>
</FocusTrap>Before: No focus management After: Added FocusTrap wrapper
import { FocusTrap } from '@/components/a11y/FocusTrap';
// Wrapped modal content
<FocusTrap active={isModalOpen}>
<motion.div>
{/* Wallet selection UI */}
</motion.div>
</FocusTrap>Before: No focus management After: Added FocusTrap wrapper
import { FocusTrap } from '@/components/a11y/FocusTrap';
// Wrapped modal content
<FocusTrap active={isModalOpen}>
<motion.div>
{/* Connection UI */}
</motion.div>
</FocusTrap>Before: No focus management After: Added FocusTrap wrapper
import { FocusTrap } from '@/components/a11y/FocusTrap';
// Wrapped modal content
<FocusTrap active={isOpen}>
<div className="bg-zinc-900 ...">
{/* Profile editing form */}
</div>
</FocusTrap>Before: Manual focus management without trapping After: Added FocusTrap wrapper
import { FocusTrap } from '@/components/a11y/FocusTrap';
// Wrapped modal content
<FocusTrap active={isOpen}>
<div ref={modalRef} className="..." tabIndex={-1}>
{/* Submission details */}
</div>
</FocusTrap>These modals already had proper focus trapping:
- Modal (
/components/ui/Modal.tsx) - Base modal component - WelcomeModal (
/components/onboarding/WelcomeModal.tsx) - Uses the base Modal component - TransactionModal (
/components/rewards/TransactionModal.tsx) - Uses the base Modal component - SubmissionSuccessModal (
/components/ui/Modal.tsx) - Uses the base Modal component
The existing FocusTrap component provides:
- Focus Containment: Traps Tab/Shift+Tab within the modal
- Focus Wrapping:
- Tab on last element → focuses first element
- Shift+Tab on first element → focuses last element
- Initial Focus: Automatically focuses the first focusable element or a specified element
- Focus Restoration: Returns focus to the previously focused element when modal closes
- Visible Elements Only: Only traps focus in visible, enabled elements
- Escape Prevention: Works alongside Escape key handlers for modal dismissal
✅ Tab - Moves focus forward through interactive elements
✅ Shift+Tab - Moves focus backward through interactive elements
✅ Escape - Closes modal (handled by parent components)
✅ Focus Cycling - Focus wraps from last to first element and vice versa
✅ role="dialog" - Identifies the element as a dialog
✅ aria-modal="true" - Indicates modal behavior
✅ aria-labelledby - Links to the modal title
✅ Focus Management - Clear focus indicators for keyboard users
✅ Prevents body scroll when modal is open
✅ Restores focus when modal closes
✅ Traps focus within modal boundaries
✅ Supports dynamic content updates
✅ Works with nested focusable elements
- Open each modal using keyboard (Enter/Space on trigger button)
- Press Tab and verify focus moves through all interactive elements
- Press Shift+Tab and verify focus moves in reverse
- Verify focus wraps from last element to first on Tab
- Verify focus wraps from first element to last on Shift+Tab
- Press Escape and verify modal closes
- Verify focus returns to the trigger button after closing
- Verify focus never escapes to elements behind the modal
Created test structure in /tests/a11y/modal-focus.test.ts with Playwright tests for:
- Focus trapping within modal
- Focus restoration on close
- Escape key functionality
- Initial focus management
- Focus cycling (Tab/Shift+Tab)
- Focus containment
This implementation addresses the following WCAG 2.1 success criteria:
- 2.4.3 Focus Order (Level A): Focus moves in a meaningful order
- 2.4.7 Focus Visible (Level AA): Focus indicator is always visible
- 4.1.2 Name, Role, Value (Level A): Proper ARIA attributes for dialogs
import { FocusTrap } from '@/components/a11y/FocusTrap';
function MyModal({ isOpen, onClose }: ModalProps) {
return (
<div role="dialog" aria-modal="true">
<FocusTrap active={isOpen}>
<div tabIndex={-1}>
{/* Modal content with interactive elements */}
<button onClick={onClose}>Close</button>
</div>
</FocusTrap>
</div>
);
}- Always set
active={isOpen}to enable/disable trapping - The modal container should have
tabIndex={-1}for programmatic focus - Include
role="dialog"andaria-modal="true"on the overlay - Use
aria-labelledbyto reference the modal title - The FocusTrap handles focus restoration automatically
/components/ui/LevelUpModal.tsx/components/wallet/WalletModal.tsx/components/wallet/WalletConnectionModal.tsx/components/profile/EditProfileModal.tsx/components/submission/SubmissionDetail.tsx/tests/a11y/modal-focus.test.ts(new)
- Run manual accessibility testing with screen readers (NVDA, JAWS, VoiceOver)
- Test with keyboard-only navigation
- Update E2E tests to include focus management checks
- Consider adding focus trap visual indicators for debugging
- Document accessibility patterns in contributing guide
Implementation Date: 2026-04-28
Issue: Close #23