Skip to content

Accessibility

<VSelect> implements the WAI-ARIA 1.2 combobox pattern end-to-end. This page enumerates which assistive-technology features are covered and which WCAG success criteria they satisfy.

ARIA attribute reference

Combobox surface (button or input)

AttributeWhenValue
rolealways"combobox"
aria-haspopupalways"listbox"
aria-expandedalways"true" when menu open, "false" otherwise
aria-controlsalwaysid of the listbox
aria-activedescendantmenu open, option highlightedid of the highlighted option
aria-autocompletewhen searchable"list"
aria-labelledbynon-searchable"{label-id} {value-summary-id}"
aria-labelledbysearchable, label prop setlabel id
aria-describedbysearchable, input empty, value selected"{value-summary-id} ..." (so the selection is announced when not actively searching)
aria-describedbyerror setincludes the error message id
aria-describedbymenu openincludes the live-region status id
aria-invaliderror truthy"true"
aria-requiredrequired prop set"true"
aria-busyloading prop set"true"
aria-disableddisabled prop setinherited from native disabled attribute

Listbox

AttributeWhenValue
rolealways"listbox"
aria-label or aria-labelledbyalwaysmatches the trigger label
aria-multiselectablemultiple prop set"true"
aria-busyloading set"true"

Option

AttributeWhenValue
rolealways"option"
aria-selectedalways"true" if part of the model
aria-disabledoption's disabled: true"true"
aria-levelrow is in a tree (depth > 0 or has children)depth in the tree (1-based)
aria-setsizealwaysnumber of navigable siblings
aria-posinsetalways1-based position
aria-expandedoption has children"true" when expanded

Tag-remove and clear buttons

AttributeValue
aria-label"Remove {label}" / "Clear selection" (overridable via labels prop)
tabindex"0" (keyboard-reachable by default)

WCAG 2.1 compliance map

Success CriterionLevelCovered?Notes
1.3.1 Info and RelationshipsARoles, labels, and relationships are all in the accessibility tree.
1.3.5 Identify Input PurposeAAThe autocomplete prop forwards to the input.
1.4.3 Contrast (Minimum)AA✅*Default tokens meet 4.5:1 in light and dark mode. *Custom themes are the consumer's responsibility.
1.4.11 Non-text ContrastAAFocus indicator is 3 px outline at ≥ 3:1 against adjacent colours.
1.4.12 Text SpacingAANo fixed widths/heights on text containers.
1.4.13 Content on Hover/FocusAAThe dropdown stays open until explicitly closed; can be dismissed via Esc; doesn't obscure the trigger.
2.1.1 KeyboardAEvery interactive element is reachable; full keyboard contract documented in the API.
2.1.2 No Keyboard TrapATab closes the menu and continues form-tab order. Esc closes and returns focus.
2.1.4 Character Key ShortcutsANo single-character shortcuts that conflict with AT shortcuts.
2.4.3 Focus OrderALogical: trigger → tags (multi) → indicators → next field.
2.4.7 Focus VisibleAA:focus-visible adds a 3 px high-contrast ring.
2.4.11 Focus Not Obscured (Min)AAFloating UI's shift keeps the menu inside the viewport; the trigger never disappears under it.
3.2.1 On FocusAFocusing the trigger doesn't change context unexpectedly.
3.2.2 On InputASelecting an option in single mode closes the menu and returns focus — that's an explicit user action, not "on input".
3.3.1 Error IdentificationAaria-invalid + visible message + aria-describedby.
3.3.2 Labels or InstructionsAThe label prop is mandatory for aria-labelledby to fire — make sure to pass one or set listboxLabel.
3.3.3 Error SuggestionAA✅*The error prop accepts any string — write descriptive messages.
4.1.2 Name, Role, ValueACombobox name = label + selected value. Role + value programmatically determinable.
4.1.3 Status MessagesAAResult counts, loading, and min-search hints announce via a polite <span role="status" aria-live="polite">.

* consumer-affected — the component provides the hooks; you provide the content.

What we explicitly do NOT do

  • Auto-focus options on hover. Hover changes the highlight (visual), not the screen-reader focus. This avoids surprising AT users.
  • Single-character search-while-closed. Some comboboxes "type-ahead" while the menu is closed. We don't — it's confusing and conflicts with Tab order. Open the menu first.
  • aria-pressed on tag-remove. It's a button that fires an action, not a toggle.

Forced colours / Windows High Contrast

When the user has high-contrast mode enabled (Windows or forced-colors: active):

  • Borders use CanvasText.
  • Active/selected options use Highlight + HighlightText.
  • The focus ring becomes a solid 2 px Highlight outline.
  • Icons inherit CanvasText so they remain visible.

The relevant rules live in packages/styles/src/style.css under the @media (forced-colors: active) block.

Reduced motion

When prefers-reduced-motion: reduce:

  • The menu open/close transition is skipped (instant pop, no slide).
  • The loading spinner stops rotating and falls back to a static full-circle outline.
  • All transition and animation durations are forced to 0.01 ms (effectively off, but preserves browser hooks).

Locale & RTL

  • All visual layout uses logical CSS properties (margin-inline-start, padding-block, etc.) — flip-correct under [dir="rtl"] without extra config.

  • Text strings come from the labels prop — pass localised strings for SR announcements:

    ts
    const labels = {
        clear: 'Auswahl löschen',
        removeItem: (l: string) => `${l} entfernen`,
        noResults: 'Keine Treffer.',
        loading: 'Lädt…',
        resultsCount: (n: number) => `${n} Ergebnis${n === 1 ? '' : 'se'} verfügbar.`
    };

Testing for accessibility

The repo runs axe-core (via @storybook/addon-a11y) against every Storybook story with the WCAG 2.0 A/AA + 2.1 A/AA + best-practice rule set. If you're integrating <VSelect> into a design system, mirror that:

bash
npm install --save-dev @storybook/addon-a11y
ts
// .storybook/preview.ts
export default {
    parameters: {
        a11y: {
            test: 'todo',
            options: {
                runOnly: {
                    type: 'tag',
                    values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice']
                }
            }
        }
    }
};