Home / lwc / lwc-accessibility
lwc-accessibility
Use when designing or reviewing LWCs for keyboard access, semantic labeling, focus. Triggers: LWC accessibility, keyboard nav, aria-label, aria-labelledby, aria-describedby, label for id, focus order, delegatesFocus, tabindex, shadow DOM label association, live region, screen reader. NOT for specific ARIA/datatable implementation patterns — use lwc/lwc-accessibility-patterns. NOT for SLDS styling with no assistive-tech impact — use lwc/lwc-css-and-styling.
Open the skill folder on GitHub
Use this skill when a Lightning Web Component looks correct visually but may fail for keyboard users or assistive technology. The highest-value move in LWC accessibility work is usually to remove custom interaction code and return to accessible base components, then handle the few remaining focus and labeling gaps deliberately.
Before Starting
Gather this context before working on anything in this domain:
- Which parts of the UI are interactive: buttons, menus, tabs, dialogs, inline actions, or custom pickers?
- Is the component built mostly from
lightning-*base components, SLDS blueprint markup, or custom HTML? - Where should focus move after the user opens a modal, triggers validation errors, saves, or cancels?
Questions to Ask Before Configuring
Ask these before writing markup. Each one maps to a failure in references/gotchas.md that renders without an error and is invisible in a mouse-only demo.
| Ask | Why it matters | What a good answer adds |
|---|---|---|
"Can lightning-input / lightning-combobox / lightning-record-edit-form express this control, and if not, exactly what stops it?" | Base components already associate the label, update ARIA on interaction, and meet WCAG 2.1 AA contrast; a custom control re-inherits all of that | A written justification for custom markup, or the decision not to build it |
| "Which component owns the label, the help text, and the error text?" | for / aria-describedby link only inside one template; across templates in native shadow DOM they cannot link at all | The component boundary drawn around the whole form element, not around each visual part |
| "Where must focus be after open, after a validation failure, after save, and after close?" | Focus skips the custom element container by default, so an unstated contract means focus goes nowhere | Four named focus targets that become four Jest assertions |
"Does a parent need to call focus() on this component?" | That decides delegatesFocus vs an @api focus method — and delegatesFocus forbids tabindex inside the component | The focus mechanism chosen once, up front, instead of both being half-applied |
| "Which state changes must be announced without moving focus?" | Save confirmations, async result counts, and inline validation are silent to a screen reader unless a live region carries them | The list of announcements and which are polite (role="status") vs assertive (role="alert") |
| "Which icons carry meaning, and which are decorative?" | alternative-text on a decorative icon is noise; its absence on a meaningful one is a WCAG 1.1.1 failure | A per-icon decision instead of a blanket rule applied in either direction |
| "Will this run in Experience Cloud, Lightning Out, or a native-shadow context?" | Lightning Experience and Experience Builder use synthetic shadow today; outside them LWC renders in native shadow, where cross-template id linking is impossible | The environment list that decides whether a cross-component ARIA reference can ship at all |
What a proper accessibility design adds over "adding ARIA at the end": the label association survives id transformation, focus has a named destination at every state change, the keyboard contract is asserted by tests rather than by a manual pass, and the component does not silently degrade when the org moves from synthetic to native shadow.
Core Concepts
Accessibility in LWC is easiest when the component stays close to platform primitives. The farther a team moves toward clickable div elements, custom focus logic, and manual ARIA, the more likely it is to recreate a solved problem badly.
| Principle | Default | Anti-pattern | Why it matters |
|---|---|---|---|
| Base components first | Use lightning-* base components or SLDS blueprints | Custom menu/button/toggle in raw HTML | Base components ship tested keyboard, label, and AT support |
| Accessible name | Real button text, label on inputs, alternative-text on meaningful icons | ARIA-patching a clickable span or div | ARIA cannot repair structurally wrong markup |
| Focus contract | Land on first actionable element, trap inside modals, return to launcher on close | Letting focus stay wherever the browser last placed it after rerender/save | Lost focus = component is broken for keyboard users |
| Programmatic validation | Use base-component validation; programmatic error association on custom inputs | Error state shown only in color or layout text | Color-only errors fail screen readers and color-blind users |
| One template per form element | Label, control, help text, and error text in the same template | Splitting the label into its own component for reuse | Id-based association is linked automatically only inside one template |
| One focus mechanism | delegatesFocus for a field wrapper, an @api method for a composite widget | delegatesFocus plus tabindex in the same component | The guide states the two combined throw off focus order |
Common Patterns
Base-Component Replacement For Custom Click Targets
When to use: The component currently uses custom HTML such as a clickable div, icon-only action, or hand-rolled toggle.
How it works: Replace the interactive surface with lightning-button, lightning-button-icon, lightning-input, or another standard base component. Keep any remaining custom markup decorative, not interactive.
Why not the alternative: Adding tabindex, role, and key handlers to arbitrary markup usually creates incomplete keyboard behavior and inconsistent screen-reader output.
Deliberate Focus Management Around Dialogs
When to use: The component opens a modal, quick action surface, or blocking overlay.
How it works: Use LightningModal or another supported dialog surface, set focus on meaningful content or the first actionable control, and return focus to the launch element when the interaction closes.
Why not the alternative: Leaving focus wherever the browser last had it makes the overlay hard to use and easy to lose.
Accessible Composite Widget Boundary
When to use: A real business need requires a custom picker, listbox, or multi-step composite component.
How it works: Choose a known WAI-ARIA interaction pattern, document the keyboard contract, and test it with both tab order and screen-reader announcements before shipping.
Decision Guidance
| Situation | Recommended Approach | Reason |
|---|---|---|
| Standard action, toggle, or input | Use a lightning-* base component | Accessible behavior is already implemented and maintained by the platform |
| Need branded layout but normal semantics | Use an SLDS blueprint with minimal custom behavior | Keeps semantics closer to supported interaction models |
| Need a modal or blocking overlay | Use LightningModal with an explicit focus plan | Modal semantics and dismissal behavior are easier to keep correct |
Considering ARIA on a clickable div | Replace it with semantic HTML or a base component | ARIA does not fully repair incorrect structure |
| Custom composite widget is unavoidable | Implement and test a formal keyboard model | Composite controls need a documented accessibility contract |
| Label must live in a different component from its input | Redesign to one template, or render both in light DOM | Cross-template id linking is impossible in native shadow DOM; light DOM is the guide's stated remedy and carries a security cost |
| Parent needs to focus a child component | Set delegatesFocus, or expose an @api focus method | Focus skips the custom element container, so the host's focus() does nothing by default |
Custom cell type inside lightning-datatable | Wire data-navigation="enable" and internalTabIndex through the cell template | Custom data types do not participate in the datatable's keyboard navigation until they opt in |
Recommended Workflow
- Inventory the interactive surface. List every element a user can activate, and for each one
record: is it a base component, semantic HTML, or a
div/spanwith a handler? Fill in the Component Scope and Accessibility Contract sections oftemplates/lwc-accessibility-template.mdas you go — the unanswered lines are the design gaps. - Delete before you add. For each custom interactive surface, answer the first question in
Questions to Ask. Anything a
lightning-*component can express goes back to that component; the remainder is what you are actually designing. - Draw the labelling boundary. Put the label, control, help text, and error text of one field
in one template. Read
references/gotchas.md§ Id-Based ARIA Association Stops At The Shadow Boundary before splitting anything across components, and § Id-Referencing ARIA Attributes Are Not Ordinary Reflected Properties before exposing anaria-*property with@api. - Build from the reference bundle. Copy the component in
references/code-examples.md— HTML,JS, CSS,
js-meta.xml— and itspackage.xml. Start fromtemplates/lwc/component-skeleton/for the bundle shape. Pick one focus mechanism (delegatesFocusor explicittabindex), never both. - Assert the accessibility, don't eyeball it. Port the Jest suite from
references/code-examples.md:for/idrelationship,aria-describedbytarget,aria-invalidflip, live-region text,shadowRoot.activeElementafter an error, and zerotabindexwhendelegatesFocusis set. Run withnpm run test:unit. - Run the checker over the source tree.
python3 skills/lwc/lwc-accessibility/scripts/check_lwc_accessibility.py --manifest-dir force-app/main/default/lwcIt fails on unresolvable id references (A4), unsupportedtabindexvalues (A5),tabindexbesidedelegatesFocus(A6), unlabelled controls (A2, A7, A9), and incomplete bundles (B1, B2). Every finding is a real defect, not a style opinion. - Do the two things no script can do. Tab the whole component with the mouse untouched, and
run it once with a screen reader, checking the four focus destinations from step 3 of Questions to Ask. Record the result in the Sign-Off Checklist of the template.
Review Checklist
Run through these before marking work in this area complete:
- [ ] Every interactive surface is semantic HTML or a supported base component.
- [ ] Inputs, buttons, and meaningful icons have a clear accessible name.
- [ ] Focus order matches the visible reading order and business flow.
- [ ] Modal or overlay interactions trap and restore focus intentionally.
- [ ] Validation feedback is programmatic and not color-only.
- [ ] Keyboard-only testing covers open, close, save, cancel, and error states.
- [ ] Every
for/aria-labelledby/aria-describedbytarget id is declared in the same template. - [ ] No
idappears in a JS or CSS selector; element lookup useslwc:refordata-*. - [ ]
tabindexis absent,0, or-1— and absent entirely ifdelegatesFocusis set. - [ ]
js-meta.xmldeclaresapiVersion, and it is not pinned below what the component needs. - [ ]
check_lwc_accessibility.pyreports no issues over the bundle's source tree. - [ ] Jest asserts the label/id relationship,
aria-invalid, andshadowRoot.activeElementafter an error.
Salesforce-Specific Gotchas
Non-obvious platform behaviors that cause real production problems:
lightning-iconneeds deliberate text when it carries meaning - icon-only affordances look obvious visually but become vague or silent to assistive technology withoutalternative-text.- Custom HTML can regress below the platform baseline quickly - moving away from
lightning-*components often removes built-in keyboard and label behavior teams assumed they still had. - Focus can get lost after rerender - conditional templates, async state changes, and modal open or close transitions can strand keyboard users unless focus is restored deliberately.
- ARIA does not replace semantic structure - adding roles to the wrong element can still produce confusing navigation and announcement behavior.
- Template
idvalues are rewritten at render time -#myInputselectors in JS and CSS miss; onlyfor/aria-*associations survive, and only within one template. forandaria-labelledbydo not cross a shadow boundary - the attribute renders, nothing links, and no error appears.tabindexsupports only0and-1- and must not be combined withdelegatesFocus.- A custom element is not focusable by default -
focus()on the host is a no-op until you setdelegatesFocusor expose a focus method. - Base components always render in shadow DOM - switching your component to light DOM does not open up theirs.
lightning-datatablecustom cell types opt out of keyboard navigation - untildata-navigationandinternalTabIndexare wired through.
Full detail, with guide line citations, is in references/gotchas.md.
Output Artifacts
| Artifact | Description |
|---|---|
| Accessibility review | Findings on semantic markup, labels, keyboard interaction, and focus behavior |
| Remediation plan | Concrete changes to base components, ARIA usage, and focus handling |
| Test checklist | Keyboard and screen-reader scenarios that should pass before release |
| Component bundle | HTML / JS / CSS / js-meta.xml with in-template label and error association (references/code-examples.md) |
| Jest suite | Assertions on for/id, aria-describedby, aria-invalid, live-region text, and focus destination |
| Checker report | check_lwc_accessibility.py --manifest-dir <lwc root> output, clean before deploy |
Reference Files
| File | Read it when |
|---|---|
references/code-examples.md | You are building or fixing a bundle — accessible input wrapper (HTML/JS/CSS), js-meta.xml, Jest suite, package.xml, deploy and verification commands |
references/gotchas.md | Something renders correctly and behaves wrongly: id rewriting, cross-boundary ARIA, tabindex, delegatesFocus, datatable cell types, synthetic vs native shadow |
references/examples.md | You want the worked before/after: clickable card → button, focus return after a modal, and the label-across-two-components failure with both fixes |
references/llm-anti-patterns.md | You are reviewing AI-generated LWC markup, or you are the AI generating it — eight detection hints for the mistakes that pass a visual check |
references/well-architected.md | You are justifying the design in a review: pillar framing, tradeoffs, and the official sources with the claim each one supports |
templates/lwc-accessibility-template.md | You are running the review — the scope, contract, labelling, and sign-off worksheet |
scripts/check_lwc_accessibility.py | Before every deploy — --manifest-dir <lwc source root> |
Related Skills
lwc/lwc-modal-and-overlay- use when the main problem is dialog choice and overlay lifecycle rather than general accessibility posture.lwc/lwc-forms-and-validation- use when the accessibility issue is mainly inside form validation and record-edit UX.lwc/lwc-testing- use alongside this skill to turn accessibility expectations into repeatable component tests.lwc/lwc-focus-management- use when focus order, traps, and restoration are the whole problem and labelling is already correct.lwc/lwc-accessibility-patterns- use when you need a specific WAI-ARIA composite-widget implementation (listbox, combobox, tabs) rather than the posture and boundary decisions here.lwc/lwc-jest-testing-with-accessibility- use when the goal is a broader accessibility test harness rather than the per-component assertions in this skill's Jest suite.lwc/lwc-shadow-vs-light-dom-decision- use before choosing light DOM to fix a cross-component ARIA reference; that choice has security consequences this skill only summarises.