Mastering Semantic HTML & Accessibility
Part 1: Semantic HTML Fundamentals
Why It Matters for Web Development
- SEO: Proper semantics improve search rankings (critical for a search company!)
- Accessibility: Screen readers rely on semantic structure
- Maintainability: Self-documenting code
- Performance: Browsers optimize semantic elements
Common Semantic Elements
<!-- ❌ BAD: Non-semantic divitis -->
<div class="header">
<div class="nav">
<div class="nav-item">Home</div>
</div>
</div>
<div class="main">
<div class="article">
<div class="title">Search Results</div>
</div>
</div>
<!-- ✅ GOOD: Semantic HTML -->
<header>
<nav aria-label="Main navigation">
<a href="/">Home</a>
</nav>
</header>
<main>
<article>
<h1>Search Results</h1>
</article>
</main>
Semantic Elements Reference
<!-- Document structure -->
<header>
<!-- Site/section header -->
<nav>
<!-- Navigation links -->
<main>
<!-- Primary content (only one per page) -->
<article>
<!-- Self-contained content -->
<section>
<!-- Thematic grouping -->
<aside>
<!-- Tangentially related content -->
<footer>
<!-- Site/section footer -->
<!-- Content organization -->
<h1>
-
<h6>
<!-- Headings (hierarchical) -->
<p><!-- Paragraphs --></p>
<ul>
,
<ol>
<!-- Lists -->
<dl>
,
<dt>,</dt>
<dd>
<!-- Description lists -->
<figure>
,
<figcaption>
<!-- Images with captions -->
<!-- Text semantics -->
<strong>
<!-- Important (bold) -->
<em>
<!-- Emphasis (italic) -->
<mark>
<!-- Highlighted text -->
<time>
<!-- Dates/times -->
<code>
<!-- Code snippets -->
<kbd>
<!-- Keyboard input -->
<!-- Interactive -->
<button>
<!-- Clickable button -->
<a>
<!-- Links -->
<details>
,
<summary><!-- Expandable content --></summary>
</details></a
>
</button></kbd
></code
></time
></mark
></em
></strong
>
</figcaption>
</figure>
</dd>
</dl>
</ol>
</ul>
</h6>
</h1>
</footer>
</aside>
</section>
</article>
</main>
</nav>
</header>
Real Search Component Example
// Search Results Component
const SearchResults: React.FC<SearchResultsProps> = ({ results, query }) => {
return (
<main id="main-content" role="main">
<header>
<h1>
Search results for <mark>"{query}"</mark>
</h1>
<p role="status" aria-live="polite">
Found {results.length} results in 0.3 seconds
</p>
</header>
<section aria-label="Search results">
{results.map((result, index) => (
<article key={result.id}>
<h2>
<a href={result.url}>{result.title}</a>
</h2>
<p>{result.description}</p>
<footer>
<time dateTime={result.publishedDate}>{formatDate(result.publishedDate)}</time>
<span aria-label="Source">{result.source}</span>
</footer>
</article>
))}
</section>
<nav aria-label="Pagination">{/* Pagination controls */}</nav>
</main>
)
}
Part 2: ARIA (Accessible Rich Internet Applications)
The Golden Rule of ARIA
”No ARIA is better than bad ARIA” - Only use ARIA when semantic HTML isn’t enough.
ARIA Principles
- Use semantic HTML first
- Don’t change native semantics (unless absolutely necessary)
- All interactive elements must be keyboard accessible
- Don’t hide focusable elements
- All interactive elements must have accessible names
ARIA Roles
// When to use roles (sparingly!)
// ✅ GOOD: Semantic HTML handles this
<button onClick={handleClick}>Search</button>
// ⚠️ ACCEPTABLE: When you must use a div
<div role="button" tabIndex={0} onClick={handleClick} onKeyDown={handleKey}>
Search
</div>
// Common roles for search interfaces:
<div role="search"> // Search landmark
<div role="status" aria-live="polite"> // Status messages
<div role="alert" aria-live="assertive"> // Urgent alerts
<div role="dialog"> // Modal dialogs
<div role="listbox"> // Custom select/combobox
<div role="tab"> // Tab interface
ARIA Attributes Categories
1. aria-label & aria-labelledby (Naming)
// Search input with clear label
const SearchInput = () => {
return (
<div>
<label id="search-label" htmlFor="search-input">
Search
</label>
<input
id="search-input"
type="search"
aria-labelledby="search-label"
aria-describedby="search-help"
/>
<small id="search-help">
Try searching for news, weather, or stocks
</small>
</div>
);
};
// Icon button without visible text
<button aria-label="Close search overlay" onClick={onClose}>
<CloseIcon aria-hidden="true" />
</button>
// Complex autocomplete
<input
type="text"
role="combobox"
aria-label="Search"
aria-autocomplete="list"
aria-controls="suggestions-list"
aria-expanded={isOpen}
aria-activedescendant={activeId}
/>
2. aria-live (Dynamic Content)
// Search status announcements
const SearchStatus = ({ isLoading, resultCount }) => {
if (isLoading) {
return (
<div role="status" aria-live="polite" aria-atomic="true">
Searching...
</div>
)
}
return (
<div role="status" aria-live="polite" aria-atomic="true">
{resultCount} results found
</div>
)
}
// Error messages (interrupting)
;<div role="alert" aria-live="assertive">
Search failed. Please try again.
</div>
aria-live values:
off: Don’t announce (default)polite: Announce when user is idleassertive: Announce immediately (interrupts)
3. aria-expanded, aria-controls (Widget States)
// Autocomplete dropdown
const Autocomplete = () => {
const [isOpen, setIsOpen] = useState(false)
const [suggestions, setSuggestions] = useState([])
return (
<div>
<input
type="text"
role="combobox"
aria-expanded={isOpen}
aria-controls="suggestions-listbox"
aria-autocomplete="list"
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<ul id="suggestions-listbox" role="listbox" aria-label="Search suggestions">
{suggestions.map((item) => (
<li key={item.id} role="option" aria-selected={item.selected}>
{item.text}
</li>
))}
</ul>
)}
</div>
)
}
4. aria-hidden (Hiding Content)
// Hide decorative icons from screen readers
<button>
<SearchIcon aria-hidden="true" />
<span>Search</span>
</button>
// ⚠️ NEVER do this on focusable elements!
// ❌ BAD:
<button aria-hidden="true">Click me</button>
5. aria-describedby (Additional Context)
<input
type="password"
aria-describedby="password-requirements"
/>
<div id="password-requirements">
Must be at least 8 characters with a number
</div>
Part 3: Keyboard Navigation
Essential Keyboard Patterns
// Tab order and focus management
const SearchModal = ({ isOpen, onClose }) => {
const firstFocusRef = useRef<HTMLInputElement>(null)
const modalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isOpen) {
// Trap focus in modal
firstFocusRef.current?.focus()
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
// Focus trap logic
if (e.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
if (!focusableElements) return
const first = focusableElements[0] as HTMLElement
const last = focusableElements[focusableElements.length - 1] as HTMLElement
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose])
return (
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Advanced Search</h2>
<input ref={firstFocusRef} type="text" />
<button onClick={onClose}>Close</button>
</div>
)
}
Standard Keyboard Interactions
// Custom dropdown component with full keyboard support
const Dropdown = ({ options, value, onChange }) => {
const [isOpen, setIsOpen] = useState(false)
const [focusedIndex, setFocusedIndex] = useState(0)
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Enter':
case ' ': // Space
e.preventDefault()
if (isOpen) {
onChange(options[focusedIndex])
setIsOpen(false)
} else {
setIsOpen(true)
}
break
case 'ArrowDown':
e.preventDefault()
if (!isOpen) {
setIsOpen(true)
} else {
setFocusedIndex((prev) => Math.min(prev + 1, options.length - 1))
}
break
case 'ArrowUp':
e.preventDefault()
if (isOpen) {
setFocusedIndex((prev) => Math.max(prev - 1, 0))
}
break
case 'Escape':
setIsOpen(false)
break
case 'Home':
e.preventDefault()
setFocusedIndex(0)
break
case 'End':
e.preventDefault()
setFocusedIndex(options.length - 1)
break
}
}
return (
<div>
<button
aria-haspopup="listbox"
aria-expanded={isOpen}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(!isOpen)}
>
{value || 'Select option'}
</button>
{isOpen && (
<ul role="listbox" aria-activedescendant={`option-${focusedIndex}`}>
{options.map((option, index) => (
<li
key={option.id}
id={`option-${index}`}
role="option"
aria-selected={index === focusedIndex}
onClick={() => {
onChange(option)
setIsOpen(false)
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
)
}
Focus Management Best Practices
// Skip to main content link
<a href="#main-content" className="skip-link">
Skip to main content
</a>
// CSS for skip link
// .skip-link {
// position: absolute;
// left: -10000px;
// top: auto;
// width: 1px;
// height: 1px;
// overflow: hidden;
// }
//
// .skip-link:focus {
// position: static;
// width: auto;
// height: auto;
// }
// Focus visible only on keyboard
<button className="focus-visible">
Click me
</button>
// CSS:
// button:focus-visible {
// outline: 2px solid blue;
// }
//
// button:focus:not(:focus-visible) {
// outline: none;
// }
Part 4: Complete Search Component Example
import { useState, useRef, useEffect, useId } from 'react'
interface SearchProps {
onSearch: (query: string) => Promise<SearchResult[]>
}
export const AccessibleSearch: React.FC<SearchProps> = ({ onSearch }) => {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [suggestions, setSuggestions] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false)
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const suggestionsId = useId()
const statusId = useId()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveSuggestionIndex((prev) => Math.min(prev + 1, suggestions.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveSuggestionIndex((prev) => Math.max(prev - 1, -1))
break
case 'Enter':
if (activeSuggestionIndex >= 0) {
setQuery(suggestions[activeSuggestionIndex])
setIsSuggestionsOpen(false)
}
handleSearch()
break
case 'Escape':
setIsSuggestionsOpen(false)
setActiveSuggestionIndex(-1)
break
}
}
const handleSearch = async () => {
if (!query.trim()) return
setIsLoading(true)
setIsSuggestionsOpen(false)
try {
const data = await onSearch(query)
setResults(data)
} catch (error) {
console.error('Search failed', error)
} finally {
setIsLoading(false)
}
}
return (
<div role="search">
{/* Search input with autocomplete */}
<div className="search-container">
<label htmlFor="search-input" className="visually-hidden">
Search
</label>
<input
ref={inputRef}
id="search-input"
type="search"
role="combobox"
aria-autocomplete="list"
aria-controls={suggestionsId}
aria-expanded={isSuggestionsOpen}
aria-activedescendant={activeSuggestionIndex >= 0 ? `suggestion-${activeSuggestionIndex}` : undefined}
aria-describedby={statusId}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search for anything"
/>
<button type="submit" aria-label="Search" onClick={handleSearch} disabled={isLoading}>
<SearchIcon aria-hidden="true" />
</button>
{query && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setQuery('')
inputRef.current?.focus()
}}
>
<CloseIcon aria-hidden="true" />
</button>
)}
</div>
{/* Autocomplete suggestions */}
{isSuggestionsOpen && suggestions.length > 0 && (
<ul id={suggestionsId} role="listbox" aria-label="Search suggestions">
{suggestions.map((suggestion, index) => (
<li
key={suggestion}
id={`suggestion-${index}`}
role="option"
aria-selected={index === activeSuggestionIndex}
onClick={() => {
setQuery(suggestion)
setIsSuggestionsOpen(false)
handleSearch()
}}
>
{suggestion}
</li>
))}
</ul>
)}
{/* Status announcements for screen readers */}
<div id={statusId} role="status" aria-live="polite" aria-atomic="true" className="visually-hidden">
{isLoading && 'Searching...'}
{!isLoading && results.length > 0 && `${results.length} results found`}
{!isLoading && results.length === 0 && query && 'No results found'}
</div>
{/* Search results */}
{results.length > 0 && (
<section aria-label="Search results">
<h2 id="results-heading">
Search Results for "{query}" ({results.length})
</h2>
{results.map((result, index) => (
<article key={result.id} aria-labelledby={`result-${index}`}>
<h3 id={`result-${index}`}>
<a href={result.url}>{result.title}</a>
</h3>
<p>{result.description}</p>
<footer>
<cite>{result.domain}</cite>
{' • '}
<time dateTime={result.date}>{formatDate(result.date)}</time>
</footer>
</article>
))}
</section>
)}
</div>
)
}
Part 5: Testing Accessibility
Manual Testing Checklist
# 1. Keyboard-only navigation
# - Tab through all interactive elements
# - Verify focus indicators are visible
# - Test arrow keys in custom widgets
# - Press Enter/Space on buttons
# - Press Escape to close modals
# 2. Screen reader testing
# - macOS: VoiceOver (Cmd + F5)
# - Windows: NVDA (free) or JAWS
# - Test: Navigate by headings (H key)
# - Test: Navigate by landmarks (D key)
# - Test: Hear all content in logical order
Automated Testing
// jest-axe for accessibility testing
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
describe('SearchComponent Accessibility', () => {
it('should not have accessibility violations', async () => {
const { container } = render(<SearchComponent />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
it('should be keyboard navigable', () => {
const { getByRole } = render(<SearchComponent />)
const input = getByRole('combobox')
// Simulate keyboard navigation
input.focus()
expect(document.activeElement).toBe(input)
fireEvent.keyDown(input, { key: 'ArrowDown' })
// Verify suggestion is focused...
})
it('should announce search results to screen readers', async () => {
const { getByRole } = render(<SearchComponent />)
// Trigger search
fireEvent.change(getByRole('combobox'), {
target: { value: 'test' },
})
// Verify status message
await waitFor(() => {
const status = getByRole('status')
expect(status).toHaveTextContent(/\\\\d+ results found/)
})
})
})
Part 6: Common Questions & Answers
Q1: “How would you make this button accessible?”
// ❌ BAD
<div className="button" onClick={handleClick}>
Submit
</div>
// ✅ GOOD
<button type="submit" onClick={handleClick}>
Submit
</button>
// ⚠️ ACCEPTABLE (if button element impossible)
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
Submit
</div>
Explanation points:
- Semantic
<button>gives you keyboard support for free - Includes focus management automatically
- Announced correctly to screen readers
- Works with Enter and Space keys natively
Q2: “How do you handle focus management in a modal?”
const Modal = ({ isOpen, onClose, children }) => {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
// Save current focus
previousFocusRef.current = document.activeElement as HTMLElement
// Focus first element in modal
const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as HTMLElement
firstFocusable?.focus()
// Prevent body scroll
document.body.style.overflow = 'hidden'
}
return () => {
// Restore focus and scroll
if (previousFocusRef.current) {
previousFocusRef.current.focus()
}
document.body.style.overflow = ''
}
}, [isOpen])
if (!isOpen) return null
return (
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" ref={modalRef}>
<h2 id="modal-title">Modal Title</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
)
}
Q3: “Explain the difference between aria-label, aria-labelledby, and aria-describedby”
// aria-label: Directly provides text label
<button aria-label="Close dialog">
<XIcon />
</button>
// aria-labelledby: References another element's ID for label
<section aria-labelledby="section-heading">
<h2 id="section-heading">Search Results</h2>
</section>
// aria-describedby: Provides additional description
<input
type="email"
aria-label="Email"
aria-describedby="email-hint email-error"
/>
<span id="email-hint">We'll never share your email</span>
<span id="email-error" role="alert">
Please enter a valid email
</span>
Q4: “How would you make a data table accessible?”
const AccessibleTable = ({ data }) => (
<table role="table">
<caption>Search Results Table - 50 items</caption>
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">URL</th>
<th scope="col">Date</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id}>
<th scope="row">{row.title}</th>
<td>{row.url}</td>
<td>
<time dateTime={row.date}>{formatDate(row.date)}</time>
</td>
<td>
<button aria-label={`View ${row.title}`}>View</button>
</td>
</tr>
))}
</tbody>
</table>
)
Part 7: Quick Reference Cheat Sheet
WCAG 2.1 Level AA Requirements (minimum for most sites):
✓ 1.4.3 Contrast: 4.5:1 for normal text, 3:1 for large text
✓ 2.1.1 Keyboard: All functionality via keyboard
✓ 2.4.7 Focus Visible: Keyboard focus indicator visible
✓ 3.2.1 On Focus: No context change on focus
✓ 4.1.2 Name, Role, Value: All UI components have these
Common ARIA patterns you should know:
- Accordion
- Alert/Alert Dialog
- Combobox (Autocomplete)
- Dialog (Modal)
- Disclosure (Show/Hide)
- Listbox (Select)
- Tabs
- Tooltip
Testing tools:
- Browser: Lighthouse, axe DevTools extension
- Screen readers: NVDA (Windows), VoiceOver (Mac)
- Automated: jest-axe, cypress-axe
- Manual: Keyboard-only testing
Keyboard shortcuts to memorize:
Tab - Next focusable element
Shift+Tab - Previous focusable element
Enter - Activate button/link
Space - Activate button, check checkbox
Arrow keys - Navigate within widgets
Escape - Close dialog/dropdown
Home/End - First/last item in list
Practice Exercises
- Build an accessible autocomplete with full keyboard support
- Create a modal dialog with proper focus trap
- Implement a tabs component using ARIA
- Make a custom dropdown accessible
- Build a skip navigation link
Time to master: Spend 2-3 days building these components and testing with screen readers. This will give you confidence on mastering the accessibility!