Accessibility
mx has no accessibility layer, and doesn't need one. aria-* attributes and role are plain attributes - they flow through render() and $attrs() like any other. The accessible name of an icon button is one prop. The hard parts - focus management, keyboard interaction, live regions - are platform APIs you call directly, not features a framework gates behind its own abstractions.
That's the whole philosophy of this page: use the platform. A real <button> is already keyboard-operable and announced as a button. A real <label for> already names its input. Reach for ARIA only to fill the gaps the platform leaves - and when you do, mx puts no friction between you and the attribute.
aria-* and role pass straight through
No special syntax. Put aria-* and role in the attributes object alongside everything else:
let { button } = mx;
// aria-* attributes are just attributes - they pass straight through
button({
'aria-label': 'Close dialog',
'aria-expanded': String(open), // boolean: String keeps "false"; a bare false removes the attr
'aria-controls': 'menu-1',
onclick: toggle
}, mx.anIcon({ icon: 'close' }));Boolean ARIA values
mx sets a boolean true to the string "true" - which is precisely what enumerated ARIA attributes require (aria-hidden="true", not aria-hidden=""). But a boolean false removes the attribute, and for tri-state ARIA that's usually wrong: aria-pressed="false" (a toggle that's off) is a different state from aria-pressed being absent (not a toggle at all):
// mx sets boolean true to the string "true" - exactly what
// enumerated ARIA attributes expect:
mx.div({ 'aria-hidden': true }) // aria-hidden="true" ✓
mx.div({ role: 'switch', 'aria-checked': true }) // aria-checked="true" ✓
// But boolean false REMOVES the attribute - which is wrong for
// tri-state ARIA, where "false" is a meaningful value:
mx.button({ 'aria-pressed': false }) // ✗ attribute removed, not "false"
// For ARIA states that need an explicit "false", coerce to a string -
// String(false) is "false", which SETS the attribute instead of removing it:
mx.button({ 'aria-pressed': String(pressed) }) // ✓ (pressed is a boolean)Rule of thumb
For aria-hidden and other "present or gone" flags, a boolean is fine. For aria-pressed, aria-checked, aria-expanded, aria-selected - anything with a meaningful "false" - coerce to a string with String(value): String(false) is "false" (attribute kept), whereas a bare false removes it.
A copy-paste aria() helper
If you set ARIA state a lot, a tiny helper takes the String() decision off your hands. It is not part of mx - copy it if you like. It prefixes each key with aria-, stringifies the value (so a boolean false becomes the string "false" rather than a removed attribute), and drops null/undefined so you can omit conditionally:
// Optional copy-paste helper - not part of mx. Prefixes each key with
// "aria-", stringifies the value (so a boolean false becomes "false"
// instead of a removed attribute), and drops null/undefined.
let aria = states => {
let attrs = {};
for (let key in states)
if (states[key] != null) attrs['aria-' + key] = '' + states[key];
return attrs;
};
aria({ checked: true, pressed: false })
// -> { 'aria-checked': 'true', 'aria-pressed': 'false' }// Spread it into any element's attributes - no per-attribute String() calls:
mx.button({ ...aria({ pressed, expanded: open, controls: 'menu-1' }), onclick: toggle }, 'Menu')
// Numbers stringify, string enums pass through, and null/undefined drop out,
// so conditional ARIA stays clean:
aria({ valuenow: 40, current: onThisPage ? 'page' : null })
// -> { 'aria-valuenow': '40' } (aria-current omitted when not the current page)false vs null, deliberately
The two nullish values give you both behaviors: a boolean false becomes "false" (the attribute stays, marking an explicit off state), while null/undefined drops the key entirely (the attribute is absent). That's exactly the tri-state vs not-applicable distinction ARIA cares about - aria({ pressed: isToggle ? on : null }) sets "true"/"false" for a real toggle and nothing at all when it isn't one.
Use real elements first
The single biggest accessibility win costs nothing: render the element that already means what you want. A <div onclick> is invisible to keyboards and screen readers; a <button> is focusable, activates on Enter and Space, exposes a button role, and supports :disabled - for free.
// ✗ A "button" with none of a button's behavior
mx.div({ class: 'btn', onclick: submit }, 'Save')
// no keyboard activation, no focusability, no role, no :disabled
// ✓ A real button - focusable, Enter/Space activate it, role is implicit
mx.button({ class: 'btn', onclick: submit }, 'Save')
// ✓ Real link, real label/input pairing, real disabled state
mx.a({ href: '/settings' }, 'Settings')
mx.label({ for: 'email' }, 'Email')
mx.input({ id: 'email', type: 'email', disabled: !ready })You only need ARIA when no native element fits (a custom listbox, a tab set, a tree). Everything a native element gives you, you'd otherwise have to re-implement with tabindex, key handlers, and role - and get exactly right. Don't, unless you must.
Accessible names
Every interactive control needs an accessible name. Visible text is the name automatically. When there's no visible text - icon-only buttons - or when a field needs extra description, name it explicitly. These five attributes cover almost everything:
aria-label - a name supplied directly as a string.aria-labelledby - a name borrowed from another element's text, by id.aria-describedby - supplementary description (hints, error text), by id.aria-placeholder - the placeholder's semantic equivalent for custom inputs.aria-errormessage - points at the element holding a field's current error (only announced while aria-invalid="true").
// Icon-only control: no visible text, so give it an accessible name
mx.button({ 'aria-label': 'Add to favorites', onclick: fav },
mx.anIcon({ icon: 'star' })
);
// Field described by helper text - point at the element holding it
mx.div(
mx.label({ for: 'pw' }, 'Password'),
mx.input({ id: 'pw', type: 'password', 'aria-describedby': 'pw-hint' }),
mx.small({ id: 'pw-hint' }, 'At least 12 characters')
);
// Group labelled by its own heading
mx.section({ role: 'group', 'aria-labelledby': 'billing-h' },
mx.h3({ id: 'billing-h' }, 'Billing'),
/* fields... */
);aria-errormessage needs aria-invalid
Per the ARIA spec, aria-errormessage is exposed to assistive tech only when the same element also has aria-invalid="true" (or "grammar"/"spelling"). With aria-invalid absent or "false", the error pointer is ignored. Always set them together, and clear both when the field becomes valid:
mx.input({
'aria-invalid': error ? 'true' : 'false',
'aria-errormessage': error ? 'pw-err' : null
});
error && mx.small({ id: 'pw-err' }, error);Prefer a real label
When the element can have a visible <label>, use one - it's a larger click target and survives translation. Reserve aria-label for genuinely text-free controls like icon buttons.
Focus management
Two rules cover most of it: when an overlay opens, move focus into it and keep it there; when it closes, return focus to whatever opened it. The platform gives you a clean tool for the trapping part - the inert attribute, which makes a subtree unfocusable and invisible to assistive tech in one property.
// Generic focus trap using the platform's inert attribute.
// Stack-based, so nested dialogs work. No dependencies.
let focusStack = [];
let FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function trapFocus(panel) {
let previousFocus = document.activeElement;
let inerted = [];
// Walk up from the panel; at each level, inert every sibling NOT on the
// path to the panel. Works wherever the panel is mounted - directly under
// <body> or nested inside an app wrapper - not just as a body child.
for (let node = panel; node && node !== document.body; node = node.parentElement) {
node.parentElement?.childNodes.forEach(sib => {
if (sib.nodeType === 1 && sib !== node && !sib.inert) {
sib.inert = true;
inerted.push(sib);
}
});
}
focusStack.push({ panel, previousFocus, inerted });
// Move focus to the first focusable control (or the panel itself)
(panel.querySelector(FOCUSABLE) || panel).focus();
}
function releaseFocus(panel) {
let top = focusStack.pop();
if (!top) return;
top.inerted.forEach(node => node.inert = false); // un-inert the page
if (top.previousFocus && document.body.contains(top.previousFocus))
top.previousFocus.focus(); // restore focus to the trigger
}Open and close a dialog with it. Note the symmetry: trap on open, release on close, and Escape closes - the keyboard contract users expect from a modal:
function openDialog(content) {
let dialog = dom.div({ role: 'dialog', 'aria-modal': 'true', tabindex: '-1', class: 'dialog' },
content,
mx.button({ 'aria-label': 'Close', onclick: close }, mx.anIcon({ icon: 'close' }))
);
function onKey(e) { if (e.key === 'Escape') close(); }
function close() {
document.removeEventListener('keydown', onKey);
releaseFocus(dialog); // restore focus to whatever opened the dialog
dialog.remove();
}
document.body.append(dialog);
document.addEventListener('keydown', onKey);
trapFocus(dialog); // move + trap focus, inert the rest of the page
return dialog;
}Why inert, not a focus-loop
The old approach was to intercept Tab and wrap focus manually. inert is better: it's declarative, it also hides the background from screen-reader virtual cursors (a focus-loop doesn't), and it's one line. The stack lets a dialog open a second dialog and unwind correctly.
Don't trap focus on non-modal surfaces
A trap belongs to modal dialogs and the like. A popover or dropdown that doesn't block the page should not trap focus - closing on Escape or outside-click and returning focus is enough. Trapping a non-modal surface strands keyboard users.
Keyboard navigation
Custom widgets that group items - listboxes, menus, tab sets - use roving focus: the container is the single tab stop, and arrow keys move an "active" index inside it. mx makes the active index ordinary state, so the key handler is a few lines and the markup re-renders with the right aria-selected:
define('option-list', {
$({ options = [], onChoose, active = 0 }) {
// The list owns focus; arrow keys move a roving "active" index.
this.setAttribute('role', 'listbox');
this.setAttribute('tabindex', '0');
this.onkeydown = e => {
let last = options.length - 1;
if (e.key === 'ArrowDown') this.$({ active: active < last ? active + 1 : 0 });
else if (e.key === 'ArrowUp') this.$({ active: active > 0 ? active - 1 : last });
else if (e.key === 'Home') this.$({ active: 0 });
else if (e.key === 'End') this.$({ active: last });
else if (e.key === 'Enter' || e.key === ' ') onChoose?.(options[active]);
else return; // let other keys (Tab, typing) through
e.preventDefault();
};
return options.map((opt, i) =>
mx.div({
role: 'option',
id: 'opt-' + i,
'aria-selected': i === active ? 'true' : 'false',
class: i === active ? 'option active' : 'option'
}, opt.label)
);
}
});
// Tell screen readers which option is active without moving real focus
// (aria-activedescendant points at the highlighted option's id)
listEl.setAttribute('aria-activedescendant', 'opt-' + activeIndex);The keys to support depend on the pattern - the ARIA Authoring Practices enumerate them per widget. The mechanics in mx are always the same: this.onkeydown updates an index in $state, the render reflects it in aria-selected, and aria-activedescendant tells assistive tech which item is active without moving real DOM focus.
Roving tabindex vs aria-activedescendant
Two valid models. aria-activedescendant (shown above) keeps focus on the container and points at the active child by id - simplest with mx, since the children re-render freely. Roving tabindex instead gives the active child tabindex="0" and the rest tabindex="-1", then calls .focus() on the active one. Use the latter when each item must be the real focus target (e.g. items contain their own controls).
Live regions
When something changes away from the user's focus - a result count updates, a toast appears, a background save finishes - screen readers won't notice unless you tell them. A live region is a container whose text changes get announced automatically. Create one, write to it:
// A single polite live region, created once. Updating its text
// makes screen readers announce the new content without moving focus.
let announcer = dom.div({
'aria-live': 'polite',
'aria-atomic': 'true',
class: 'visually-hidden' // off-screen, not display:none (which AT ignores)
});
document.body.append(announcer);
function announce(message) {
announcer.textContent = ''; // reset so repeats re-announce
requestAnimationFrame(() => announcer.textContent = message);
}
// Use it for things that happen away from the user's focus:
announce('5 results found');
announce('Draft saved');
// Urgent, interrupting messages: role="alert" already implies
// aria-live="assertive" + aria-atomic="true", so don't repeat them.
let errors = dom.div({ role: 'alert', class: 'visually-hidden' });aria-live="polite" - announce when the user is idle. Default for status, counts, confirmations.aria-live="assertive" (or role="alert") - interrupt immediately. Reserve for errors and time-critical messages.aria-atomic="true" - announce the whole region on change, not just the diff.
aria-busy for loading regions
While a region rebuilds, mark it aria-busy so assistive tech waits for a settled tree instead of announcing it mid-construction:
// While a region is loading, mark it busy so AT doesn't announce
// a half-built tree. Clear it when the content settles.
panel.$attrs({ 'aria-busy': loading ? 'true' : null });
return loading
? mx.div({ role: 'status' }, mx.aSpinner(), 'Loading...')
: mx.div(/* the loaded content */);Off-screen, not display:none
A visually-hidden live region must stay in the accessibility tree - use the clip/off-screen technique (position:absolute;width:1px;height:1px;overflow:hidden), not display:none or visibility:hidden, which remove it from the tree and silence announcements.
Checklist
| Need | Reach for |
|---|
| A clickable thing | A real <button>/<a> - not <div onclick> |
| Name an icon-only control | aria-label |
| Describe a field / its error | aria-describedby / aria-errormessage |
| A toggle / disclosure state | aria-pressed/aria-expanded via String(value), not a bare boolean |
| Trap focus in a modal | inert + restore focus on close + Escape |
| Arrow-key a custom widget | Roving index in $state + aria-activedescendant |
| Announce a background change | aria-live region |
The throughline
Accessibility in mx isn't a feature you turn on - it's the platform you were already standing on. Semantic elements carry their own semantics through the reconciler untouched; aria-* is just attributes; focus and keyboard are DOM APIs. The framework's job is to stay out of the way.
Related: Forms (labels, validation messaging), Lifecycle (cleaning up the keydown listeners these patterns add), and tag-level macros (enforcing a11y defaults on every instance of a tag).