Web Performance & Accessibility
Accessibility Standards & Testing
Accessibility (a11y) is no longer a "nice to have" — it is a legal requirement in many jurisdictions and a core evaluation area in frontend interviews. WCAG 2.2 was published on October 5, 2023, and you should know its key additions.
WCAG 2.2 AA — What You Need to Know
WCAG is organized into four principles: Perceivable, Operable, Understandable, Robust (POUR). Most companies target AA conformance as the standard.
Key New Criteria in WCAG 2.2
2.5.8 Target Size (Minimum) — Level AA
Interactive targets must be at least 24x24 CSS pixels:
/* Ensure all interactive elements meet minimum target size */
button, a, input[type="checkbox"], input[type="radio"], select {
min-width: 24px;
min-height: 24px;
}
/* Better: use generous touch targets for mobile */
.btn {
min-width: 44px; /* Apple HIG recommendation */
min-height: 44px;
padding: 12px 16px;
}
/* Inline links are exempt if text spacing meets requirements */
2.4.11 Focus Not Obscured (Minimum) — Level AA
When an element receives keyboard focus, it must not be fully hidden by sticky headers, footers, modals, or other overlapping content:
/* BAD: Sticky header can cover focused elements */
.header {
position: sticky;
top: 0;
z-index: 100;
}
/* GOOD: Account for sticky header when scrolling to focused elements */
:target {
scroll-margin-top: 80px; /* Height of sticky header */
}
/* Ensure focused elements scroll into visible area */
*:focus {
scroll-margin-top: 80px;
scroll-margin-bottom: 60px;
}
3.3.7 Redundant Entry — Level A
Do not force users to re-enter information they have already provided in the same session:
// BAD: Asking for shipping address after user already entered it as billing
function CheckoutForm() {
return (
<form>
<BillingAddressFields />
{/* Forces user to re-type everything */}
<ShippingAddressFields />
</form>
);
}
// GOOD: Offer to reuse previously entered data
function CheckoutForm() {
const [sameAsBilling, setSameAsBilling] = useState(true);
return (
<form>
<BillingAddressFields />
<label>
<input
type="checkbox"
checked={sameAsBilling}
onChange={(e) => setSameAsBilling(e.target.checked)}
/>
Shipping address same as billing
</label>
{!sameAsBilling && <ShippingAddressFields />}
</form>
);
}
3.3.8 Accessible Authentication (Minimum) — Level AA
Authentication must not rely on cognitive function tests (like remembering a password or solving a puzzle) without providing alternatives:
// GOOD: Provide multiple authentication methods
function LoginPage() {
return (
<div>
<h1>Sign In</h1>
{/* Allow password managers to autofill */}
<input type="email" autoComplete="email" />
<input type="password" autoComplete="current-password" />
{/* Alternative: passkey/biometric */}
<button onClick={handlePasskeyLogin}>Sign in with Passkey</button>
{/* Alternative: magic link */}
<button onClick={handleMagicLink}>Send me a sign-in link</button>
{/* If using CAPTCHA, provide an accessible alternative */}
</div>
);
}
ARIA Roles, States, and Properties
The first rule of ARIA: do not use ARIA if you can use semantic HTML instead.
Semantic HTML vs. ARIA
<!-- BAD: Using ARIA to recreate what HTML already provides -->
<div role="button" tabindex="0" aria-pressed="false"
onclick="handleClick()" onkeydown="handleKeydown(event)">
Click me
</div>
<!-- GOOD: Use the native element — it has built-in keyboard handling,
focus management, and screen reader support -->
<button onclick="handleClick()">Click me</button>
When ARIA Is Necessary
<!-- Custom combobox — no native HTML equivalent -->
<div role="combobox" aria-expanded="true" aria-haspopup="listbox"
aria-owns="results-list">
<input type="text" aria-autocomplete="list"
aria-controls="results-list" aria-activedescendant="option-3">
</div>
<ul id="results-list" role="listbox">
<li id="option-1" role="option">Result 1</li>
<li id="option-2" role="option">Result 2</li>
<li id="option-3" role="option" aria-selected="true">Result 3</li>
</ul>
<!-- Live region for dynamic content updates -->
<div aria-live="polite" aria-atomic="true">
3 results found
</div>
<!-- Progress indicator -->
<div role="progressbar" aria-valuenow="65" aria-valuemin="0"
aria-valuemax="100" aria-label="Upload progress">
65%
</div>
Keyboard Navigation Patterns
Focus Trapping in Modals
When a modal opens, focus must stay within the modal until it closes:
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Save the element that was focused before modal opened
previousFocusRef.current = document.activeElement;
const modal = modalRef.current;
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
// Focus the first element in the modal
firstFocusable?.focus();
function handleKeyDown(e) {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
// Trap focus within modal
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
modal.addEventListener('keydown', handleKeyDown);
return () => {
modal.removeEventListener('keydown', handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div ref={modalRef} role="dialog" aria-modal="true"
aria-labelledby="modal-title" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}
Route Change Announcements
Single-page applications must announce navigation to screen readers:
function RouteAnnouncer() {
const location = useLocation();
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
// Announce the new page title to screen readers
const pageTitle = document.title;
setAnnouncement(`Navigated to ${pageTitle}`);
}, [location]);
return (
<div
role="status"
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
);
}
Skip Links
Allow keyboard users to skip repetitive navigation:
<!-- First focusable element on the page -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav><!-- Long navigation --></nav>
<main id="main-content" tabindex="-1">
<!-- Page content -->
</main>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: #000;
color: #fff;
z-index: 1000;
transition: top 0.2s;
}
.skip-link:focus {
top: 0; /* Visible only when focused via keyboard */
}
</style>
Color Contrast Requirements
| Text Type | Minimum Contrast Ratio (AA) |
|---|---|
| Normal text (< 18pt or < 14pt bold) | 4.5:1 |
| Large text (≥ 18pt or ≥ 14pt bold) | 3:1 |
| UI components and graphical objects | 3:1 |
/* GOOD: High contrast combinations */
.text-primary { color: #1a1a1a; } /* On white: 16.7:1 */
.text-secondary { color: #595959; } /* On white: 7.0:1 */
/* BAD: Insufficient contrast */
.text-light { color: #aaaaaa; } /* On white: 2.3:1 — FAILS */
Motion and Color Scheme Preferences
prefers-reduced-motion
Respect users who are sensitive to motion:
/* Default: animations enabled */
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: scale(1.05);
}
/* Reduce or remove motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
/* Replace animations with opacity changes */
.fade-in {
animation: none;
opacity: 1;
}
}
prefers-color-scheme
Support system dark mode preference:
:root {
--bg-color: #ffffff;
--text-color: #1a1a1a;
--border-color: #e0e0e0;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--text-color: #f0f0f0;
--border-color: #333333;
}
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}
Screen Reader Testing Basics
VoiceOver (macOS)
| Action | Shortcut |
|---|---|
| Turn on/off | Cmd + F5 |
| Read next element | VO + Right Arrow (VO = Ctrl + Option) |
| Interact with element | VO + Shift + Down Arrow |
| Stop reading | Ctrl |
| Open Rotor (landmarks, headings) | VO + U |
NVDA (Windows)
| Action | Shortcut |
|---|---|
| Turn on/off | Ctrl + Alt + N |
| Read next element | Down Arrow |
| List headings | H (or Insert + F7) |
| List landmarks | D |
| Stop reading | Ctrl |
Accessibility Tree in Chrome DevTools
- Open DevTools → Elements tab
- Click the "Accessibility" pane in the sidebar
- Inspect any element to see its:
- Role: what type of element the screen reader sees
- Name: what the screen reader announces (from label, aria-label, alt text)
- State: checked, expanded, selected, etc.
- Enable "Full accessibility tree" in DevTools settings to replace the DOM tree view
Interview tip: When discussing accessibility, always frame it as a core engineering concern, not an afterthought. Mention that semantic HTML solves 80% of accessibility issues, ARIA handles the remaining 20%, and automated testing catches about 30% of issues — manual testing with a screen reader is essential.
Congratulations on completing Module 5! Take the quiz to test your knowledge of performance and accessibility. :::