Accessibility
Summary
Soma targets WCAG 2.2 AA and treats it as a CI gate: the e2e suite runs an axe-core sweep over every page of this sandbox in all three themes, so a regression fails the pipeline rather than shipping. This page documents the postures that sweep can't fully express (focus management, keyboard contracts, live regions, the semantics the components apply for you) and your part as a consumer, the handful of things only your markup and data handling can get right.
Focus management
Focus rings
Every interactive element shows a visible ring on
:focus-visible (keyboard focus), via the
--soma-focus-ring token. Mouse clicks don't paint
rings. Composite fields (quicksearch, the navbar search, the
select) ring the wrapper via :focus-within instead
of the inner input. If you build custom controls, consume the
same token.
Dialogs: native modality
dialog2 (and the command palette) open a native
<dialog> with showModal(): the
rest of the page becomes inert (unfocusable,
unclickable and hidden from assistive technology), which is
containment stronger than any JS focus trap, and the platform
restores focus to the opener on close. Initial focus follows
the platform (first focusable, or your autofocus).
The accessible name is derived automatically from the header
heading: write a .soma-dialog2-header-main and
the dialog announces itself by its title.
Popovers: focus return
Dropdowns, inline dialogs, the date picker and the select all
return focus to their trigger when they close while focus is
inside them; focus is never dropped onto
<body>. Opening the date picker deliberately
does not steal focus from the input: you can keep
typing, and ↓ moves into the calendar when you want
it.
Live regions
Content injected by JavaScript is only announced if it appears
inside a live region, so the dynamic factories set the right
role automatically: warning/danger toasts,
banners and messages get role="alert" (interrupt);
info/success get role="status" (queue politely).
Static server-rendered markup needs no role — it was there at
page load.
Keyboard reference
Every overlay — dialogs and the popover family (menus, inline dialogs, tooltips, the date picker, select menus, the drawer, toasts) — renders in the browser's top layer, so it is always visually above the page it belongs to, and Esc unwinds nested overlays one entry at a time, most recent first: a dropdown opened inside a dialog closes before the dialog does.
Dialogs
| Key | Action |
|---|---|
| Tab / Shift+Tab | Move focus inside the dialog; the page behind is natively inert, so focus cannot leave. |
| Esc | Close the topmost dialog and restore focus (native; a strict modal suppresses it). |
Dropdown menus
| Key | Action |
|---|---|
| ↓ on the trigger | Open and move to the first item. |
| ↓/↑, Tab/Shift+Tab | Move the cursor with wrap; Tab cannot escape an open menu. |
| Home / End | First / last item. |
| Enter | Invoke the cursor item. |
| Esc | Close and refocus the trigger. |
Tabs
| Key | Action |
|---|---|
| →/← | Next / previous tab (visual direction, mirrored in RTL), activated on move. |
| Home / End | First / last tab. |
Date picker
| Key | Action |
|---|---|
| ↓ in the input | Open (if needed) and focus the calendar grid. |
| ←/→ | ±1 day (visual direction, mirrored in RTL). |
| ↑/↓ | ±1 week. |
| Home / End | Start / end of week. |
| PageUp / PageDown | ±1 month (Shift: ±1 year). |
| Enter / Space | Select the focused day. |
| Esc | Close and return to the input (without reopening). |
Select
| Key | Action |
|---|---|
| Type | Filter the options. |
| ↓/↑ | Move through matches. |
| Enter | Choose the highlighted option. |
| Backspace (multi, empty search) | Remove the last pill. |
| Esc | Close, keeping the selection. |
Sortable tables
| Key | Action |
|---|---|
| Tab | Headers are focusable (tabindex="0"). |
| Enter / Space | Sort by that column; repeat to reverse. aria-sort tracks the state. |
Tree, drawer, expander
| Key | Action |
|---|---|
| Tree | One tab stop (roving tabindex): ↓/↑ move through visible items, →/← expand/collapse and walk the hierarchy (visual direction in RTL), Home/End jump, Enter/Space toggle; the toggle's label flips between the (localised) Expand/Collapse strings. |
| Drawer | Non-modal: no blanket, no focus trap; the page stays interactive. Esc and outside-click close; focus returns to the trigger. |
| Expander | Enter/Space on the trigger; state on aria-expanded. |
Colour and contrast
- Text contrast is audited against the effective translucent surface colour, not white; the tokens file documents each assumption.
- Colour is never the sole state channel: status pairs with icons or text (badges say "failed", the meter has a value label).
--soma-color-text-subtleis decorative-only; anything that must be read uses-mutedor stronger.- The high-contrast theme raises everything to ≥7:1 and underlines links.
Forced colors (Windows High Contrast)
Distinct from Soma's high-contrast theme: the OS strips
author backgrounds and substitutes system colours.
_a11y.scss patches what that would erase: icon
glyphs (mask + background), menu and calendar selection states,
progress/meter fills, the switch track. If you paint state with
background-color alone, add your own
@media (forced-colors: active) rule.
Reduced motion
Every transition and animation respects
prefers-reduced-motion. Spinners keep animating —
the motion is the signal — but nothing else moves.
Screen-reader helpers
.soma-assistive renders content for screen readers
only (visually hidden, still announced). Use it for icon-only
controls that can't take aria-label and for table
headers whose meaning is visual (the RESTful table's operations
column does exactly this).
Screen-reader semantics
The JS components apply their roles and ARIA attributes themselves: you write the markup contract from each component's page, and the component wires the semantics at init (or on every show, where content can be replaced). One row per component; everything listed is set by Soma automatically:
| Component | Semantics Soma applies |
|---|---|
| Dialog | A native <dialog> opened with
showModal(): role, modality, top layer
and the inert page come from the platform (no
aria-modal or aria-hidden
bookkeeping; the native open attribute is
the state). The accessible name is derived
automatically: the header heading gets an id and the
dialog points aria-labelledby at it, so
it announces as "Create user, dialog", not as an
anonymous dialog. |
| Dropdown | Trigger gets aria-haspopup +
aria-expanded. The menu gets
role="menu"; structural
ul/li get
role="none"; items get
menuitem, menuitemcheckbox or
menuitemradio with
aria-checked (without the roles,
aria-checked is inert). Roles are
re-applied on every show, so ajax-replaced menu
content is covered. |
| Tabs | tablist / tab /
tabpanel roles with
aria-selected, aria-controls
from tab to panel and aria-labelledby back;
the <li> wrappers get
role="presentation" so the tablist owns
only tabs (list items inside a tablist are invalid
ARIA); hidden panes carry
aria-hidden. |
| Inline dialog | Trigger gets aria-haspopup="dialog" +
aria-expanded; the popover gets
role="dialog" and toggles
aria-hidden. |
| Date picker | The input becomes an ARIA 1.2 combobox:
role="combobox",
aria-haspopup="dialog",
aria-expanded, aria-controls
(the role is what makes aria-expanded valid
on a text input). The popover is a labelled
role="dialog" over a
role="grid"; every day cell carries a full
spoken-date aria-label ("24 July 2026";
the visible text is just a number), today gets
aria-current="date", the chosen day
aria-selected="true", out-of-range days
aria-disabled="true". The month title is
aria-live="polite" so paging is
announced. |
| Select | The generated control gets role="combobox"
+ aria-haspopup="listbox" +
aria-expanded, and the accessible name is
re-pointed from your <label> (which
targets the hidden native select) to the control. The
list is role="listbox"
(aria-multiselectable in multi mode) of
role="option" items with
aria-selected/aria-disabled;
the virtual cursor is exposed via
aria-activedescendant on the search field;
pill remove buttons are individually
labelled. |
| Quicksearch | Input gets role="combobox" +
aria-autocomplete="list" +
aria-expanded + aria-controls;
the results panel is a role="listbox" and
the highlighted result is reported through
aria-activedescendant while focus stays in
the input. |
| Toasts | The shared container is aria-live="polite";
each toast gets role="alert"
(warning/danger: interrupt) or
role="status" (info/success: queue
politely). The dismiss button carries a localised
aria-label. |
| Messages & banners (dynamic) | Soma.message.create() and
Soma.banner() instances get
role="alert" for error/warning (banners:
danger/critical/warning) and role="status"
for the rest: injected content is only announced from
inside a live region. Static server-rendered messages
and banners deliberately get no role: they were
part of the page at load, not an update to
announce. |
| Progress | Soma.progress() ensures
role="progressbar" with
aria-valuemin="0" /
aria-valuemax="100";
.update(v) writes the percentage to
aria-valuenow, and
.setIndeterminate() removes it — a
progressbar without aria-valuenow is what
assistive tech reads as indeterminate. |
| Spinner | Programmatic spinners get role="status" and
a localised "Loading" aria-label, announced
politely when they appear. |
| Tooltip (JS) | The bubble is role="tooltip"; while it is
visible the trigger points at it with
aria-describedby, so the hint is read
together with the control. |
| Sortable table | Sortable headers get tabindex="0"
(keyboard-reachable); the sorted column carries
aria-sort="ascending|descending"; only one
column at a time, so the sort state is
unambiguous. |
| Tree | Items with a child group get
aria-expanded; each toggle's
aria-label flips between the localised
Expand/Collapse strings as the state changes. The
whole tree is one tab stop (roving
tabindex on the treeitems, WAI-ARIA
tree pattern): arrows move through visible items,
→/← expand/collapse and walk
the hierarchy (visual direction in RTL),
Home/End jump,
Enter/Space toggle. |
| Expander | State lives where ARIA wants it:
aria-expanded on the trigger,
aria-hidden on the content, kept in sync
on every toggle. |
| Drawer | role="complementary" +
aria-hidden. Deliberately non-modal: no
blanket, no focus trap, no
aria-modal; the page stays operable while
it is open. |
| Sidebar nav groups | The toggle is a native <button>
(Enter/Space free) pointing
aria-controls at its panel. Init
normalises the pair from whichever side the markup
declared — aria-expanded on the toggle,
aria-hidden on the panel — and every
toggle flips both. |
| Sidebar resize | The handle is a labelled, focusable
role="separator" with
aria-orientation="vertical" and
aria-valuenow /
-valuemin / -valuemax tracking
the width. |
| RESTful table | The operations column header gets visually-hidden
.soma-assistive text (its meaning is
visual); edit-mode inputs are labelled with their
column header; the loading row's spinner is a labelled
role="status". |
One semantic is part of the markup contract rather than
applied by JS: scrollable log-viewer and diff panes need
tabindex="0" so keyboard users can focus and scroll
them; the focus ring is styled for it. The CSS-only components
(badges, labels, meters, …) need no JS-applied ARIA; their pages
document any attributes the markup itself should carry.
Your part as a consumer
The library carries the component-level work; a few things only you can do:
- Label icon-only controls. A button or
trigger whose only content is an icon needs an
aria-label:<button class="soma-button" aria-label="Close"><span class="soma-icon soma-icon-close"></span></button>. The icon itself is a CSS mask on an empty<span>: it contributes no accessible name. - Give avatars alt text.
<img alt="Tomas">when the identity matters;alt=""when the avatar sits next to the visible name and is decorative. - Offer the high-contrast theme to users who asked the
OS for it. A three-line
matchMediaopt-in mapsprefers-contrast: moretoSoma.theme('high-contrast')when no explicitdata-soma-themeis set; the recipe is on the Themes page. Soma never switches themes on its own. - Don't remove the focus ring. If it clashes
with your design, restyle
:focus-visible(the--soma-focus-ringtoken is the hook); neveroutline: nonewithout a visible replacement. - Pair inputs with labels. Every form field
needs a
<label for="…">(or a wrapping label); placeholder text is not a label: it vanishes on input and is never announced as a name. - Wire descriptions and errors to the input.
Give the
.soma-field-descriptionand.soma-field-messageids and list them in the input'saria-describedby(description first, then message); addaria-invalid="true"to an errored input. Without the wiring the error is only tinted, never announced. On long forms, add an error summary (.soma-form-errors) at the top and focus it after a failed submit; the full contract is on the Forms page. - Escape user data in HTML-injecting hooks.
bodyHtml(toasts,message.create, banners), tooltiphtml: true, selectformatResult/formatSelection, and restful-tablereadViewall inject raw markup: escape anything user-supplied before it goes in (Soma.escapeHtmlexists for exactly this — attribute-safe, unlike the textContent→innerHTML shortcut), or stay on the escaped-by-default paths:title/bodyare always inserted as text, and the select's default formatters escape the option text for you. The full escaping model is in the repository's SECURITY.md.