Skip to content
Back to skills

007 Accessibility Guide 7e852b1a

ASecurity

Comprehensive guide to building WCAG 2.1 AA compliant Lightning Web Components. ---

  • 9 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 11, 2026
designjavascriptgojavabashawstestingapiperformance

Works with

  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 11, 2026

npx -y skills add tools-only/X-Skills --skill 007-accessibility-guide_7e852b1a --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of 007 Accessibility Guide 7e852b1a?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for 007 Accessibility Guide 7e852b1a
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tools-only-007-accessibility-guide-7e852b1a/badge)](https://www.skillsdirectory.com/skills/tools-only-007-accessibility-guide-7e852b1a)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

SKILL.md
# Accessibility Guide for LWC

Comprehensive guide to building WCAG 2.1 AA compliant Lightning Web Components.

---

## Table of Contents

1. [Accessibility Standards](#accessibility-standards)
2. [Semantic HTML](#semantic-html)
3. [ARIA Attributes](#aria-attributes)
4. [Keyboard Navigation](#keyboard-navigation)
5. [Focus Management](#focus-management)
6. [Color and Contrast](#color-and-contrast)
7. [Screen Reader Support](#screen-reader-support)
8. [Live Regions](#live-regions)
9. [Form Accessibility](#form-accessibility)
10. [Common Patterns](#common-patterns)
11. [Testing](#testing)
12. [Tools and Resources](#tools-and-resources)

---

## Accessibility Standards

### WCAG 2.1 AA Compliance

All Lightning Web Components should meet WCAG 2.1 Level AA standards.

| Principle | Description |
|-----------|-------------|
| **Perceivable** | Information must be presentable to users in ways they can perceive |
| **Operable** | UI components must be operable (keyboard, mouse, voice) |
| **Understandable** | Information and UI must be understandable |
| **Robust** | Content must work with assistive technologies |

### Key Requirements

| Requirement | Standard | Implementation |
|-------------|----------|----------------|
| **Color contrast** | 4.5:1 for normal text, 3:1 for large text | Use SLDS color tokens |
| **Keyboard navigation** | All interactive elements accessible via keyboard | Tab order, Enter/Space triggers |
| **Screen reader support** | ARIA labels, roles, live regions | Proper semantic HTML + ARIA |
| **Focus indicators** | Visible focus state | Use SLDS focus utilities |
| **Alternative text** | All images have alt text | `alt` attribute on images |

---

## Semantic HTML

### Use Proper HTML Elements

```html
<!-- BAD: Non-semantic markup -->
<div onclick={handleClick}>Click me</div>

<!-- GOOD: Semantic button -->
<button onclick={handleClick}>Click me</button>
```

### Headings Hierarchy

```html
<!-- BAD: Skipping heading levels -->
<h1>Page Title</h1>
<h3>Subsection</h3> <!-- Skipped h2 -->

<!-- GOOD: Logical heading structure -->
<h1>Page Title</h1>
<h2>Main Section</h2>
<h3>Subsection</h3>
```

### Landmarks

```html
<template>
    <header class="slds-page-header">
        <h1>Dashboard</h1>
    </header>

    <nav aria-label="Primary navigation">
        <ul>
            <li><a href="#home">Home</a></li>
            <li><a href="#accounts">Accounts</a></li>
        </ul>
    </nav>

    <main>
        <article>
            <h2>Account Details</h2>
            <!-- Content -->
        </article>
    </main>

    <aside aria-label="Related information">
        <!-- Sidebar content -->
    </aside>

    <footer>
        <p>Copyright 2025</p>
    </footer>
</template>
```

---

## ARIA Attributes

### ARIA Labels

```html
<!-- Icon button without visible text -->
<button aria-label="Delete record" onclick={handleDelete}>
    <lightning-icon icon-name="utility:delete" size="small"></lightning-icon>
</button>

<!-- Form field with additional context -->
<lightning-input
    label="Phone"
    aria-describedby="phone-help"
    value={phone}
    onchange={handlePhoneChange}>
</lightning-input>
<div id="phone-help" class="slds-text-color_weak">
    Enter phone number with country code
</div>
```

### ARIA Roles

```html
<!-- Custom list -->
<div role="list">
    <div role="listitem">Item 1</div>
    <div role="listitem">Item 2</div>
</div>

<!-- Alert message -->
<div role="alert" class="slds-notify slds-notify_alert">
    <span class="slds-assistive-text">Error</span>
    <p>Form validation failed</p>
</div>

<!-- Dialog/Modal -->
<div role="dialog"
     aria-modal="true"
     aria-labelledby="modal-heading"
     aria-describedby="modal-description">
    <h2 id="modal-heading">Confirm Action</h2>
    <p id="modal-description">Are you sure you want to delete this record?</p>
</div>
```

### ARIA States

```html
<!-- Expandable section -->
<button
    aria-expanded={isExpanded}
    aria-controls="details-section"
    onclick={toggleExpanded}>
    Show Details
</button>
<div id="details-section" class={sectionClass}>
    <!-- Details content -->
</div>

<!-- Loading state -->
<div aria-busy={isLoading}>
    <template lwc:if={isLoading}>
        <lightning-spinner alternative-text="Loading data"></lightning-spinner>
    </template>
    <template lwc:else>
        <!-- Content -->
    </template>
</div>

<!-- Required field -->
<lightning-input
    label="Name"
    required
    aria-required="true"
    value={name}>
</lightning-input>
```

---

## Keyboard Navigation

### Tab Order

```html
<!-- Natural tab order -->
<form>
    <lightning-input label="First Name" tabindex="0"></lightning-input>
    <lightning-input label="Last Name" tabindex="0"></lightning-input>
    <lightning-button label="Submit" tabindex="0"></lightning-button>
</form>

<!-- Skip to main content link -->
<a href="#main-content" class="slds-assistive-text slds-assistive-text_focus">
    Skip to main content
</a>
<main id="main-content">
    <!-- Content -->
</main>
```

### Keyboard Event Handlers

```javascript
// accountCard.js
export default class AccountCard extends LightningElement {
    handleKeyDown(event) {
        // Enter or Space activates
        if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault();
            this.handleSelect();
        }

        // Arrow navigation
        if (event.key === 'ArrowDown') {
            event.preventDefault();
            this.focusNextItem();
        } else if (event.key === 'ArrowUp') {
            event.preventDefault();
            this.focusPreviousItem();
        }

        // Escape closes
        if (event.key === 'Escape') {
            this.handleClose();
        }
    }

    focusNextItem() {
        const items = this.template.querySelectorAll('[role="listitem"]');
        const currentIndex = Array.from(items).indexOf(document.activeElement);
        const nextIndex = (currentIndex + 1) % items.length;
        items[nextIndex].focus();
    }

    focusPreviousItem() {
        const items = this.template.querySelectorAll('[role="listitem"]');
        const currentIndex = Array.from(items).indexOf(document.activeElement);
        const prevIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1;
        items[prevIndex].focus();
    }
}
```

```html
<!-- accountCard.html -->
<template>
    <div role="list">
        <template for:each={accounts} for:item="account">
            <div key={account.Id}
                 role="listitem"
                 tabindex="0"
                 onkeydown={handleKeyDown}
                 onclick={handleSelect}
                 data-id={account.Id}>
                {account.Name}
            </div>
        </template>
    </div>
</template>
```

---

## Focus Management

### Focus Trap in Modals

```javascript
// composableModal.js
export default class ComposableModal extends LightningElement {
    _focusableElements = [];
    _isOpen = false;

    @api
    toggleModal() {
        this._isOpen = !this._isOpen;

        if (this._isOpen) {
            // Capture focusable elements
            this._focusableElements = this.getFocusableElements();

            // Focus first element
            requestAnimationFrame(() => {
                this._focusableElements[0]?.focus();
            });

            // Add keyboard listener
            window.addEventListener('keydown', this._handleKeyDown);

            // Store previous focus
            this._previousFocus = document.activeElement;
        } else {
            // Remove keyboard listener
            window.removeEventListener('keydown', this._handleKeyDown);

            // Restore focus
            this._previousFocus?.focus();
        }
    }

    getFocusableElements() {
        const selector = [
            'a[href]',
            'button:not([disabled])',
            'input:not([disabled])',
            'select:not([disabled])',
            'textarea:not([disabled])',
            '[tabindex]:not([tabindex="-1"])'
        ].join(',');

        return Array.from(this.template.querySelectorAll(selector));
    }

    _handleKeyDown = (event) => {
        if (event.key === 'Tab') {
            this.trapFocus(event);
        } else if (event.key === 'Escape') {
            this.toggleModal();
        }
    }

    trapFocus(event) {
        const firstElement = this._focusableElements[0];
        const lastElement = this._focusableElements[this._focusableElements.length - 1];
        const activeElement = this.template.activeElement;

        if (event.shiftKey) {
            // Shift+Tab: Moving backward
            if (activeElement === firstElement) {
                event.preventDefault();
                lastElement.focus();
            }
        } else {
            // Tab: Moving forward
            if (activeElement === lastElement) {
                event.preventDefault();
                firstElement.focus();
            }
        }
    }

    disconnectedCallback() {
        window.removeEventListener('keydown', this._handleKeyDown);
    }
}
```

### Managing Focus After Actions

```javascript
handleDelete(event) {
    const itemId = event.target.dataset.id;
    const itemIndex = this.items.findIndex(item => item.Id === itemId);

    // Delete item
    this.items = this.items.filter(item => item.Id !== itemId);

    // Focus next item or previous if last item deleted
    requestAnimationFrame(() => {
        const focusIndex = itemIndex < this.items.length ? itemIndex : itemIndex - 1;
        if (focusIndex >= 0) {
            const nextItem = this.template.querySelector(`[data-id="${this.items[focusIndex].Id}"]`);
            nextItem?.focus();
        }
    });
}
```

---

## Color and Contrast

### Use SLDS Color Tokens

```css
/* BAD: Hardcoded colors */
.my-component {
    color: #333333;
    background-color: #ffffff;
    border-color: #dddddd;
}

/* GOOD: SLDS tokens with proper contrast */
.my-component {
    color: var(--slds-g-color-on-surface, #181818);
    background-color: var(--slds-g-color-surface-container-1, #ffffff);
    border-color: var(--slds-g-color-border-1, #c9c9c9);
}
```

### Testing Contrast

```html
<!-- Text: 4.5:1 minimum contrast ratio -->
<p class="slds-text-body_regular">Regular body text</p>

<!-- Large text (18pt+): 3:1 minimum -->
<h1 class="slds-text-heading_large">Large heading</h1>

<!-- Links: Must be distinguishable from surrounding text -->
<p>
    Visit our <a href="/help" class="slds-text-link">help center</a> for support.
</p>
```

### Color Independence

```html
<!-- BAD: Relies only on color to convey status -->
<span class="text-red">Error</span>

<!-- GOOD: Uses icon + text + color -->
<span class="slds-text-color_error">
    <lightning-icon icon-name="utility:error" size="x-small"></lightning-icon>
    Error
</span>

<!-- GOOD: Status indicators with patterns -->
<div class="slds-badge slds-theme_error">
    <lightning-icon icon-name="utility:close" size="xx-small"></lightning-icon>
    Failed
</div>
```

---

## Screen Reader Support

### Assistive Text

```html
<!-- Hidden text for screen readers -->
<span class="slds-assistive-text">Required field</span>
<lightning-input label="Email" required value={email}></lightning-input>

<!-- Button with icon only -->
<button aria-label="Edit record">
    <lightning-icon icon-name="utility:edit" size="small"></lightning-icon>
    <span class="slds-assistive-text">Edit</span>
</button>

<!-- Loading state announcement -->
<template lwc:if={isLoading}>
    <span class="slds-assistive-text">Loading data, please wait</span>
    <lightning-spinner size="small"></lightning-spinner>
</template>
```

### Image Alternative Text

```html
<!-- Decorative images (no alt needed, hide from screen readers) -->
<img src="decorative-icon.png" alt="" role="presentation">

<!-- Informative images (descriptive alt text) -->
<img src="chart.png" alt="Sales trend showing 15% increase over last quarter">

<!-- Functional images (describe action) -->
<a href="/profile">
    <img src="user-avatar.png" alt="View your profile">
</a>
```

---

## Live Regions

### ARIA Live Regions

```javascript
// notificationComponent.js
export default class NotificationComponent extends LightningElement {
    @track messages = [];

    addMessage(message, type = 'info') {
        const id = Date.now();
        this.messages = [...this.messages, { id, message, type }];

        // Auto-remove after 5 seconds
        setTimeout(() => {
            this.messages = this.messages.filter(m => m.id !== id);
        }, 5000);
    }
}
```

```html
<!-- notificationComponent.html -->
<template>
    <!-- Polite: Announced after current speech -->
    <div aria-live="polite" aria-atomic="true" class="slds-assistive-text">
        <template for:each={messages} for:item="msg">
            <p key={msg.id}>{msg.message}</p>
        </template>
    </div>

    <!-- Visual notifications -->
    <div class="slds-notify-container">
        <template for:each={messages} for:item="msg">
            <div key={msg.id} class={msg.type} role="status">
                <p>{msg.message}</p>
            </div>
        </template>
    </div>
</template>
```

### Assertive vs Polite

```html
<!-- Polite: Non-urgent updates (search results, status changes) -->
<div aria-live="polite" class="slds-assistive-text">
    {searchResultsCount} results found
</div>

<!-- Assertive: Urgent messages (errors, warnings) -->
<div aria-live="assertive" role="alert" class="slds-notify slds-notify_alert">
    <span class="slds-assistive-text">Error</span>
    Form submission failed. Please correct the errors and try again.
</div>
```

---

## Form Accessibility

### Accessible Form Fields

```html
<template>
    <form onsubmit={handleSubmit}>
        <!-- Required field with validation -->
        <lightning-input
            label="Email"
            type="email"
            name="email"
            required
            value={email}
            onchange={handleEmailChange}
            message-when-value-missing="Email is required"
            message-when-bad-input="Please enter a valid email">
        </lightning-input>

        <!-- Field with help text -->
        <lightning-input
            label="Phone"
            type="tel"
            value={phone}
            field-level-help="Enter phone number with country code"
            aria-describedby="phone-help"
            onchange={handlePhoneChange}>
        </lightning-input>
        <div id="phone-help" class="slds-text-color_weak slds-m-top_xx-small">
            Format: +1 (555) 555-5555
        </div>

        <!-- Error state -->
        <template lwc:if={errors.industry}>
            <lightning-input
                label="Industry"
                value={industry}
                variant="label-hidden"
                aria-invalid="true"
                aria-describedby="industry-error"
                class="slds-has-error">
            </lightning-input>
            <div id="industry-error" class="slds-form-element__help" role="alert">
                {errors.industry}
            </div>
        </template>

        <!-- Submit button -->
        <lightning-button
            type="submit"
            label="Save"
            variant="brand"
            disabled={isSubmitting}>
        </lightning-button>
    </form>
</template>
```

### Fieldset and Legend

```html
<!-- Radio button group -->
<fieldset class="slds-form-element">
    <legend class="slds-form-element__legend slds-form-element__label">
        Contact Method <abbr class="slds-required" title="required">*</abbr>
    </legend>
    <div class="slds-form-element__control">
        <lightning-radio-group
            name="contactMethod"
            label="Contact Method"
            options={contactOptions}
            value={selectedMethod}
            onchange={handleMethodChange}
            variant="label-hidden"
            required>
        </lightning-radio-group>
    </div>
</fieldset>
```

---

## Common Patterns

### Accessible Tabs

```javascript
// tabsComponent.js
export default class TabsComponent extends LightningElement {
    @track activeTab = 'tab1';

    handleTabKeyDown(event) {
        const tabs = Array.from(this.template.querySelectorAll('[role="tab"]'));
        const currentIndex = tabs.indexOf(event.target);

        let nextIndex;
        if (event.key === 'ArrowRight') {
            nextIndex = (currentIndex + 1) % tabs.length;
        } else if (event.key === 'ArrowLeft') {
            nextIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
        } else if (event.key === 'Home') {
            nextIndex = 0;
        } else if (event.key === 'End') {
            nextIndex = tabs.length - 1;
        }

        if (nextIndex !== undefined) {
            event.preventDefault();
            tabs[nextIndex].focus();
            this.activeTab = tabs[nextIndex].dataset.tab;
        }
    }

    handleTabClick(event) {
        this.activeTab = event.currentTarget.dataset.tab;
    }
}
```

```html
<!-- tabsComponent.html -->
<template>
    <div class="slds-tabs_default">
        <ul role="tablist" class="slds-tabs_default__nav">
            <li class="slds-tabs_default__item" role="presentation">
                <a role="tab"
                   tabindex={tab1Tabindex}
                   aria-selected={isTab1Active}
                   aria-controls="tab1-panel"
                   data-tab="tab1"
                   onclick={handleTabClick}
                   onkeydown={handleTabKeyDown}>
                    Tab 1
                </a>
            </li>
            <li class="slds-tabs_default__item" role="presentation">
                <a role="tab"
                   tabindex={tab2Tabindex}
                   aria-selected={isTab2Active}
                   aria-controls="tab2-panel"
                   data-tab="tab2"
                   onclick={handleTabClick}
                   onkeydown={handleTabKeyDown}>
                    Tab 2
                </a>
            </li>
        </ul>

        <div id="tab1-panel"
             role="tabpanel"
             aria-labelledby="tab1"
             class={tab1PanelClass}>
            <!-- Tab 1 content -->
        </div>

        <div id="tab2-panel"
             role="tabpanel"
             aria-labelledby="tab2"
             class={tab2PanelClass}>
            <!-- Tab 2 content -->
        </div>
    </div>
</template>
```

### Accessible Data Table

```html
<template>
    <table class="slds-table slds-table_bordered" role="grid">
        <thead>
            <tr>
                <th scope="col" role="columnheader">
                    <span class="slds-truncate">Account Name</span>
                </th>
                <th scope="col" role="columnheader">
                    <span class="slds-truncate">Industry</span>
                </th>
                <th scope="col" role="columnheader">
                    <span class="slds-truncate">Actions</span>
                </th>
            </tr>
        </thead>
        <tbody>
            <template for:each={accounts} for:item="account">
                <tr key={account.Id} role="row">
                    <th scope="row" role="gridcell">
                        <a href={account.link}>{account.Name}</a>
                    </th>
                    <td role="gridcell">
                        {account.Industry}
                    </td>
                    <td role="gridcell">
                        <button aria-label={account.editLabel}
                                data-id={account.Id}
                                onclick={handleEdit}>
                            <lightning-icon icon-name="utility:edit" size="x-small"></lightning-icon>
                        </button>
                    </td>
                </tr>
            </template>
        </tbody>
    </table>
</template>
```

---

## Testing

### Automated Testing

```javascript
// Jest accessibility tests
it('has proper ARIA labels', () => {
    const element = createElement('c-my-component', {
        is: MyComponent
    });
    document.body.appendChild(element);

    const button = element.shadowRoot.querySelector('button');
    expect(button.getAttribute('aria-label')).toBeTruthy();
});

it('manages focus when modal opens', async () => {
    const element = createElement('c-modal', { is: Modal });
    document.body.appendChild(element);

    element.openModal();
    await flushPromises();

    const firstFocusable = element.shadowRoot.querySelector('.focusable');
    expect(document.activeElement).toBe(firstFocusable);
});

it('announces status changes to screen readers', async () => {
    const element = createElement('c-notification', {
        is: Notification
    });
    document.body.appendChild(element);

    element.showMessage('Success');
    await flushPromises();

    const liveRegion = element.shadowRoot.querySelector('[aria-live]');
    expect(liveRegion.textContent).toContain('Success');
});
```

### Manual Testing Checklist

- [ ] Navigate entire component using only keyboard (Tab, Shift+Tab, Enter, Space, Arrows)
- [ ] Test with screen reader (NVDA, JAWS, VoiceOver)
- [ ] Verify color contrast ratios (4.5:1 minimum for text)
- [ ] Test at 200% zoom
- [ ] Verify focus indicators are visible
- [ ] Test with high contrast mode
- [ ] Verify all interactive elements have accessible names
- [ ] Test form validation announcements

---

## Tools and Resources

### Browser Extensions

| Tool | Purpose |
|------|---------|
| **axe DevTools** | Automated accessibility testing |
| **Lighthouse** | Built into Chrome DevTools, accessibility audit |
| **WAVE** | Visual accessibility evaluation |
| **Color Contrast Analyzer** | Check WCAG contrast compliance |

### Screen Readers

| Platform | Screen Reader |
|----------|---------------|
| Windows | NVDA (free), JAWS |
| macOS | VoiceOver (built-in) |
| iOS | VoiceOver (built-in) |
| Android | TalkBack (built-in) |

### Testing Commands

```bash
# Run axe accessibility tests
npm install --save-dev @axe-core/cli
axe https://your-app.lightning.force.com

# Lighthouse CLI
npm install -g lighthouse
lighthouse https://your-app.lightning.force.com --only-categories=accessibility
```

### Resources

- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/)
- [Salesforce Lightning Design System Accessibility](https://www.lightningdesignsystem.com/accessibility/overview/)
- [WebAIM Resources](https://webaim.org/resources/)

---

## Related Resources

- [component-patterns.md](component-patterns.md) - Implementation patterns
- [jest-testing.md](jest-testing.md) - Testing strategies
- [performance-guide.md](performance-guide.md) - Performance optimization

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.