agentsclimarketplace

Accessibility

Skill fernando-bertholdo/4-successful-AI-life/plugins/ui-excellence/skills/foundations/accessibility

Curated Claude Code plugin marketplace — opinionated, production-ready skills for AI-assisted work, focused on craft, rigor, and practical excellence.

Install
npx -y skills add fernando-bertholdo/4-successful-AI-life --skill accessibility

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Use when building or reviewing UI components to ensure WCAG 2.1 compliance, keyboard navigation, semantic HTML, ARIA attributes, color contrast, and assistive technology support.

SKILL.md

26.2 KB, as published. Nobody here has run it

UI Accessibility (WCAG 2.1)

Overview

This skill guides implementation and review of accessible UI components following WCAG 2.1 Level AA standards. Accessibility ensures your interface is usable by everyone—keyboard users, screen reader users, users with low vision, and users with cognitive disabilities.

Core principle: Semantic HTML first, ARIA last. Keyboard navigation and screen reader support are non-negotiable.


When to Use / When NOT to Use

✅ Use This Skill When

  • Building new UI components (buttons, modals, dropdowns, forms, tabs)
  • Adding keyboard navigation or focus management
  • Reviewing code for accessibility violations
  • Testing with screen readers or keyboard-only
  • Ensuring color contrast and visual focus indicators
  • Adding ARIA attributes to dynamic content

❌ Do NOT Use When

  • Component is purely decorative (no interaction needed)
  • Audience explicitly doesn't include users with accessibility needs (internally impossible—always assume diverse users)
  • You're skipping automated testing due to time constraints (violates DoD)

Core Concepts

1. Semantic HTML Structure

Foundation: Use meaningful HTML elements to convey structure and purpose.

<!-- ✅ CORRECT: Semantic structure -->
<header role="banner">
  <nav aria-label="Main navigation">
    <ul>
      <li><a href="/home">Home</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
</header>

<main id="main-content">
  <article>
    <h1>Page Title</h1>
    <section>
      <h2>Section Heading</h2>
      <p>Content...</p>
    </section>
  </article>
</main>

<footer>
  <p>&copy; 2026 Company</p>
</footer>

<!-- ✅ CORRECT: Proper heading hierarchy -->
<h1>Main Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>

<!-- ❌ WRONG: Non-semantic div soup -->
<div class="header">
  <div class="nav">
    <div>Home</div>
    <div>About</div>
  </div>
</div>
<div class="main">
  <div class="title">Page Title</div>
</div>

<!-- ❌ WRONG: Skipping heading hierarchy -->
<h1>Main Title</h1>
<h3>Jumps to h3 (breaks hierarchy)</h3>

Label Association Rules:

<!-- ✅ CORRECT: Explicit label-input association -->
<label for="email-input">Email Address</label>
<input id="email-input" type="email" required />

<!-- ✅ CORRECT: Implicit label wrapping -->
<label>
  Email Address
  <input type="email" required />
</label>

<!-- ❌ WRONG: Placeholder alone (insufficient) -->
<input type="email" placeholder="Enter email" />
<!-- Screen readers read placeholder only on focus; users don't see it in context -->

<!-- ❌ WRONG: No association -->
<label>Email Address</label>
<input type="email" />

2. Keyboard Navigation

Principle: All interactive features usable via keyboard—Tab, Shift+Tab, Enter, Space, Arrow keys, Escape.

Key Bindings:

  • Tab / Shift+Tab: Move focus forward/backward
  • Enter / Space: Activate buttons, toggle checkboxes
  • Arrow keys: Navigate lists, menus, sliders, tabs (up/down for vertical; left/right for horizontal)
  • Home / End: Jump to first/last item in list
  • Escape: Close modal, dropdown, or menu

Focus Management:

<!-- ✅ CORRECT: Explicit focus for interactive elements -->
<button tabindex="0">Click me</button>

