Francisco Guardado Blog

My WCAG Tutorial
My WCAG Tutorial

Quick Background: What Are WCAG and Section 508? WCAG (Web Content Accessibility Guidelines) is the international standard for web accessibility, published by the W3C. Current versions are WCAG 2.1 and 2.2, each with three conformance levels: A, AA, and AAA. Section 508 is a U.S. federal law requiring government agencies (and often their contractors) to make electronic content accessible. Since 2017, Section 508 has been updated to directly reference WCAG 2.0 Level AA as its technical standard. The practical takeaway: if you build to WCAG 2.1/2.2 Level AA, you satisfy Section 508 and most enterprise accessibility policies at the same time.

WCAG is organized around four principles, often remembered by the acronym POUR: -Perceivable: Users must be able to perceive the content (not hidden from any of their senses) -Operable: Users must be able to operate the interface (keyboard, voice, switch devices, etc.) -Understandable: Content and operation must be understandable -Robust: Content must work with a wide range of assistive technologies, now and in the future

Submit

Prefer:

HTML
<button type="submit">Submit</button>

Same logic applies to navigation, lists, and page structure:

HTML
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <header>... nav with 15 links ...</header>
  <main id="main-content">
    ...
  </main>
</body>
CSS
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
}

.skip-link:focus {
  left: 0;
  padding: 1rem;
  background: #fff;
  outline: 3px solid #1a73e8;
}

(WCAG 2.4.1 Bypass Blocks)

CSS
/* Bad: fixed pixel sizing ignores user's browser font-size settings */
body {
  font-size: 14px;
}

/* Good: rem units scale with the user's browser/OS preferences */
body {
  font-size: 1rem; /* 16px default, but respects user zoom/settings */
}

.container {
  max-width: 100%;
  overflow-x: hidden;
}

Also avoid disabling pinch-zoom on mobile:

HTML
<!-- Bad: blocks users who need to zoom in -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

<!-- Good -->
<meta name="viewport" content="width=device-width, initial-scale=1">

(WCAG 1.4.4 – Resize Text, 1.4.10 – Reflow)

HTML
<video controls>
  <source src="demo.mp4" type="video/mp4">
  <track kind="captions" src="demo-captions.vtt" srclang="en" label="English" default>
</video>

(WCAG 1.2.2 Captions (Prerecorded))

HTML
<!-- Bad -->
<a href="/report.pdf">Click here</a> to download the report.

<!-- Good -->
<a href="/report.pdf">Download the Q3 accessibility audit report (PDF)</a>

A Practical Testing Checklist Before shipping any feature, I run through this: Unplug your mouse. Tab through the entire page or flow. Can you reach and operate everything? Is focus always visible? Run an automated scanner. Use axe DevTools, Lighthouse, or WAVE. These catch about 30 to 40% of issues, such as missing alt text, contrast failures, and missing labels, but they will not catch logical or UX issues. Test with a screen reader. Use VoiceOver on Mac, NVDA on Windows, or TalkBack on Android. Simply landing on your homepage and navigating by headings can surface many accessibility issues. Zoom to 200%. Check that nothing breaks, overlaps, or gets cut off. Check color contrast. Test every text, background, and UI component combination. Validate forms. Submit the form with errors and confirm the error is announced to the user, not just displayed visually.

Rule of thumb: if a native HTML element (, , , ) does what you need, use it. Custom widgets multiply your accessibility workload. (WCAG 2.1.1 Keyboard)

Semantic HTML First Assistive technology (screen readers, voice control, switch devices) relies on the browser's accessibility tree, which is built from semantic HTML. A with a click handler is invisible to that tree unless you do a lot of extra work — so use real elements whenever one exists. Avoid:

Skip Navigation Link Keyboard and screen reader users shouldn't have to tab through your entire nav menu on every single page just to reach the main content.

Responsive Text and Zoom Content must remain usable when zoomed to 200%, and text must reflow without requiring horizontal scrolling.

Captions and Transcripts for Media Any pre-recorded video with audio needs captions; audio-only content needs a transcript.

Descriptive Link and Button Text Screen reader users often navigate by pulling up a list of all links on a page in isolation, so "click here" and "read more" are meaningless out of context.

ARIA: A Seasoning, Not a Base Ingredient The first rule of ARIA is: don't use ARIA if a native HTML element already does the job. ARIA only changes how assistive tech announces something which it adds zero behavior (no keyboard support, no focus management). Misused ARIA can make a page less accessible than no ARIA at all.

Why it matters: , , , , and create landmarks that screen reader users can jump between instantly (WCAG 1.3.1 Info and Relationships).

A good test: if you removed the image, would the alt text let a screen reader user understand why it was there? "Image of chart" fails that test. "Q3 sales rose 24%" passes. (WCAG 1.1.1 Non-text Content)

Never rely on color alone to convey meaning:

(WCAG 1.4.3 Contrast (Minimum), 1.4.1 – Use of Color) Tools: Chrome DevTools' built-in contrast checker, or the WebAIM Contrast Checker.

