← All posts

Accessible components in Micra: focus, keyboard, and aria from one state

Three things decide whether a small interactive component works without a mouse: where focus goes when something opens or changes, what the keys do, and whether the aria attributes say what is actually on the screen. None of them shows up when you click through a page with a mouse, which is why they’re the part that tends to be missing.

Below are three components from the Micra docs — a modal, tabs, and a switch — and in each one you can see where the accessibility lives and how many lines it takes. Two of them got shorter in 2.9: the modal no longer needs a hand-written document listener, and both lost a setTimeout that was there to wait for the render.

The include, so the examples run:

<script src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>

An accessible dialog has four duties, and all of them are about focus. On open, focus moves inside. Tab cycles through the dialog’s elements and never escapes to the page underneath. Escape closes it. On close, focus returns to whatever opened the dialog, so a keyboard user continues from where they were, not from the top of the document.

<div data-component="modal" @keydown.escape.window="close" @keydown.tab.window="trapTab">
  <button @click="open">Delete project</button>

  <div data-show="isOpen">
    <div class="backdrop" @click="close"></div>
    <div data-ref="dialog" role="dialog" aria-modal="true" aria-labelledby="m-title">
      <h2 id="m-title">Delete project</h2>
      <p>This action cannot be undone.</p>
      <button @click="close">Cancel</button>
      <button @click="close">Delete</button>
    </div>
  </div>
</div>

The dialog markup is ordinary: role="dialog", aria-modal="true", the heading wired up through aria-labelledby. data-ref="dialog" gives the focus code a handle on the element without looking it up by id. The two attributes on the root are the keyboard: .window puts the listener on window, so Escape and Tab are caught wherever focus happens to be.

Micra.define("modal", {
  state: { isOpen: false },

  async open() {
    this._last = document.activeElement; // remember where we came from
    this.state.isOpen = true;
    await this.tick(); // the dialog is rendered now
    this.refs.dialog.querySelector("button").focus();
  },

  close() {
    if (!this.state.isOpen) return;
    this.state.isOpen = false;
    this._last?.focus(); // hand focus back to the trigger
  },

  // the trap: Tab on the last element wraps to the first, and back
  trapTab(e) {
    if (!this.state.isOpen) return;
    const items = [...this.refs.dialog.querySelectorAll("button, [href], input")];
    const first = items[0],
      last = items[items.length - 1];
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  },
});
Micra.start();

await this.tick() is there because rendering is batched: right after isOpen = true the dialog is still display: none, and focus() on a hidden element does nothing. tick() resolves once the render has landed. Before 2.9 this line was a setTimeout, and the keyboard listener was added to document in onCreate and removed in onDestroy by hand. Now Micra removes the two window listeners with the component, like any other handler in the markup.

Both handlers check isOpen first, because the listeners stay on window for the component’s whole life, not only while the dialog is open. Without the check, Tab on the page would be trapped by a dialog nobody can see.

One thing I ran into when moving the Micra Pro overlays to tick(): if the dialog appears through a CSS transition on visibility (a fade-in, typically), tick() resolves too early. At that moment the render has landed, but the transition has only just started and the dialog is still visibility: hidden, so focus() again does nothing. Either let visibility switch instantly on open and keep the delay only for closing, or wait for two animation frames before focusing. The data-show version above has no transition, so tick() is enough.

The whole accessibility of this modal is _last (one line to remember, one to restore), trapTab (about a dozen lines) and two attributes on the root. Take them away and you get exactly the modal that usually ships.

Tabs: roving tabindex

Tabs have a different keyboard pattern, and a less familiar one. The naive version puts every tab button in the tab order, so a keyboard user has to Tab through all of them to reach the content. The correct pattern is called a roving tabindex: the tab list holds exactly one Tab stop — the active tab has tabindex="0", the rest have -1 — and you move between tabs with the arrow keys. Focus travels together with the active state.

<div data-component="tabs">
  <div role="tablist" aria-label="Product details">
    <template data-each="tabs" data-key="id">
      <button
        role="tab"
        data-bind="data-id:item.id, class:tabClass(item.id), aria-selected:sel(item.id), tabindex:tabIndex(item.id)"
        @click="select"
        @keydown.left.prevent="prev"
        @keydown.right.prevent="next"
        data-text="item.label"
      ></button>
    </template>
  </div>

  <div data-show="active === 'overview'">…</div>
  <div data-show="active === 'specs'">…</div>
  <div data-show="active === 'reviews'">…</div>
