Back to Blog
AccessibilityWCAGARIAWeb Development

Web Accessibility: Building Inclusive Applications

Build accessible web applications for everyone. Learn WCAG guidelines, ARIA patterns, and practical techniques for inclusive design.

B
Bootspring Team
Engineering
February 26, 2026
6 min read

Accessibility ensures everyone can use your application, including people with disabilities. This guide covers WCAG guidelines, ARIA patterns, and practical implementation techniques.

WCAG Principles (POUR)#

1. Perceivable#

Content must be presentable in ways users can perceive:

1<!-- Provide text alternatives for images --> 2<img src="chart.png" alt="Sales increased 25% from Q1 to Q2 2024"> 3 4<!-- Don't rely on color alone --> 5<span class="status error"> 6 <span class="icon" aria-hidden="true"></span> 7 Error: Invalid email address 8</span> 9 10<!-- Provide captions for videos --> 11<video controls> 12 <source src="demo.mp4" type="video/mp4"> 13 <track kind="captions" src="captions.vtt" srclang="en" label="English"> 14</video>

2. Operable#

Interface must be operable by all users:

1<!-- Keyboard accessible --> 2<button onclick="submitForm()">Submit</button> 3 4<!-- Skip navigation link --> 5<a href="#main-content" class="skip-link">Skip to main content</a> 6 7<!-- Sufficient time for interactions --> 8<div role="alert" aria-live="polite"> 9 Session expires in 5 minutes. 10 <button onclick="extendSession()">Extend session</button> 11</div>

3. Understandable#

Content and operation must be understandable:

1<!-- Specify page language --> 2<html lang="en"> 3 4<!-- Label form inputs --> 5<label for="email">Email address</label> 6<input type="email" id="email" name="email" required> 7 8<!-- Provide error guidance --> 9<div class="error" id="email-error" role="alert"> 10 Please enter a valid email address (e.g., name@example.com) 11</div>

4. Robust#

Content must be robust for various technologies:

1<!-- Use semantic HTML --> 2<nav aria-label="Main navigation"> 3 <ul> 4 <li><a href="/">Home</a></li> 5 <li><a href="/about">About</a></li> 6 </ul> 7</nav> 8 9<!-- Valid HTML --> 10<button type="submit">Send</button> 11<!-- Not: <div onclick="send()">Send</div> -->

Semantic HTML#

Document Structure#

1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Page Title - Site Name</title> 7</head> 8<body> 9 <a href="#main" class="skip-link">Skip to main content</a> 10 11 <header> 12 <nav aria-label="Main"> 13 <!-- Navigation --> 14 </nav> 15 </header> 16 17 <main id="main"> 18 <h1>Page Title</h1> 19 20 <article> 21 <h2>Article Title</h2> 22 <p>Content...</p> 23 </article> 24 25 <aside aria-label="Related content"> 26 <!-- Sidebar --> 27 </aside> 28 </main> 29 30 <footer> 31 <!-- Footer content --> 32 </footer> 33</body> 34</html>

Heading Hierarchy#

1<!-- Correct: Sequential headings --> 2<h1>Main Title</h1> 3 <h2>Section 1</h2> 4 <h3>Subsection 1.1</h3> 5 <h3>Subsection 1.2</h3> 6 <h2>Section 2</h2> 7 <h3>Subsection 2.1</h3> 8 9<!-- Wrong: Skipping levels --> 10<h1>Main Title</h1> 11 <h3>Section</h3> <!-- Skipped h2 -->

ARIA Patterns#

Live Regions#

