Skip to content

Mastering Semantic HTML & Accessibility

Published: at 05:00 AM

Mastering Semantic HTML & Accessibility

Part 1: Semantic HTML Fundamentals

Why It Matters for Web Development

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

  1. Use semantic HTML first
  2. Don’t change native semantics (unless absolutely necessary)
  3. All interactive elements must be keyboard accessible
  4. Don’t hide focusable elements
  5. 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:

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:

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

  1. Build an accessible autocomplete with full keyboard support
  2. Create a modal dialog with proper focus trap
  3. Implement a tabs component using ARIA
  4. Make a custom dropdown accessible
  5. 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!