</div>
Micra.define("tabs", {
  state: {
    active: "overview",
    tabs: [
      { id: "overview", label: "Overview" },
      { id: "specs", label: "Specs" },
      { id: "reviews", label: "Reviews" },
    ],
  },
  // everything derived is a method over `active`, not extra state fields
  sel(id) {
    return this.state.active === id ? "true" : "false";
  },
  tabIndex(id) {
    return this.state.active === id ? "0" : "-1";
  },
  tabClass(id) {
    return this.state.active === id ? "tab tab-active" : "tab";
  },
  select(e) {
    this.state.active = e.currentTarget.dataset.id;
  },
  async step(delta) {
    const t = this.state.tabs;
    const i = t.findIndex((x) => x.id === this.state.active);
    this.state.active = t[(i + delta + t.length) % t.length].id; // wraps around
    await this.tick(); // the new tabindex values are rendered
    // focus follows the active tab — the essence of a roving tabindex
    this.$el.querySelector('[data-id="' + this.state.active + '"]')?.focus();
  },
  prev() {
    this.step(-1);
  },
  next() {
    this.step(1);
  },
});
Micra.start();

tabindex, aria-selected and the highlight class are three methods over the single active field. The arrow keys are handled by the @keydown.left.prevent and @keydown.right.prevent modifiers, with no branching on e.key inside a handler. The one imperative spot is the focus() in step: the active tab changed, focus has to move to it, and that is a DOM operation by definition. tick() makes sure it runs against the tab list that already has the new tabindex values.

The accessible version of these tabs differs from the inaccessible one by a single tabIndex method and two attributes in the markup. The switching logic is the same.

Switch: role=“switch” and a string aria-checked

The shortest example, with the most instructive detail.

<div data-component="switch">
  <button
    type="button"
    role="switch"
    data-bind="aria-checked:enabled ? 'true' : 'false'"
    data-class="on:enabled"
    @click="toggle"
    aria-label="Email notifications"
  >
    <span class="knob"></span>
  </button>
</div>
Micra.define("switch", {
  state: { enabled: false },
  toggle() {
    this.state.enabled = !this.state.enabled;
  },
});
Micra.start();

The detail is the ternary: enabled ? 'true' : 'false' rather than plain enabled. ARIA state attributes (aria-checked, aria-expanded, aria-selected) take the literal strings "true" and "false"; the attribute should always be present and name its value. data-bind treats a boolean the way presence attributes like hidden or disabled want: true sets an empty attribute, false removes it. That’s right for hidden. For aria-checked it gives you either an empty attribute or a missing one, and a screen reader reads both as “off”. So ARIA state is always bound as a string. The same goes for CSS: a skin that colors the track with [aria-checked='true'] only matches the explicit string.

There’s a useful side effect: aria-checked here isn’t a description of accessibility sitting next to the logic. It’s the component’s contract. The styles and the screen reader read the same attribute, computed from the same field.

The common principle

In all three components the aria attributes are derived from the same state that drives the interface: isOpen, active, enabled. There is no separate aria bookkeeping to remember. In imperative code, aria-expanded is one more line in every handler, and it’s the first line to get forgotten: everything works visually, the mouse test passes, and the drift is noticed only by someone using a screen reader. When the attribute is declared as a function of state, you can forget it exactly once, while writing the markup. After that it updates for the same reason the highlight class does.

The same idea carries over to bigger components. In the combobox from the same docs, aria-expanded is computed from isOpen, and each option’s aria-selected comes from the highlight index over the same filtered list the arrow keys move through. The visual highlight, the keyboard cursor and what the screen reader announces are three readings of one state, so there’s nowhere for them to diverge.

Which answers the question of cost. The modal’s focus trap is about a dozen lines, written once. The roving tabindex is one method and two attributes. The string aria-checked is a ternary instead of a bare field. Everything else is code you would have written anyway.

What Micra doesn’t do

Micra has no idea what a modal is. It won’t add role="dialog", won’t wire the heading through aria-labelledby, won’t remind you that a tab list needs an aria-label. The semantics of the markup are entirely yours; the library only guarantees that the attributes you declared can’t drift from the state.

There are simplifications worth knowing, too. The list of focusable elements in the trap — button, [href], input — is deliberately short; if a select, a textarea or anything with a tabindex lands in the dialog, the selector needs extending. The modal doesn’t set aria-hidden on the rest of the page and doesn’t lock its scroll. And no pattern replaces checking by hand: walking through a component with the keyboard and listening to it with a screen reader takes less time than it sounds and finds things the code won’t show you.

I won’t claim these components cover accessibility completely; that only gets confirmed with real users. But the baseline — keyboard, focus, honest aria state — turned out to be a few lines inside the pattern rather than a separate project.

The modal, the tabs and the switch, plus the combobox with full keyboard navigation, are in the docs with runnable demos: modal, tabs, toggle, combobox.