Portfolio Project · WCAG 2.1

Web Accessibility
in Practice

Ten common accessibility failures, side-by-side with their correct implementations. Each demo is interactive — try them with a keyboard or screen reader.

HTML Semantics ARIA Keyboard Nav WCAG 2.1

Test your own site

Run a quick automated check against any public URL using free accessibility tools.

Opens the WAVE Web Accessibility Evaluation Tool report in a new tab.

Or install the axe DevTools browser extension (opens in new tab) to test any site directly in your browser's DevTools.

Color Contrast

SC 1.4.3 Level AA

Text must have a contrast ratio of at least 4.5:1 against its background (3:1 for large text). Low-contrast text fails users with low vision, in bright sunlight, or on low-quality screens.

Broken color: #9ca3af on #ffffff

The quick brown fox jumps over the lazy dog. This text is styled with low contrast gray — it looks subtle, but it fails WCAG.

Contrast ratio: 2.3:1 Fails AA

Try it: Live Contrast Checker

Sample text
Contrast ratio
10.7:1
Normal text (AA)
Pass
Large text (AA)
Pass
View code

Broken

/* Insufficient contrast */
.text {
  color: #9ca3af;
  background: #ffffff;
  /* Ratio: 2.3:1 — Fails AA */
}

Fixed

/* Sufficient contrast */
.text {
  color: #374151;
  background: #ffffff;
  /* Ratio: 10.7:1 — Passes AAA */
}

Alt Text

SC 1.1.1 Level A

Every meaningful image needs a text alternative that conveys the same information. Decorative images should use alt="" so screen readers skip them entirely.

Broken

Informative image — missing alt

Functional image — meaningless alt

Decorative image — redundant alt

View code

Broken

<!-- Missing alt -->
<img src="chart.png">

<!-- Filename as alt -->
<button>
  <img src="search.png"
       alt="search.png">
</button>

<!-- Noisy decorative alt -->
<img src="divider.png"
     alt="squiggly line">

Fixed

<!-- Descriptive alt -->
<img src="chart.png"
     alt="Bar chart: Q4 revenue up 23%">

<!-- Alt describes the action -->
<button>
  <img src="search.png"
       alt="Search products">
</button>

<!-- Empty alt for decorative -->
<img src="divider.png" alt="">

Form Labels

SC 1.3.1 · 3.3.2 Level A

Placeholder text is not a label. It disappears on focus, leaving users without context. Every input needs a persistently visible, programmatically associated <label>.

Broken — placeholder only

Click any field — the placeholder disappears, leaving no hint of what to type.

View code

Broken

<!-- No label element -->
<input
  type="email"
  placeholder="Email">

Fixed

<!-- Visible, associated label -->
<label for="email">
  Email address
</label>
<input
  type="email"
  id="email"
  placeholder="e.g. jane@example.com">

Focus Visibility

SC 2.4.7 · 2.4.11 Level AA

Removing the focus outline is one of the most common and harmful accessibility mistakes. Keyboard users rely on visible focus to navigate. Use :focus-visible to show focus rings for keyboard navigation without affecting mouse users.

Broken — outline: none

Tab through these links with your keyboard. Where is the focus?

Focus is invisible — keyboard users are lost.

View code

Broken

/* Removes focus for everyone */
a:focus {
  outline: none;
}

Fixed

/* Only shows for keyboard nav */
a:focus-visible {
  outline: 3px solid #ca8a04;
  outline-offset: 3px;
  border-radius: 2px;
}

Heading Structure

SC 1.3.1 · 2.4.6 Level A / AA

Screen reader users navigate pages by jumping between headings — it's their table of contents. Skipped levels and non-semantic markup break that navigation entirely.

Broken — skipped levels, div soup

Document outline (screen reader sees):

  • h1: Page Title
  • h4: Section A ⚠ skipped h2, h3
  • h2: Section B
  • h5: Subsection ⚠ skipped h3, h4
  • div: "Section C" ⚠ not a heading at all
View code

Broken

<h1>Page Title</h1>
<!-- jumped from h1 to h4 -->
<h4>Section A</h4>
<h2>Section B</h2>
<!-- not a heading at all -->
<div class="big-text">Section C</div>

Fixed

<h1>Page Title</h1>
<h2>Section A</h2>
  <h3>Subsection A.1</h3>
<h2>Section B</h2>
  <h3>Subsection B.1</h3>
<h2>Section C</h2>

Button Semantics

SC 4.1.2 · 2.1.1 Level A

