Back to the blog

Web Accessibility (a11y): Complete Guide

Building accessible websites: ARIA attributes, semantics, focus, contrast, screen readers and WCAG standards.

January 5, 2026
12 min read
454 views
MOLOTILO

MOLOTILO DIGITAL

Web Accessibility (a11y): Complete Guide

Why accessibility matters

Accessibility (a11y) is the practice of building websites that everyone can use, including people with disabilities. It’s not only ethical but also expands your audience and improves SEO.

Semantic markup

Correct HTML tags are the foundation of accessibility:

<!-- ❌ Bad -->
<div class="header">
  <div class="nav">
    <div class="link" onclick="...">Home</div>
  </div>
</div>

<!-- ✅ Good -->
<header>
  <nav aria-label="Main navigation">
    <a href="/">Home</a>
    <a href="/en/about">About</a>
  </nav>
</header>

<main>
  <article>
    <h1>Page heading</h1>
    <section>
      <h2>Subheading</h2>
      <p>Content...</p>
    </section>
  </article>
</main>

<footer>
  <nav aria-label="Secondary navigation">...</nav>
</footer>

ARIA attributes

// Icon button
<button aria-label="Close modal">
  <XIcon />
</button>

// Modal window
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-description"
>
  <h2 id="modal-title">Confirmation</h2>
  <p id="modal-description">Are you sure?</p>
</div>

// Loading state
<button aria-busy={isLoading} disabled={isLoading}>
  {isLoading ? 'Loading...' : 'Submit'}
</button>

// Notifications
<div role="alert" aria-live="polite">
  Form submitted successfully
</div>

// Navigation with the current page
<nav>
  <a href="/" aria-current="page">Home</a>
  <a href="/en/about">About</a>
</nav>

// Dropdown menu
<button
  aria-expanded={isOpen}
  aria-controls="dropdown-menu"
  aria-haspopup="true"
>
  Menu
</button>
<ul id="dropdown-menu" role="menu" hidden={!isOpen}>
  <li role="menuitem"><a href="#">Item 1</a></li>
</ul>

Focus management

// Focus trap for the modal
function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement>();

  useEffect(() => {
    if (isOpen) {
      previousFocus.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();
    } else {
      previousFocus.current?.focus();
    }
  }, [isOpen]);

  // Focus trap
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Tab') {
      const focusable = modalRef.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );

      if (!focusable?.length) return;

      const first = focusable[0] as HTMLElement;
      const last = focusable[focusable.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();
      }
    }

    if (e.key === 'Escape') {
      onClose();
    }
  };

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}
      onKeyDown={handleKeyDown}
    >
      {children}
    </div>
  );
}

// Skip link for keyboard navigation
<a href="#main-content" className="skip-link">
  Skip to main content
</a>

// CSS for the skip link
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px;
  background: #000;
  color: #fff;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}

Contrast and colors

/* WCAG AA requires 4.5:1 contrast for text */
/* WCAG AAA requires 7:1 contrast */

/* ❌ Bad — low contrast */
.text-low-contrast {
  color: #999;
  background: #fff;
}

/* ✅ Good — sufficient contrast */
.text-good-contrast {
  color: #595959;
  background: #fff;
}

/* Don’t rely on color alone */
/* ❌ Bad */
.error { color: red; }

/* ✅ Good — color + icon + text */
.error {
  color: #dc2626;
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.error::before {
  content: '⚠️';
}

/* Focus must be visible */
:focus {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

/* Don’t remove outline without a replacement */
:focus:not(:focus-visible) {
  outline: none;
}

:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

Forms

// Linking label and input
<label htmlFor="email">Email</label>
<input id="email" type="email" required />

// Describing errors
<div>
  <label htmlFor="password">Password</label>
  <input
    id="password"
    type="password"
    aria-describedby="password-error password-hint"
    aria-invalid={!!error}
  />
  <p id="password-hint">At least 8 characters</p>
  {error && <p id="password-error" role="alert">{error}</p>}
</div>

// Grouping fields
<fieldset>
  <legend>Delivery method</legend>
  <label>
    <input type="radio" name="delivery" value="pickup" />
    Pickup
  </label>
  <label>
    <input type="radio" name="delivery" value="courier" />
    Courier
  </label>
</fieldset>

Images

// Informative image
<img src="/product.jpg" alt="Red backpack with two pockets" />

// Decorative image
<img src="/decoration.svg" alt="" role="presentation" />

// Complex image (chart)
<figure>
  <img
    src="/chart.png"
    alt="Sales chart for 2024"
    aria-describedby="chart-description"
  />
  <figcaption id="chart-description">
    Sales grew 25% in Q4 compared to Q1
  </figcaption>
</figure>

Accessibility testing

# Tools
# - axe DevTools (browser extension)
# - Lighthouse (built into Chrome)
# - WAVE (wave.webaim.org)
# - Screen readers: NVDA (Windows), VoiceOver (Mac)

# Automated tests
npm install @axe-core/playwright
// Playwright test
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage should have no accessibility violations', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page }).analyze();

  expect(results.violations).toEqual([]);
});

Conclusion

Accessibility is not an extra feature but a basic requirement. Semantic markup, ARIA attributes, focus management and sufficient contrast make a site accessible to everyone. Test with screen readers and automated tools.

An accessible site is a good site. a11y practices improve the UX for all users.

Enjoyed the article?

Subscribe to our blog so you don’t miss new posts