ARIA & Semantic HTML: Making Web Apps Understandable

Medium•

ARIA is powerful but complex - understand when and how to use it correctly.

Quick Decision Guide

ARIA Rules:

1. Prefer semantic HTML: <button> not <div role="button">

2. Use ARIA for complex widgets: Tabs, accordions, modals, dropdowns

3. Key attributes: - role: Element type (button, dialog, tab) - aria-label: Accessible name - aria-expanded: Open/closed state - aria-hidden: Hide from screen readers

4. Always test with screen readers

Result: Custom components work for keyboard and screen reader users.

Semantic HTML First

Use Native Elements

<!-- āœ… Best: Native button -->
<button onClick={handleClick}>Submit</button>

<!-- āš ļø OK: ARIA button (only if necessary) -->
<div 
  role="button"
  tabindex="0"
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      handleClick();
    }
  }}
>
  Submit
</div>

<!-- āŒ Bad: No accessibility -->
<div onClick={handleClick}>Submit</div>

Native Semantics

<!-- These have built-in accessibility -->
<button>        <!-- Keyboard, role, states -->
<a href="">     <!-- Keyboard, role -->
<input>         <!-- Keyboard, role, label association -->
<select>        <!-- Keyboard, role, native dropdown -->
<textarea>      <!-- Keyboard, role -->

<!-- Semantic structure -->
<header>
<nav>
<main>
<article>
<section>
<aside>
<footer>

When to Use ARIA

Only when HTML doesn't provide needed semantics:

•Custom widgets (tabs, dropdowns, sliders)
•Dynamic content updates
•Complex interactions
•Single-page apps with client-side routing

ARIA Roles

Widget Roles

<!-- Button -->
<div role="button" tabindex="0">Click me</div>

<!-- Checkbox -->
<div role="checkbox" aria-checked="false" tabindex="0">
  Accept terms
</div>

<!-- Radio group -->
<div role="radiogroup">
  <div role="radio" aria-checked="true" tabindex="0">Option 1</div>
  <div role="radio" aria-checked="false" tabindex="-1">Option 2</div>
</div>

<!-- Slider -->
<div 
  role="slider" 
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuenow="50"
  aria-label="Volume"
  tabindex="0"
>
  <div style="width: 50%"></div>
</div>

Composite Roles