If you must build a custom interactive widget (e.g., a custom dropdown), you have to manually replicate what native elements give you automatically:

If you want a custom focus style, replace it — don't remove it:

Using :focus-visible instead of :focus means the outline shows for keyboard users but doesn't awkwardly appear on every mouse click — best of both worlds. (WCAG 2.4.7 Focus Visible)

Style the visual size with CSS, not by picking a heading level that happens to look right. (WCAG 1.3.1, 2.4.6 Headings and Labels)

HTML
<div class="form-field">
  <label for="email">Email address</label>
  <input
    type="email"
    id="email"
    name="email"
    required
    aria-describedby="email-error"
    aria-invalid="true"
  />
  <span id="email-error" role="alert">
    Please enter a valid email address, e.g. name@example.com
  </span>
</div>

Where ARIA genuinely helps with dynamic regions, custom widgets, live status updates:

(WCAG 4.1.2 Name, Role, Value)

XML / MARKUP
<!-- Decorative image: hide it from assistive tech -->
<img src="divider-swirl.png" alt="">

<!-- Informative image: describe its purpose, not its appearance -->
<img src="chart-q3-sales.png" alt="Q3 sales rose 24% compared to Q2">

<!-- Functional image (e.g. inside a link/button) -->
<a href="/cart">
  <img src="cart-icon.svg" alt="View shopping cart">
</a>
XML / MARKUP
<!-- Bad: color is the only signal -->
<span style="color: red;">Invalid</span>

<!-- Good: icon + text + color -->
<span class="error">
  <svg aria-hidden="true">...</svg> Invalid: must be a valid email address
</span>
XML / MARKUP
// Bad: div "buttons" aren't keyboard-focusable at all
<div onClick={openModal}>Open Settings</div>

// Good: native button gets focus, Enter/Space, and semantics for free
<button onClick={openModal}>Open Settings</button>
HTML
function CustomToggle({ pressed, onToggle, children }) {
  return (
    <div
      role="button"
      tabIndex={0}
      aria-pressed={pressed}
      onClick={onToggle}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          onToggle();
        }
      }}
    >
      {children}
    </div>
  );
}
XML / MARKUP
/* This breaks keyboard navigation for everyone */
*:focus {
  outline: none;
}
HTML
<header>
  <nav aria-label="Primary">
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/projects">Projects</a></li>
      <li><a href="/contact">Contact</a></li>
    </ul>
  </nav>
</header>

<main>
  <h1>Page Title</h1>
  <article>...</article>
</main>

<footer>...</footer>
XML / MARKUP
button:focus-visible {
  outline: 3px solid #1a73e8;
  outline-offset: 2px;
}
XML / MARKUP
<!-- Bad: skips from h1 to h3, and uses h4 just because it "looked right" -->
<h1>Portfolio</h1>
<h3>Featured Projects</h3>
<h4 style="font-size: 24px;">E-commerce App</h4>

<!-- Good: sequential, based on document structure not visual size -->
<h1>Portfolio</h1>
<h2>Featured Projects</h2>
<h3>E-commerce App</h3>

Meaningful Alt Text for Images Every needs an alt attribute. But the content of that attribute matters more than its presence.

Color Contrast You Can Actually Prove Text needs a contrast ratio of at least: 4.5:1 for normal text (Level AA) 3:1 for large text (18pt+/14pt bold+) 3:1 for UI components and graphical objects (icons, form borders, focus indicators)

Full Keyboard Operability Every interactive element must be reachable and usable with the Tab, Shift+Tab, Enter, and Space keys alone, no mouse. This is the single most common failure I see in portfolios and production apps.

Visible Focus Indicators Never

Logical Heading Structure Headings aren't for styling text size they build a document outline that screen reader users can navigate (many jump straight from heading to heading).

Accessible Forms This is where most real-world sites fail. Every input needs a programmatically associated label, and errors need to be announced, not just shown visually.

Key points: must match the input's id, placeholder text is not a label substitute (it disappears once you type, and many screen readers skip it). aria-describedby links the error message to the input so screen readers announce it as part of the field. role="alert" announces dynamically-inserted error text immediately, without the user needing to navigate to it. aria-invalid="true" flags the field's error state programmatically. (WCAG 1.3.1, 3.3.1: Error Identification, 3.3.2. Labels or Instructions, 4.1.2. Name, Role, Value)

HTML
<!-- Announces status changes (e.g. "Saved", "3 items in cart") -->
<div role="status" aria-live="polite">
  {statusMessage}
</div>

<!-- Modal dialog: traps focus and is announced correctly -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm Deletion</h2>
  ...
</div>
HTML
<!-- Unnecessary and risky: reinventing a native element badly -->
<div role="button" aria-label="Close" onclick="closeModal()"></div>

<!-- Just use the real thing -->
<button aria-label="Close" onclick="closeModal()">✕</button>
HTML
/* Fails AA: light gray on white, ~2.3:1 */
.text-muted {
  color: #aaaaaa;
  background-color: #ffffff;
}

/* Passes AA: ~4.6:1 */
.text-muted {
  color: #757575;
  background-color: #ffffff;
}