Tabs

Horizontal tab strip with keyboard navigation (←/→ and Home/End). Single state field holds the active tab id; the active class and the visible panel both derive from it. Each panel is a data-if region — only the active one is in the DOM.

Live preview

Plan summary, recent activity, and usage trends.
Invoices, payment method, billing email — and a button that downloads a CSV of past charges.
Active sessions, API keys, two-factor authentication.

Markup

<div data-component="tabs-demo">
  <div class="tabs-bar" role="tablist">
    <button class="tab-trigger" role="tab"
      @click="select" @keydown="onKey"
      data-bind="data-id:'overview', tabindex:active === 'overview' ? 0 : -1"
      data-class="active:active === 'overview'">Overview</button>
    <button class="tab-trigger" role="tab"
      @click="select" @keydown="onKey"
      data-bind="data-id:'billing', tabindex:active === 'billing' ? 0 : -1"
      data-class="active:active === 'billing'">Billing</button>
    <button class="tab-trigger" role="tab"
      @click="select" @keydown="onKey"
      data-bind="data-id:'security', tabindex:active === 'security' ? 0 : -1"
      data-class="active:active === 'security'">Security</button>
  </div>

  <div class="tab-panel" role="tabpanel" data-if="active === 'overview'">…</div>
  <div class="tab-panel" role="tabpanel" data-if="active === 'billing'">…</div>
  <div class="tab-panel" role="tabpanel" data-if="active === 'security'">…</div>
</div>

Definition

Micra.define('tabs-demo', {
  state: { active: 'overview' },

  // Static config exposed as a method — Micra only copies functions from
  // the definition onto the instance, not plain fields. Bonus: methods can
  // pull from state if the tab set ever becomes dynamic.
  ids() { return ['overview', 'billing', 'security'] },

  select(e) {
    this.state.active = e.currentTarget.dataset.id
  },

  onKey(e) {
    const ids = this.ids()
    const i = ids.indexOf(this.state.active)
    if (e.key === 'ArrowRight') {
      this.state.active = ids[(i + 1) % ids.length]
      this._focusActive()
    } else if (e.key === 'ArrowLeft') {
      this.state.active = ids[(i - 1 + ids.length) % ids.length]
      this._focusActive()
    } else if (e.key === 'Home') {
      this.state.active = ids[0]
      this._focusActive()
    } else if (e.key === 'End') {
      this.state.active = ids[ids.length - 1]
      this._focusActive()
    }
  },

  _focusActive() {
    // Wait one microtask so Micra re-renders with the new active class,
    // then move focus to the matching trigger.
    queueMicrotask(() => {
      const el = this.$el.querySelector(`[data-id="${this.state.active}"]`)
      el?.focus()
    })
  },
})

Integration

  • Roving tabindex. tabindex:active === id ? 0 : -1 puts only the active tab in the tab order, matching ARIA authoring practices.
  • Keyboard. ←/→ moves between tabs (wraps), Home jumps to first, End to last. After a key-driven change, focus moves to the new active trigger.
  • Panel state. Panels use data-if so inactive content is removed from the DOM — that includes any costly children (charts, video players) and ARIA / focus traps don't reach them.
  • Static config as a method. ids() returns a literal array — it stays out of the reactive proxy (not in state) and survives the trip from definition to instance (Micra only copies functions from the definition onto the instance, not plain data fields).

Pitfalls

  • Don't use @keydown.enter — Enter/Space already activate buttons. The keydown handler here is for arrow / Home / End navigation.
  • Don't move focus synchronously inside the handler — the directive map still reflects the old active state, so focusing the new trigger before render would land on the wrong element in some edge cases. queueMicrotask waits for Micra's batched re-render.
  • For lazy panel content (heavy components, async data), keep data-if instead of data-show — inactive panels pay zero cost.