Accessibility
Lists built from layout primitives
Stack, Layout and Box accept a tag property, which renders that tag inside the component's shadow root. Use it to build a semantic list:
<Stack tag="ol">
<li>One</li>
<li>Two</li>
<li>Three</li>
</Stack>
The component renders <ol class="wb-stack" role="list"> and states role="list" for you. It has to: WebKit removes the list and listitem roles from a list whose items show no marker — the state a consumer creates with list-style: none — and stating the role explicitly restores it. The tagged element lives inside the component's shadow root, so a consumer cannot reach it to state the role themselves; only the component can. Chrome and Firefox keep the roles regardless, so a list that is broken in Safari looks correct under NVDA, under Chrome, and to axe.
Measured with real VoiceOver in Safari on 2026-09-03 against packages/e2e-tests-react/src/list-semantics/: a Stack tag="ol" announces list 3 items with each item's position, client-rendered and server-rendered alike, and role="presentation" correctly suppresses it.
The tags that carry a default role:
| Tag | Role |
|---|---|
ul, ol, menu | list |
li | listitem |
Every other tag renders no role.
The same applies to every component that accepts a tag: Stack, Layout, Box, Border, Bezel, GridContainer and Text all resolve the role the same way. The examples here use Stack because it is the common case.
Overriding the role
A role you set always wins:
<Stack tag="ul" role="presentation">…</Stack>
Use presentation or none where the list is decorative, or where the surrounding structure already supplies the semantics. Setting role to an empty string also suppresses the default.
Where to put the li
Put the li in your own markup and the Box inside it:
<Stack tag="ol">
<li>
<Box borderColor="border">…</Box>
</li>
</Stack>
This keeps the markup valid — an <ol>'s content model permits only li, script and template as children — and puts no host between the list and its items. The cost: the <li> is the consumer's own light-DOM markup, so webbloqs never sets a role on it, and the item carries no listitem role.
Box tag="li" states role="listitem" on the <li> it renders, because that <li> is the tagged element inside Box's own shadow root. It does not validate as HTML, since <ol> permits only li, script and template as children, and the <li> nests behind the wb-box host, between the list and its item.
Both shapes announce as a list with the correct item count — the wb-box host between <ol> and <li> does not break list semantics. Recommended: <li><Box>…</Box></li>. It validates, and it avoids a marker defect in the other shape: because each Box tag="li" sits in its own shadow root, the ordered-list counter restarts inside every item, so every item's visible marker renders as "1" — measured as 1 Boxed one, 1 Boxed two. Against that, Box tag="li" does give a slightly better per-item announcement: one stop per item with its position (1 of 2), where <li><Box>…</Box></li> splits into two stops — the marker, then the content — and announces no position. A consumer who wants that position announcement back can add the role directly in their own markup: <li role="listitem">…</li>.
Known limitation. Box tag="li" numbers every item "1" instead of counting up, because the ordered-list counter that produces the visible marker restarts inside each item's own shadow root. This is a real defect in that composition, independent of accessibility and not fixed here; a consumer who chooses Box tag="li" anyway should expect it.
Server-side rendering
The default role is emitted by the component, so it appears in server-rendered markup and survives hydration unchanged.
Do not set role="list" yourself as a workaround. role is a declared reactive property, but LitElementWithARIAProperties declares it without reflect: true, so setting the property alone does not reflect to the role attribute — <Stack role="list"> from React, for instance, only sets the property, because @lit/react routes a prop found on the element prototype to a property assignment. The duplicate list happens only where role is set as an attribute instead — hand-authored HTML, setAttribute, or server-rendered markup: that lands the attribute on the custom element host as well as on the tagged element inside it, and the accessibility tree then reads a list inside a list. The component default has no such effect, because it never touches the host.
Alerts announce their level
Alert derives its live-region semantics from level, so a message announces without the call site configuring anything:
<Alert level="error">Payment could not be processed.</Alert>
The component renders <div class="wb-alert wb-alert--error" role="alert" aria-live="assertive"> inside its shadow root. What each level selects:
level | role | aria-live |
|---|---|---|
error, warning | alert | assertive |
info, success | status | polite |
default | — | — |
default is the neutral level, so an alert used purely as a styled container never becomes a live region.
A role or aria-live you state wins, and wins for the whole pair — state only role and no aria-live is added, state only aria-live and no role is added. Mixing the two would produce a live region neither side asked for: <Alert level="error" role="region"> would silently become assertive. Set role to an empty string to opt an alert out of being a live region entirely.
What actually announces
A live region announces a change inside a region assistive technology already knows about. Three cases follow from that, and they are not equally reliable:
- An alert that stays mounted while its message changes announces reliably. The live region is already there and observes the change. Measured in Chromium on 2026-09-03 against
packages/e2e-tests-react/: the region is the same DOM node before and after a message change, and before and after alevelchange. - An alert already in the DOM when the page loads does not announce. Nothing changed inside it. This is correct behaviour, not a defect — a static info banner is not a status message.
- An alert mounted in the same breath as its message is the weak case. Region and content enter the DOM together, which screen readers handle least consistently: a newly inserted
role="alert"is generally announced, a newly insertedrole="status"often is not. Where a call site can keep the alert mounted and swap only the text, prefer that.
Prefer, therefore:
<Alert level="info">{message ?? ""}</Alert>
over {message && <Alert level="info">{message}</Alert>}, which mounts the region and its content in one step.
Ordering and dismissal
role="alert" and role="status" imply aria-atomic="true": the region announces as one utterance, in document order. The component therefore renders the slotted message before the close button, so a screen reader reads the message and not Dismiss, button first. Keep the same order in anything you slot in.
A dismissed closable alert sets display: none on the live region and never restores it. Pushing a new message into an alert the user has already dismissed announces nothing. Render a new alert instead of reusing a dismissed one.
Attributes on the host
role and aria-live set as attributes land on the wb-alert host as well as on the alert inside it, nesting one live region in another — the same trap the server-side rendering section describes for lists, and for the same reason. A React prop only assigns the property, so <Alert role="status"> is safe.
Collapsed content is unreachable
Collapsible marks its collapsed region inert. Collapsed content therefore leaves the tab order and the accessibility tree at the same time — a keyboard user tabs past a closed section, and a screen reader does not read what is inside it.
<Collapsible title="Details">
<Link href="/invoice">Open the invoice</Link>
</Collapsible>
Closed, that link is neither tabbable nor announced. Open, both come back.
inert replaces an earlier aria-hidden on the content, which removed the content from the accessibility tree but left it in the tab order. A keyboard user landed on a link the screen reader treated as absent — the gap between "not announced" and "not reachable" was the defect. Chrome names the same fix in its own console warning for that pattern: "Consider using the inert attribute instead, which will also prevent focus."
inert also covers slotted light-DOM content, even though the attribute sits on an element inside the component's shadow root. Measured in Chromium's accessibility tree on 2026-09-04: a slotted link and a slotted paragraph are both absent while closed and both present once open.
Browser floor
inert needs Chrome 102, Safari 15.5 or Firefox 112. On an older engine the collapsed content sits in the tab order and in the accessibility tree, where aria-hidden used to keep it out of the latter — worse than before, for engines that old. This is the only browser floor webbloqs states; the repo declares no general support matrix.
Required fields in a collapsed section
Do not put a required form field inside a collapsible that can be submitted while closed. The browser refuses the submit and cannot show its validity message on content it cannot reach, so the user presses Submit and nothing happens — no error, no indication which section to open.
This predates inert and neither spelling fixes it: with aria-hidden the browser moved focus to a zero-height hidden field, with inert the focus stays on the submit button. Either expand the section before submitting, or keep required fields out of collapsed sections.
Focus when a section closes
Two cases are unhandled today, both tracked separately:
Formfocuses the first invalid field after a failed submit. In a collapsed section that focus call does nothing; the section should expand first.- Closing a section while focus is inside it — a close button in the content, or a consumer setting
open = false— drops focus to<body>and restarts the tab order. The WAI-APG disclosure pattern puts focus on the header instead. Closing via the header, the normal case, is unaffected.
Icons name nothing
An icon inside a control that already names itself is decorative. wb-icon hides such an icon from the accessibility tree: aria-hidden="true" on the wb-icon element itself as well as on the svg in its shadow root, focusable="false", and no <title>. Only an icon marked standalone carries a name, from its ariaLabel.
The host attribute is the load-bearing one. A spritemap built by svg-spritemap-webpack-plugin — the catalog's own and ewz-design's alike, neither turning generate.title off — carries a <title> in every <symbol>, and Chromium computes name-from-content through the shadow tree of <use>. That text therefore reaches the accessible name of an enclosing control even while the svg is aria-hidden; with only the svg hidden, the collapsible header button read button "close Stromkosten". aria-hidden on the host prunes the whole subtree, sprite <title> included.
Collapsible is the case that forced the rule. Its toggle icon sits inside the header button, which is named by the panel title and states open or closed through aria-expanded. A name on the icon is appended to the button's, so a labelled icon produced button "Collapse Stromkosten" where the panel is called Stromkosten. Measured through CDP Accessibility.getFullAXTree; the header button now reads button "Stromkosten".
No value of the old closeLabel property was correct — a blank string violates axe's svg-img-alt, any empty value fell back to the sprite name close, and the default was the English Collapse in an interface that may be in any language. The property is therefore deprecated and ignored rather than given a better default.
<title> follows standalone for the same reason. On a decorative icon it never reached a screen reader — the svg is aria-hidden — but it stayed a hover tooltip, showing the sprite name: close, pencil, coin-stack, untranslated. An icon that should show a tooltip is not decorative; mark it standalone and give it an ariaLabel.
Selecting a decorative icon in a test
A decorative icon has no accessible name, so no role-and-name query finds it. Collapsible exposes data-testid="wb-collapsible-toggle-icon", exported as TOGGLE_ICON_TEST_ID:
await page.locator('wb-collapsible [data-testid="wb-collapsible-toggle-icon"]').click();
Prefer the header button where the test can use it — getByRole("button", { name: "Stromkosten" }). The test id exists for toggleTrigger="icon", where only the icon toggles.
Measuring an icon's contribution to a name
A test page whose <use> does not resolve cannot see this class of finding at all: with no symbol fetched, no sprite <title> enters the tree and the name comes out right on broken code. collapsible-decorative-icon/index.tsx therefore points initGlobals({ spritemap }) at public/spritemap.svg, which carries a <title> per <symbol>, and the suite asserts that the file resolves before it asserts anything about a name.
Animating panels clip, they do not scroll
Collapsible animates max-height and clips the overflow with overflow-y: hidden, closed and while transitioning alike.
auto clips identically, and was what the component used, but it also makes the container a scroll container: for the ~250 ms of the open animation the container is a few pixels tall around full-height content, holds no focusable element of its own, and is therefore a region a pointer user can scroll and a keyboard user cannot reach. axe reports it as scrollable-region-focusable.
The finding exists only inside that window. A run in either rest state is green on the broken code too — closed, the container is inert; open, its overflow-y is visible. A regression test for this has to open the panel and sample while it animates, as collapsible-decorative-icon.test.ts does.
tabindex="0" on the container, the usual remedy for the rule, is the wrong one here: it would put a keyboard stop on every accordion on the page, on an element that is a few pixels tall for a quarter second and gone thereafter.