Using <div> or <span> as clickable controls is a common anti-pattern. They're invisible to keyboards and screen readers unless you manually recreate what <button> gives you for free.

Broken — <div> as button

Try to Tab to the buttons below. Then try clicking one.

Add to Cart
Save for Later

These divs are unreachable by keyboard. Try pressing Tab.

View code

Broken

<!-- Not keyboard accessible -->
<!-- No role, no name, no focus -->
<div onclick="addToCart()">
  Add to Cart
</div>

Fixed

<!-- Keyboard, role, name: all free -->
<button type="button"
        onclick="addToCart()">
  Add to Cart
</button>

Modal Focus Trap

SC 2.1.2 Level A

When a modal dialog opens, keyboard focus must be trapped inside it. Focus should not leak into background content. When closed, focus must return to the element that opened it.

Broken — focus escapes modal

Open the modal and press Tab repeatedly — focus will escape into the page behind it.

View code

Broken

<!-- div, no ARIA, no trap -->
<div class="modal">
  ...content...
</div>

Fixed

<div role="dialog"
     aria-modal="true"
     aria-labelledby="modal-title"
     tabindex="-1">
  <h2 id="modal-title">...</h2>
  ...content...
</div>
<!-- JS traps focus + handles Escape -->
<!-- inert attr applied to siblings -->

Live Regions

SC 4.1.3 Level AA

When content updates dynamically — search results, form errors, loading states — screen readers need to be told. Without aria-live, these updates are silent.

Broken — silent updates

Click "Save" — visually the status updates, but a screen reader hears nothing.

View code

Broken

<!-- No live region -->
<div id="status"></div>

// JS updates visually only
status.textContent = 'Saved!';

Fixed

<!-- Announced on change -->
<div id="status"
     role="status"
     aria-live="polite"
     aria-atomic="true">
</div>

// Same JS update — now announced
status.textContent = 'Settings saved.';

Reduced Motion

SC 2.3.3 Level AAA

Persistent, large-scale motion can trigger vestibular disorders — causing nausea, dizziness, and migraines. Respecting prefers-reduced-motion is a straightforward, high-impact fix. Listed as AAA, but failure has real physical consequences.

Broken — ignores OS preference

This animation plays regardless of the user's OS motion setting.

View code

Broken

/* Animation always plays */
.box {
  animation: spin 2s
    linear infinite;
}

Fixed

/* Animation plays by default */
.box {
  animation: spin 2s
    linear infinite;
}

/* Removed when user prefers it */
@media (prefers-reduced-motion: reduce) {
  .box {
    animation: none;
  }
}

Mega Navigation

SC 2.1.1 / 4.1.2 Level A

Mega navigation menus are often built as CSS hover-only dropdowns — keyboard users can Tab to the top-level links but can't reach submenu content at all. The fix uses disclosure buttons with aria-expanded and arrow-key navigation so every item is reachable without a mouse.

Broken — hover-only, no keyboard access

Hover the nav items with a mouse to open submenus. Try reaching a submenu link with Tab or arrow keys — you can't.

View code

Broken

<!-- anchor tag, hover-only CSS -->
<a href="/patterns">Patterns</a>
<div class="dropdown">
  <!-- shown only via :hover -->
</div>

Fixed

<button type="button"
        aria-expanded="false"
        aria-controls="panel-patterns">
  Patterns
</button>
<ul id="panel-patterns" hidden>
  <!-- JS toggles hidden +
       aria-expanded on click -->
</ul>

Tooltip

SC 1.4.13 Level AA

Custom tooltips are often triggered only on mouse hover — keyboard users who Tab to an icon-only button never see the tooltip and can't tell what the control does. The fix triggers tooltips on both hover and focus, associates them via aria-describedby, and lets users dismiss them with Escape.

Broken — hover-only, no keyboard trigger, no ARIA

Hover the buttons to see tooltips. Tab to them with a keyboard — no tooltip appears and there is no accessible name.

Save
Share
Delete
View code

Broken

<!-- no accessible name, hover-only -->
<button type="button">
  <svg ...></svg>
</button>
<div class="tooltip">Save</div>
<!-- .tooltip shown via CSS :hover
     on parent — never on :focus -->

Fixed

<button type="button"
        aria-describedby="tip-save">
  <svg ...></svg>
  <span class="sr-only">Save</span>
</button>
<div role="tooltip"
     id="tip-save" hidden>
  Save document
</div>
<!-- JS shows on hover + focus;
     Escape dismisses -->