Four lines that fixed 90% of Vanilla Framework’s accessibility violations
I ran axe-core across every component example page on vanillaframework.io. It found 960 WCAG 2.1 A/AA violations. 868 of them were the same attribute, on the same element, injected into every page.
The audit in numbers
881
Example pages audited
Every example reachable from the 113 pages in the docs navigation.
960
WCAG violations found
axe-core 4.14.0, run against WCAG 2.0 and 2.1, levels A and AA.
868
From one single defect
90.4% of the total, and 2,623 individual failing elements.
4
Lines changed to fix it
In one file: templates/static/js/example-tools.js.
The bug
Every standalone example on the Vanilla documentation site has a small toolbar injected into it by
example-tools.js, and that toolbar carries a Light / Dark / Paper
theme switcher. The switcher was built as three native buttons, each declaring its state like this:
As published
<button class="p-segmented-control__button p-theme-toggle__button is-dense"
role="button"
aria-selected="true"
data-color-theme-name="light">Light</button>
aria-selected is supported on exactly six roles: gridcell,
option, row, tab, columnheader and
rowheader. button is not one of them, so the browser’s accessibility
tree discards the attribute entirely.
What is left is a control with visible state but no programmatic state. Sighted users see the active theme shaded. A screen reader user hears three identical, stateless buttons — “Light, button”, “Dark, button”, “Paper, button” — with nothing to say which theme is applied, and no name tying the three together into one control.
Because the toolbar is injected rather than written into each page, that single defect reproduced on 868 of the 881 example pages, 2,623 failing elements in total. axe-core grades it critical.
The fix
aria-pressed is supported on button, and it is the right attribute
for a toggle. Three changes, four lines:
Before
<button class="p-segmented-control__button p-theme-toggle__button is-dense"
role="button"
aria-selected="true"
data-color-theme-name="light">Light</button>
<div class="p-segmented-control__list">
After
<button class="p-segmented-control__button p-theme-toggle__button is-dense"
aria-pressed="true"
data-color-theme-name="light">Light</button>
<div class="p-segmented-control__list" role="group" aria-label="Color theme">
-
Swap
aria-selectedforaria-pressed— in the button template and in the state setter that runs on every theme change, so the two cannot disagree. -
Drop
role="button". A native<button>already has that role; repeating it added nothing, and it is what made the invalid attribute look plausible. -
Give the wrapper
role="group"andaria-label="Color theme", so the options are announced as one named control rather than three unrelated buttons.
Verification
A change to a CSS framework that quietly moves something is not a fix. Vanilla’s own
_base_button.scss already styles both attributes with the same rule, so the active option
keeps its shading with no stylesheet change at all:
scss/_base_button.scss
&:active,
&[aria-pressed='true'],
&[aria-selected='true'],
&[aria-expanded='true'] {
background-color: $button-active-background-color;
}
I checked that rather than asserting it. The same live example page was loaded twice in one browser
session — once as published, once with the patched example-tools.js substituted in by
request interception — then compared on axe output, computed style and behaviour:
| Measured | As published | With the fix |
|---|---|---|
| axe violations (WCAG 2.0/2.1 A + AA) | aria-allowed-attr critical ×3 |
none |
Light button attributes |
role="button" aria-selected="true" |
aria-pressed="true" |
Group role / aria-label |
null / null |
group / Color theme |
Click “Dark” → body.className |
is-dark |
is-dark |
| Active button background | rgb(55, 55, 55) |
rgb(55, 55, 55) |
The other 92
Removing 868 violations leaves 92, across 15 rules. These are separate defects in separate components, so they belong in separate reviewable changes rather than one sweeping pull request against a project that already has dozens open. I offered them as a follow-up in issue #5866.
Showing all 15 rules.
| What axe reports | Example page | |||
|---|---|---|---|---|
button-name |
critical | 13 | Buttons must have discernible text | /layouts/application/default |
scrollable-region-focusable |
serious | 13 | Scrollable region must have keyboard access | /layouts/full-width/default |
list |
serious | 13 | <ul> and <ol> must only directly contain <li>, <script> or <template> elements | /patterns/divided-section/bulleted-list |
image-alt |
critical | 12 | Images must have alternative text | /patterns/article-block/in-grid |
label-content-name-mismatch |
serious | 8 | Elements must have their visible text as part of their accessible name | /patterns/basic-section/linked-logo-block |
aria-hidden-focus |
serious | 6 | ARIA hidden element must not be focusable or contain focusable elements | /patterns/search-and-filter/chip-overflow |
label |
critical | 5 | Form elements must have labels | /base/typography/variable-font |
frame-title |
serious | 4 | Frames must have an accessible name | /patterns/basic-section/media-variants |
select-name |
critical | 4 | Select element must have an accessible name | /patterns/code-snippet/dropdown |
link-name |
serious | 4 | Links must have discernible text | /patterns/logo-block/logo-block-fallback |
aria-prohibited-attr |
serious | 3 | Elements must only use permitted ARIA attributes | /patterns/chip/branded |
aria-conditional-attr |
serious | 2 | ARIA attributes must be used as specified for the element's role | /patterns/forms/checkbox-indeterminate |
aria-required-parent |
critical | 2 | Certain ARIA roles must be contained by particular parents | /patterns/navigation/dropdown-full-width |
aria-valid-attr-value |
critical | 2 | ARIA attributes must conform to valid values | /templates/maas-layout |
aria-required-children |
critical | 1 | Certain ARIA roles must contain particular children | /templates/maas-layout |
Method
The docs site loads each example into an iframe, lazily. Auditing the docs pages themselves would
therefore have measured the documentation chrome and missed the components. So the audit reads the
/docs/examples/… paths out of the 113 pages listed in
side-navigation.yaml, then visits each of the resulting 881 example URLs on its
own. A violation found there belongs to the framework, not to the site around it.
The axe-core call
await axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
resultTypes: ['violations'],
});
- Target: vanillaframework.io, Vanilla Framework 4.61.0
- Tools: axe-core 4.14.0, driven by playwright-core 1.64.1
- Browser: Microsoft Edge 156.0.4314.58
- Run: 2026-11-02; 870 of 881 pages reported at least one violation
What this does not prove
This page
Semantic HTML, styled with Vanilla Framework 4.61.0 compiled from the framework’s own Sass
sources with only the components this page renders included — vf-p-strip,
vf-p-data-spotlight, vf-p-code-snippet and so on — rather than the
whole framework. No JavaScript framework; the three interactive pieces are about ninety lines of plain
DOM code.
No violations
It also leaves 6 contrast checks undecided rather than passed, which is worth
stating plainly. 5 of them are inside Vanilla’s own notification pattern, where the
status icon is a background image and axe cannot resolve what sits behind the text. The
remaining one is the highlighted token in the “After” snippet, where an
<ins> overlaps its parent. I worked those composites out by hand instead:
15.39:1 and 14.49:1 in the light theme, 8.47:1 and 9.36:1 in the dark, against a 4.5:1
threshold. The figures are in a comment in src/styles.scss.
Decisions worth naming
-
The theme toggle at the top of this page uses the fixed pattern.
aria-pressedon native buttons, inside arole="group"with anaria-label. It would be a poor advert for the fix to ship the bug on the page that describes it. -
Every code block is reachable by keyboard.
scrollable-region-focusable— content that scrolls but cannot be focused — is one of the 15 rules in the table above, on 13 pages. Each<pre>here carriestabindex="0"and an accessible name. - Impact is never colour alone. Each status label spells out “critical” or “serious” in text, so the distinction survives greyscale and colour blindness.
-
The filter announces itself. Changing it updates a
role="status"region, so a screen reader user is told how many rows remain instead of silently losing most of the table. -
Sorting puts
aria-sorton the header and a real button inside it. That is Vanilla’s own sortable-table pattern, and unlike its legacy variant it can be operated from the keyboard. -
The theme follows the system by default through
prefers-color-scheme, and overrides it only once you choose. The choice is remembered inlocalStorageinside atry/catch, because that call throws in a private window.