<!-- ✅ CORRECT: Programmatic focus (not in tab order) -->
<div tabindex="-1" id="skip-target">Skip to main content</div>

<!-- ❌ WRONG: Positive tabindex > 0 (breaks tab order) -->
<button tabindex="1">First</button>
<button tabindex="2">Second</button>
<!-- Order now: tabindex 1, 2, then remaining DOM order (confusing!) -->

<!-- ❌ WRONG: Removed focus outline -->
button:focus {
  outline: none; /* NEVER DO THIS */
}

Skip Links (Focus Management):

<!-- Place at TOP of page, hidden by default -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<style>
  .skip-link {
    position: absolute;
    left: -9999px;
    z-index: 999;
  }

  .skip-link:focus {
    left: 0;
    top: 0;
    background: #000;
    color: #fff;
    padding: 8px 12px;
  }
</style>

<main id="main-content" tabindex="-1">
  <!-- Content -->
</main>

3. ARIA Attributes

Golden Rule: Semantic HTML first. Use ARIA only when native HTML doesn't exist or to enhance semantics.

Common ARIA Attributes

AttributePurposeExample
aria-labelNames element (for icons, buttons without text)<button aria-label="Close menu">×</button>
aria-labelledbyLinks to another element as label<h2 id="dialog-title">Confirm Delete</h2><div aria-labelledby="dialog-title">...
aria-describedbyAdds descriptive text (error, hint)<input aria-describedby="pwd-hint" /><p id="pwd-hint">Min 8 chars
aria-liveAnnounces dynamic content ("polite" or "assertive")<div aria-live="polite">Item added to cart</div>
aria-hiddenHides from screen readers (decorative only)<span aria-hidden="true">→</span>
roleSemantic role<div role="dialog">...
aria-expandedIndicates expanded/collapsed state<button aria-expanded="false">Menu</button>
aria-haspopupSignals a popup exists<button aria-haspopup="menu">Options</button>
aria-selectedCurrent selection in list/tabs<button role="tab" aria-selected="true">Tab 1</button>
aria-modalMarks modal dialogs<div role="dialog" aria-modal="true">...
aria-requiredFlags required form fields<input aria-required="true" />
aria-invalidIndicates validation error<input aria-invalid="true" aria-describedby="error-msg" />
aria-atomicReads entire content block<div aria-live="polite" aria-atomic="true">Status: Updated</div>

4. Color Contrast & Visual Focus

WCAG Contrast Ratios:

LevelNormal TextLarge Text (18pt+)
AA (minimum)4.5:13:1
AAA (enhanced)7:14.5:1

Visual Focus Indicators (Required):

/* ✅ CORRECT: Visible focus indicator */
button:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

/* ✅ CORRECT: Alternative (background change) */
button:focus {
  background-color: #e6f2ff;
  box-shadow: inset 0 0 0 2px #0066cc;
}

/* ❌ WRONG: Removing focus outline */
button:focus {
  outline: none; /* FORBIDDEN */
}

/* ❌ WRONG: Low contrast focus */
button:focus {
  outline: 1px solid #ccc; /* Too subtle */
}

Never Convey Info by Color Alone:

<!-- ❌ WRONG: Color only -->
<span style="color: red;">This field is required</span>

<!-- ✅ CORRECT: Color + icon/text -->
<span style="color: red;">
  <span aria-hidden="true">*</span> This field is required
</span>

<!-- ✅ CORRECT: Color + pattern -->
<input type="checkbox" style="border: 2px solid red;" />
<label>I agree (required)</label>

5. Testing & Validation

Automated Tools:

  • axe DevTools: Browser extension for violations (Chrome, Firefox)
  • Lighthouse: Chrome DevTools → Lighthouse → Accessibility (target: 90+)
  • Pa11y: CLI tool for automated scanning
  • jest-axe: Automated testing in Jest

