Skip to main content

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">
  1. Swap aria-selected for aria-pressed — in the button template and in the state setter that runs on every theme change, so the two cannot disagree.
  2. Drop role="button". A native <button> already has that role; repeating it added nothing, and it is what made the invalid attribute look plausible.
  3. Give the wrapper role="group" and aria-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:

The same example page measured as published and with the fix applied, across five properties.
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.

The 15 axe-core rules that the theme toggle fix does not address, with the number of example pages each affects.
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

Automated rules catch a minority of real accessibility barriers — roughly a third, by most estimates. A clean axe run means no machine-detectable violations, not an accessible component. Focus order, meaningful names, reading order and whether an interaction can be completed at all still need a keyboard and a screen reader. The theme toggle is a good illustration: axe found the invalid attribute, but the reason it matters only becomes obvious when you listen to the control.

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

axe-core 4.14.0 reports zero violations against wcag2a, wcag2aa, wcag21a, wcag21aa on this page, in both the light and dark themes, checked on 2026-11-02. 24 checks pass, and the keyboard reaches all 44 interactive elements in document order, starting with the skip link.

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-pressed on native buttons, inside a role="group" with an aria-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 carries tabindex="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-sort on 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 in localStorage inside a try/catch, because that call throws in a private window.