// Tabs
function Tabs({ tabs }) {
  const [selected, setSelected] = useState(0);
  
  return (
    <div>
      <div role="tablist">
        {tabs.map((tab, i) => (
          <button
            key={i}
            role="tab"
            aria-selected={selected === i}
            aria-controls={`panel-${i}`}
            onClick={() => setSelected(i)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab, i) => (
        <div
          key={i}
          id={`panel-${i}`}
          role="tabpanel"
          hidden={selected !== i}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

// Accordion
<div>
  <h3>
    <button
      aria-expanded={isOpen}
      aria-controls="content-1"
    >
      Section 1
    </button>
  </h3>
  <div id="content-1" role="region" hidden={!isOpen}>
    Content
  </div>
</div>

// Menu
<div role="menu">
  <div role="menuitem" tabindex="0">New</div>
  <div role="menuitem" tabindex="-1">Open</div>
  <div role="separator"></div>
  <div role="menuitem" tabindex="-1">Save</div>
</div>

Document Structure Roles

<div role="banner">Header</div>
<div role="navigation">Nav</div>
<div role="main">Main content</div>
<div role="complementary">Sidebar</div>
<div role="contentinfo">Footer</div>
<div role="search">Search form</div>

ARIA States & Properties

Labels

<!-- aria-label: Direct label -->
<button aria-label="Close dialog">Ɨ</button>

<!-- aria-labelledby: Reference to label element -->
<div role="dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm Deletion</h2>
</div>

<!-- aria-describedby: Additional description -->
<input 
  aria-label="Password"
  aria-describedby="password-rules"
/>
<div id="password-rules">
  Must be at least 8 characters
</div>

States

// Expanded/collapsed
<button 
  aria-expanded={isOpen}
  aria-controls="dropdown-menu"
>
  Menu
</button>
<div id="dropdown-menu" hidden={!isOpen}>...</div>

// Pressed (toggle buttons)
<button 
  aria-pressed={isBold}
  onClick={() => setIsBold(!isBold)}
>
  <BoldIcon /> Bold
</button>

// Selected
<div role="option" aria-selected={isSelected}>
  Option 1
</div>

// Checked
<div role="checkbox" aria-checked={isChecked}>
  Accept terms
</div>

// Disabled
<button aria-disabled="true" disabled>
  Submit
</button>

// Invalid
<input 
  aria-invalid={hasError}
  aria-errormessage="error-msg"
/>
<div id="error-msg" role="alert">{error}</div>

Live Regions

// Alert (interrupts)
<div role="alert">
  Form submitted successfully!
</div>

// Status (polite, doesn't interrupt)
<div role="status" aria-live="polite">
  Loading...
</div>

// Log (chat messages, etc.)
<div role="log" aria-live="polite" aria-atomic="false">
  <div>New message from John</div>
</div>

// Timer
<div role="timer" aria-live="off">
  Time remaining: 5:30
</div>

Complex Widget Patterns

Modal Dialog

function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef();
  
  useEffect(() => {
    if (isOpen) {
      modalRef.current?.focus();
    }
  }, [isOpen]);
  
  if (!isOpen) return null;
  
  return (
    <div 
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      tabIndex={-1}
      onKeyDown={(e) => {
        if (e.key === 'Escape') onClose();
      }}
    >
      <div className="modal-content">
        <h2 id="modal-title">{title}</h2>
        <button 
          onClick={onClose}
          aria-label="Close dialog"
        >
          Ɨ
        </button>
        {children}
      </div>
      <div 
        className="modal-overlay"
        onClick={onClose}
        aria-hidden="true"
      />
    </div>
  );
}

Combobox (Autocomplete)

function Combobox({ options, value, onChange }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  
  return (
    <div>
      <input
        role="combobox"
        aria-expanded={isOpen}
        aria-controls="listbox"
        aria-activedescendant={
          activeIndex >= 0 ? `option-${activeIndex}` : undefined
        }
        value={value}
        onChange={(e) => {
          onChange(e.target.value);
          setIsOpen(true);
        }}
        onKeyDown={(e) => {
          if (e.key === 'ArrowDown') {
            e.preventDefault();
            setActiveIndex(Math.min(activeIndex + 1, options.length - 1));
          } else if (e.key === 'ArrowUp') {
            e.preventDefault();
            setActiveIndex(Math.max(activeIndex - 1, 0));
          } else if (e.key === 'Enter' && activeIndex >= 0) {
            onChange(options[activeIndex]);
            setIsOpen(false);
          }
        }}
      />
      {isOpen && (
        <ul id="listbox" role="listbox">
          {options.map((option, i) => (
            <li
              key={i}
              id={`option-${i}`}
              role="option"
              aria-selected={i === activeIndex}
              onClick={() => {
                onChange(option);
                setIsOpen(false);
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Common Mistakes & Best Practices

Mistakes to Avoid

<!-- āŒ Overriding native semantics -->
<button role="heading">This is confusing</button>

<!-- āŒ Missing required attributes -->
<div role="button">No tabindex or keyboard support</div>

<!-- āŒ Redundant ARIA -->
<button role="button">Already a button</button>

<!-- āŒ Incorrect states -->
<button aria-expanded="yes">Should be true/false</button>

<!-- āŒ Missing label -->
<div role="dialog">
  <h2>No aria-labelledby</h2>
</div>

Best Practices

<!-- āœ… Proper role with required attributes -->
<div 
  role="button"
  tabindex="0"
  onKeyDown={handleKey}
>
  Custom button
</div>

<!-- āœ… Modal with proper ARIA -->
<div 
  role="dialog"
  aria-modal="true"
  aria-labelledby="title"
>
  <h2 id="title">Dialog Title</h2>
</div>

<!-- āœ… Form with error -->
<input 
  aria-invalid="true"
  aria-describedby="error"
/>
<div id="error" role="alert">Invalid email</div>

<!-- āœ… Loading state -->
<button aria-busy="true" disabled>
  <Spinner /> Loading...
</button>

Testing Checklist

āœ… Use semantic HTML when possible

āœ… All custom widgets have proper roles

āœ… All interactive elements are keyboard accessible

āœ… ARIA states update dynamically

āœ… Test with screen reader (VoiceOver/NVDA)

āœ… Run axe DevTools audit

āœ… Check ARIA specification for patterns