1<!-- Polite: Announced when idle --> 2<div role="status" aria-live="polite"> 3 3 items in your cart 4</div> 5 6<!-- Assertive: Announced immediately --> 7<div role="alert" aria-live="assertive"> 8 Error: Payment failed. Please try again. 9</div> 10 11<!-- React implementation --> 12function Notification({ message, type }) { 13 return ( 14 <div 15 role={type === 'error' ? 'alert' : 'status'} 16 aria-live={type === 'error' ? 'assertive' : 'polite'} 17 > 18 {message} 19 </div> 20 ); 21}
1function Modal({ isOpen, onClose, title, children }) { 2 const modalRef = useRef<HTMLDivElement>(null); 3 4 useEffect(() => { 5 if (isOpen) { 6 // Focus the modal when opened 7 modalRef.current?.focus(); 8 9 // Trap focus inside modal 10 const handleTab = (e: KeyboardEvent) => { 11 if (e.key === 'Tab') { 12 const focusable = modalRef.current?.querySelectorAll( 13 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 14 ); 15 if (focusable) { 16 const first = focusable[0] as HTMLElement; 17 const last = focusable[focusable.length - 1] as HTMLElement; 18 19 if (e.shiftKey && document.activeElement === first) { 20 e.preventDefault(); 21 last.focus(); 22 } else if (!e.shiftKey && document.activeElement === last) { 23 e.preventDefault(); 24 first.focus(); 25 } 26 } 27 } 28 if (e.key === 'Escape') { 29 onClose(); 30 } 31 }; 32 33 document.addEventListener('keydown', handleTab); 34 return () => document.removeEventListener('keydown', handleTab); 35 } 36 }, [isOpen, onClose]); 37 38 if (!isOpen) return null; 39 40 return ( 41 <div 42 className="modal-overlay" 43 onClick={onClose} 44 aria-hidden="true" 45 > 46 <div 47 ref={modalRef} 48 role="dialog" 49 aria-modal="true" 50 aria-labelledby="modal-title" 51 tabIndex={-1} 52 onClick={e => e.stopPropagation()} 53 > 54 <h2 id="modal-title">{title}</h2> 55 {children} 56 <button onClick={onClose}>Close</button> 57 </div> 58 </div> 59 ); 60}

Tabs#

1function Tabs({ tabs }) { 2 const [activeIndex, setActiveIndex] = useState(0); 3 4 const handleKeyDown = (e: React.KeyboardEvent, index: number) => { 5 let newIndex = index; 6 7 switch (e.key) { 8 case 'ArrowRight': 9 newIndex = (index + 1) % tabs.length; 10 break; 11 case 'ArrowLeft': 12 newIndex = (index - 1 + tabs.length) % tabs.length; 13 break; 14 case 'Home': 15 newIndex = 0; 16 break; 17 case 'End': 18 newIndex = tabs.length - 1; 19 break; 20 default: 21 return; 22 } 23 24 e.preventDefault(); 25 setActiveIndex(newIndex); 26 }; 27 28 return ( 29 <div> 30 <div role="tablist" aria-label="Content tabs"> 31 {tabs.map((tab, index) => ( 32 <button 33 key={tab.id} 34 role="tab" 35 id={`tab-${tab.id}`} 36 aria-selected={index === activeIndex} 37 aria-controls={`panel-${tab.id}`} 38 tabIndex={index === activeIndex ? 0 : -1} 39 onClick={() => setActiveIndex(index)} 40 onKeyDown={(e) => handleKeyDown(e, index)} 41 > 42 {tab.label} 43 </button> 44 ))} 45 </div> 46 47 {tabs.map((tab, index) => ( 48 <div 49 key={tab.id} 50 role="tabpanel" 51 id={`panel-${tab.id}`} 52 aria-labelledby={`tab-${tab.id}`} 53 hidden={index !== activeIndex} 54 tabIndex={0} 55 > 56 {tab.content} 57 </div> 58 ))} 59 </div> 60 ); 61}

Forms#

Accessible Form Pattern#

1function ContactForm() { 2 const [errors, setErrors] = useState<Record<string, string>>({}); 3 4 return ( 5 <form onSubmit={handleSubmit} noValidate> 6 <div className="form-group"> 7 <label htmlFor="name"> 8 Name <span aria-hidden="true">*</span> 9 <span className="visually-hidden">(required)</span> 10 </label> 11 <input 12 type="text" 13 id="name" 14 name="name" 15 required 16 aria-required="true" 17 aria-invalid={!!errors.name} 18 aria-describedby={errors.name ? 'name-error' : undefined} 19 /> 20 {errors.name && ( 21 <div id="name-error" className="error" role="alert"> 22 {errors.name} 23 </div> 24 )} 25 </div> 26 27 <fieldset> 28 <legend>Contact preference</legend> 29 <div> 30 <input type="radio" id="email-pref" name="contact" value="email" /> 31 <label htmlFor="email-pref">Email</label> 32 </div> 33 <div> 34 <input type="radio" id="phone-pref" name="contact" value="phone" /> 35 <label htmlFor="phone-pref">Phone</label> 36 </div> 37 </fieldset> 38 39 <button type="submit">Send message</button> 40 </form> 41 ); 42}

Error Handling#

1function FormWithValidation() { 2 const [errors, setErrors] = useState<string[]>([]); 3 const errorSummaryRef = useRef<HTMLDivElement>(null); 4 5 const handleSubmit = (e: React.FormEvent) => { 6 e.preventDefault(); 7 const newErrors = validate(formData); 8 9 if (newErrors.length > 0) { 10 setErrors(newErrors); 11 // Focus error summary 12 errorSummaryRef.current?.focus(); 13 } 14 }; 15 16 return ( 17 <form onSubmit={handleSubmit}> 18 {errors.length > 0 && ( 19 <div 20 ref={errorSummaryRef} 21 role="alert" 22 tabIndex={-1} 23 className="error-summary" 24 > 25 <h2>There are {errors.length} errors in this form</h2> 26 <ul> 27 {errors.map((error, index) => ( 28 <li key={index}> 29 <a href={`#${error.field}`}>{error.message}</a> 30 </li> 31 ))} 32 </ul> 33 </div> 34 )} 35 {/* Form fields */} 36 </form> 37 ); 38}

Focus Management#

Visible Focus Styles#

1/* Never remove focus outlines completely */ 2:focus { 3 outline: 2px solid #005fcc; 4 outline-offset: 2px; 5} 6 7/* Use :focus-visible for keyboard-only focus */ 8:focus:not(:focus-visible) { 9 outline: none; 10} 11 12:focus-visible { 13 outline: 2px solid #005fcc; 14 outline-offset: 2px; 15} 16 17/* Skip link */ 18.skip-link { 19 position: absolute; 20 top: -40px; 21 left: 0; 22 padding: 8px; 23 background: #000; 24 color: #fff; 25 z-index: 100; 26} 27 28.skip-link:focus { 29 top: 0; 30}

Managing Focus in SPAs#

1function usePageFocus(pageTitle: string) { 2 const headingRef = useRef<HTMLHeadingElement>(null); 3 4 useEffect(() => { 5 // Update document title 6 document.title = pageTitle; 7 8 // Focus main heading 9 headingRef.current?.focus(); 10 11 // Announce page change 12 const announcement = document.createElement('div'); 13 announcement.setAttribute('role', 'status'); 14 announcement.setAttribute('aria-live', 'polite'); 15 announcement.className = 'visually-hidden'; 16 announcement.textContent = `Navigated to ${pageTitle}`; 17 document.body.appendChild(announcement); 18 19 return () => { 20 document.body.removeChild(announcement); 21 }; 22 }, [pageTitle]); 23 24 return headingRef; 25} 26 27function Page({ title, children }) { 28 const headingRef = usePageFocus(title); 29 30 return ( 31 <main> 32 <h1 ref={headingRef} tabIndex={-1}>{title}</h1> 33 {children} 34 </main> 35 ); 36}

Testing Accessibility#

Automated Testing#

1// Jest + Testing Library 2import { axe, toHaveNoViolations } from 'jest-axe'; 3 4expect.extend(toHaveNoViolations); 5 6test('form is accessible', async () => { 7 const { container } = render(<ContactForm />); 8 const results = await axe(container); 9 expect(results).toHaveNoViolations(); 10}); 11 12// Playwright 13test('page meets accessibility standards', async ({ page }) => { 14 await page.goto('/'); 15 16 const accessibilityScanResults = await new AxeBuilder({ page }) 17 .withTags(['wcag2a', 'wcag2aa']) 18 .analyze(); 19 20 expect(accessibilityScanResults.violations).toEqual([]); 21});

Manual Testing Checklist#

  1. Navigate using keyboard only (Tab, Enter, Space, Arrows)
  2. Test with screen reader (VoiceOver, NVDA, JAWS)
  3. Zoom to 200% - ensure content remains usable
  4. Use high contrast mode
  5. Disable images - check alt text adequacy
  6. Check color contrast ratios (4.5:1 for text)

Conclusion#

Accessibility benefits everyone. Start with semantic HTML, add ARIA only when needed, and test with real assistive technologies. Build accessibility into your development process from the beginning.

Share this article

Help spread the word about Bootspring