Manual Testing:

  1. Keyboard-only: Unplug mouse; navigate using Tab, Enter, Space, arrows
  2. Screen readers: NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS)
  3. Color contrast: WebAIM Contrast Checker
  4. Zoom: Test at 200% zoom; ensure nothing breaks

Acceptance Criteria:

  • Zero axe DevTools violations
  • Lighthouse Accessibility ≥ 90
  • All features functional via keyboard
  • All images have alt text
  • All form inputs have labels

Component Patterns

Pattern 1: Accessible Dropdown Menu

import { useState, useRef, useEffect } from 'react';

export function AccessibleDropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(0);
  const buttonRef = useRef(null);
  const menuRef = useRef(null);

  const options = ['Edit', 'Duplicate', 'Delete'];

  // Close on Escape
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.key === 'Escape' && isOpen) {
        setIsOpen(false);
        buttonRef.current?.focus();
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen]);

  // Focus management for arrow keys
  const handleMenuKeyDown = (e) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setSelectedIndex((i) => (i + 1) % options.length);
        break;
      case 'ArrowUp':
        e.preventDefault();
        setSelectedIndex((i) => (i - 1 + options.length) % options.length);
        break;
      case 'Home':
        e.preventDefault();
        setSelectedIndex(0);
        break;
      case 'End':
        e.preventDefault();
        setSelectedIndex(options.length - 1);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        handleSelect(options[selectedIndex]);
        break;
      default:
        break;
    }
  };

  const handleSelect = (option) => {
    console.log('Selected:', option);
    setIsOpen(false);
    buttonRef.current?.focus();
  };

  return (
    <div className="dropdown">
      <button
        ref={buttonRef}
        aria-haspopup="menu"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onClick={() => setIsOpen(!isOpen)}
      >
        Options
      </button>

      {isOpen && (
        <ul
          id="dropdown-menu"
          ref={menuRef}
          role="menu"
          onKeyDown={handleMenuKeyDown}
        >
          {options.map((option, i) => (
            <li key={option} role="none">
              <button
                role="menuitem"
                aria-selected={i === selectedIndex}
                onMouseEnter={() => setSelectedIndex(i)}
                onClick={() => handleSelect(option)}
              >
                {option}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Pattern 2: Accessible Modal Dialog

import { useEffect, useRef } from 'react';

export function AccessibleModal({ isOpen, onClose, title, children }) {
  const dialogRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    // Store the element that opened the modal for focus restore
    const previouslyFocused = document.activeElement;

    // Focus the modal
    dialogRef.current?.focus();

    // Focus trap: trap focus inside modal
    const handleKeyDown = (e) => {
      if (e.key === 'Escape') {
        onClose();
        previouslyFocused?.focus();
        return;
      }

      // Trap Tab inside modal
      if (e.key === 'Tab') {
        const focusableElements = dialogRef.current?.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        if (!focusableElements || focusableElements.length === 0) return;

        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];

        if (e.shiftKey) {
          if (document.activeElement === firstElement) {
            e.preventDefault();
            lastElement.focus();
          }
        } else {
          if (document.activeElement === lastElement) {
            e.preventDefault();
            firstElement.focus();
          }
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      previouslyFocused?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <>
      {/* Backdrop */}
      <div
        className="modal-backdrop"
        onClick={onClose}
        aria-hidden="true"
      />

      {/* Modal */}
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby="modal-description"
        className="modal"
        tabIndex="-1"
      >
        <h2 id="modal-title">{title}</h2>
        <div id="modal-description">{children}</div>
        <button onClick={onClose}>Close</button>
      </div>
    </>
  );
}

Pattern 3: Accessible Alert/Notification

export function AccessibleAlert({ type, message, onDismiss }) {
  // type: 'error' | 'success' | 'warning' | 'info'
  const isError = type === 'error';

  return (
    <div
      role="alert"
      aria-live={isError ? 'assertive' : 'polite'}
      aria-atomic="true"
      className={`alert alert-${type}`}
    >
      <span aria-hidden="true">
        {type === 'error' && '⚠️'}
        {type === 'success' && '✓'}
        {type === 'warning' && '⚡'}
        {type === 'info' && 'ℹ️'}
      </span>
      {message}
      {onDismiss && (
        <button
          aria-label="Dismiss notification"
          onClick={onDismiss}
        >
          ×
        </button>
      )}
    </div>
  );
}

Pattern 4: Accessible Form with Error Handling

import { useState } from 'react';

export function AccessibleForm() {
  const [formData, setFormData] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState({});

  const validate = () => {
    const newErrors = {};

    if (!formData.email) {
      newErrors.email = 'Email is required';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      newErrors.email = 'Email must follow format: [email protected]';
    }

    if (!formData.password) {
      newErrors.password = 'Password is required';
    } else if (formData.password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validate()) {
      console.log('Form submitted:', formData);
    }
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      {/* Email Field */}
      <div className="form-group">
        <label htmlFor="email-input">
          Email Address <span aria-hidden="true">*</span>
        </label>
        <input
          id="email-input"
          type="email"
          value={formData.email}
          onChange={(e) => setFormData({ ...formData, email: e.target.value })}
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert" className="error">
            {errors.email}
          </span>
        )}
      </div>

      {/* Password Field */}
      <div className="form-group">
        <label htmlFor="password-input">
          Password <span aria-hidden="true">*</span>
        </label>
        <input
          id="password-input"
          type="password"
          value={formData.password}
          onChange={(e) => setFormData({ ...formData, password: e.target.value })}
          aria-required="true"
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? 'password-error' : 'password-hint'}
        />
        <p id="password-hint" className="hint">
          Minimum 8 characters
        </p>
        {errors.password && (
          <span id="password-error" role="alert" className="error">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">Sign In</button>
    </form>
  );
}

Pattern 5: Accessible Tab Component

import { useState } from 'react';

export function AccessibleTabs() {
  const [activeTab, setActiveTab] = useState(0);

  const tabs = [
    { label: 'Overview', content: 'Overview content here' },
    { label: 'Details', content: 'Details content here' },
    { label: 'Related', content: 'Related items here' },
  ];

  const handleTabKeyDown = (e, index) => {
    let nextIndex = index;

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        nextIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case 'ArrowRight':
        e.preventDefault();
        nextIndex = (index + 1) % tabs.length;
        break;
      case 'Home':
        e.preventDefault();
        nextIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        nextIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    setActiveTab(nextIndex);
    // Focus the newly active tab
    document.getElementById(`tab-${nextIndex}`)?.focus();
  };

  return (
    <div className="tabs">
      {/* Tablist */}
      <div role="tablist" aria-label="Content sections">
        {tabs.map((tab, i) => (
          <button
            key={i}
            id={`tab-${i}`}
            role="tab"
            aria-selected={activeTab === i}
            aria-controls={`tabpanel-${i}`}
            tabIndex={activeTab === i ? 0 : -1}
            onClick={() => setActiveTab(i)}
            onKeyDown={(e) => handleTabKeyDown(e, i)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {/* Tabpanels */}
      {tabs.map((tab, i) => (
        <div
          key={i}
          id={`tabpanel-${i}`}
          role="tabpanel"
          aria-labelledby={`tab-${i}`}
          hidden={activeTab !== i}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Pattern 6: Testing with axe-core and Jest

import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { AccessibleForm } from './AccessibleForm';

expect.extend(toHaveNoViolations);

describe('Accessibility Tests', () => {
  test('form has no accessibility violations', async () => {
    const { container } = render(<AccessibleForm />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  test('form is keyboard navigable', () => {
    render(<AccessibleForm />);
    const emailInput = screen.getByLabelText('Email Address');
    const passwordInput = screen.getByLabelText('Password');
    const submitButton = screen.getByRole('button', { name: /sign in/i });

    // Tab focus order
    emailInput.focus();
    expect(document.activeElement).toBe(emailInput);

    // Tab to password
    const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' });
    emailInput.dispatchEvent(tabEvent);

    // Can activate button with Space/Enter
    submitButton.focus();
    const spaceEvent = new KeyboardEvent('keydown', { key: ' ' });
    submitButton.dispatchEvent(spaceEvent);
  });

  test('modal traps focus', () => {
    const { rerender } = render(
      <AccessibleModal isOpen={true} onClose={() => {}} title="Test Modal">
        <button>Action 1</button>
        <button>Action 2</button>
      </AccessibleModal>
    );

    const buttons = screen.getAllByRole('button');
    const lastButton = buttons[buttons.length - 1];

    // Focus trap: Tab on last button should go to first
    lastButton.focus();
    const tabEvent = new KeyboardEvent('keydown', {
      key: 'Tab',
      shiftKey: false,
    });
    expect(lastButton === document.activeElement);
  });

  test('alert is announced by screen readers', () => {
    render(
      <AccessibleAlert
        type="success"
        message="Item saved successfully"
      />
    );
    const alert = screen.getByRole('alert');
    expect(alert).toHaveAttribute('aria-live', 'polite');
    expect(alert).toHaveAttribute('aria-atomic', 'true');
  });
});

Quick Reference Tables

Keyboard Events Mapping

User ActionKey(s)ComponentPurpose
NavigateTab / Shift+TabAllFocus previous/next
ActivateEnter / SpaceButton, Checkbox, RadioTrigger action
Navigate list↑↓Menu, Listbox, SelectMove between items
Navigate horizontal← →Tabs, Slider, MenuMove between options
Go to startHomeTabs, List, SliderJump to first item
Go to endEndTabs, List, SliderJump to last item
Close/CancelEscapeModal, Dropdown, MenuDismiss overlay
Alphabetica–zListbox, ComboboxJump to matching item

ARIA Role Reference (Common)

RoleElementUsage
button<div>, <span> (non-button)Make non-button interactive
menu / menuitem<ul>, <li>Popup/context menu
listbox / optionCustom dropdownAccessible select replacement
tab / tablist / tabpanelTab UITab container and panels
dialogModal / overlayModal dialog (+ aria-modal)
alertNotificationAssertive/polite announcement
regionGeneric sectionLandmark region (aria-label required)
navigationNav linksMain/secondary navigation

Contrast Ratio Quick Check

ScenarioMin RatioExample
Body text (AA)4.5:1#333 on #fff = 12.6:1 ✓
Large text (AA)3:1#666 on #fff = 5.74:1 ✓
Icon/UI (AA)3:1#0066cc on #fff = 8.59:1 ✓
Body text (AAA)7:1#555 on #fff = 9.26:1 ✓

Review Checklist

Use this checklist before marking a component accessible:

Semantic HTML

  • Uses <button> for buttons, not <div onclick>
  • Uses <nav>, <main>, <header>, <footer> for landmarks
  • Heading hierarchy is correct (<h1><h2><h3>, no skips)
  • All form inputs have <label> or aria-label
  • Images have meaningful alt or decorative alt=""

Keyboard Navigation

  • Tab order is logical (left-to-right, top-to-bottom)
  • All interactive elements are reachable via Tab
  • No outline: none without replacement focus indicator
  • Enter/Space activates buttons
  • Arrow keys work in menus/lists/tabs
  • Escape closes modals/dropdowns
  • Focus visible with ≥2px outline or equivalent

ARIA

  • ARIA attributes are correct and not redundant
  • aria-live used for dynamic announcements
  • aria-hidden only on decorative elements
  • Modal has role="dialog" + aria-modal="true"
  • Form errors linked with aria-describedby
  • Required fields marked with aria-required

Visual & Color

  • Text contrast ≥4.5:1 (AA) or 7:1 (AAA)
  • Information not conveyed by color alone
  • Focus indicators visible and distinct
  • Component responsive at 200% zoom

Testing

  • Axe DevTools: Zero violations
  • Lighthouse Accessibility: ≥90
  • Manual keyboard testing: All features work
  • Screen reader tested (NVDA/VoiceOver/JAWS)
  • Jest/axe unit tests included

Common Mistakes

❌ Mistake 1: Placeholder as Label

// WRONG: Placeholder disappears on focus
<input type="email" placeholder="Enter your email" />

// CORRECT: Explicit label
<label htmlFor="email">Email</label>
<input id="email" type="email" />

Why: Placeholders are hidden when users focus the input. Screen reader users won't hear what the field is for.


❌ Mistake 2: outline: none Without Replacement

/* WRONG: Keyboard users can't see focus */
button:focus {
  outline: none;
}

/* CORRECT: Provide visible focus */
button:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

Why: Keyboard users rely on focus indicators to navigate. Removing it breaks navigation.


❌ Mistake 3: Positive tabindex

// WRONG: Breaks natural tab order
<button tabindex="2">Second (confusing!)</button>
<button tabindex="1">First</button>
<button>Third (where does it go?)</button>

// CORRECT: Use tabindex 0 or -1 only
<button>First</button>  // tabindex 0 (implicit)
<button>Second</button>
<button tabindex="-1">Hidden from tab order</button>

Why: Positive tabindex disrupts the natural DOM order. Screen reader and keyboard users get confused.


❌ Mistake 4: Color Alone to Convey Info

<!-- WRONG: Only color indicates required -->
<input style="border: 1px solid red;" />

<!-- CORRECT: Color + icon/text -->
<label>
  Email <span aria-hidden="true">*</span>
</label>
<input />

Why: Users with color blindness can't see the distinction. Pair color with icons or text.


❌ Mistake 5: No Focus Trap in Modal

// WRONG: Focus can escape the modal
<div role="dialog">
  <button>Close</button>
</div>

// CORRECT: Focus is trapped
useEffect(() => {
  const handleKeyDown = (e) => {
    if (e.key === 'Tab') {
      // Trap focus inside modal
    }
  };
  document.addEventListener('keydown', handleKeyDown);
}, []);

Why: Keyboard users can accidentally tab out of a modal, losing context.


❌ Mistake 6: Missing Alt Text on Images

<!-- WRONG: No alt text -->
<img src="chart.png" />

<!-- CORRECT: Meaningful alt -->
<img src="chart.png" alt="Sales increased 25% in Q3" />

<!-- CORRECT: Decorative image -->
<img src="spacer.png" alt="" aria-hidden="true" />

Why: Screen reader users hear "image, chart-dot-png" with no context. Provide descriptive alt text.


❌ Mistake 7: No Error Messages Linked to Fields

// WRONG: Error exists but not linked
<input type="email" />
<span>Invalid email format</span>

// CORRECT: Error linked with aria-describedby
<input
  id="email"
  type="email"
  aria-invalid="true"
  aria-describedby="email-error"
/>
<span id="email-error" role="alert">Invalid email format: [email protected]</span>

Why: Screen reader users won't know the error is related to the input. Link it explicitly.


❌ Mistake 8: No Announce for Dynamic Content

// WRONG: Item added silently
<div>Items in cart: 5</div>

// CORRECT: Announce change
<div aria-live="polite" aria-atomic="true">
  Item added to cart. Items: 5
</div>

Why: Screen reader users don't hear dynamic updates. Use aria-live to announce.


Summary

Accessibility is not a feature—it's a requirement. Every component must be:

  1. Keyboard accessible — All features work without a mouse
  2. Screen reader friendly — Semantic HTML + ARIA
  3. Visually accessible — Sufficient contrast and focus indicators
  4. Well tested — Axe, Lighthouse, manual keyboard, screen reader

Start with semantic HTML. Add ARIA only when needed. Test with real tools and real users.


Last updated: 2026-03-30 Version: 1.0.0

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.