busy-office-ui
Menu

Tabs

ARIA tabs pattern: CSS styles aria-selected, panels hide via the hidden attribute, initTabs() adds roving tabindex and arrow-key navigation with automatic activation. Not for moving between pages — tabs swap panels within one screen and keep the URL, so a browser Back that skips the whole screen is the tell. Cross-screen movement belongs to the sidebar and Breadcrumb.

More tabs than fit

Overview panel. Nine tabs in a 34rem strip: the list scrolls, it never wraps and never clips, and each tab owns its own panel — sharing one panel between tabs looks fine until the second click.

A tab strip wider than its container scrolls sideways. It never wraps and never clips, so no tab is unreachable.

The edge fades when there is more to reach — on the right when you are at the start, on the left when you are at the end, both when you are in the middle. It appears only when the strip actually overflows, so a strip that fits is never dimmed.

It is a mask rather than a gradient background on purpose: a background fade has to know the colour behind it, and this list sits on the page canvas here but on a card surface inside an app. A mask fades to transparency, so it composites correctly over whatever is actually there. The fade is also dropped under forced-colors, where costing contrast to give a hint is the wrong trade.

Why a fade and not arrow buttons: arrows would need either an overlay background (the same colour problem) or a wrapper injected into your markup, to duplicate what shift+wheel, a trackpad swipe and the arrow keys already do. Keyboard users never needed it — arrow keys move between tabs and the browser brings the focused one into view, including tabs that are off-screen.

A left rail instead of a strip

General settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.

Add bo-tabs--vertical. That is the whole change — same markup, same initTabs(), same parts. Settings screens reach for this once there are more sections than fit comfortably across the top, and because the rail runs down the page it holds far more of them.

The arrow keys follow the rail. Up and Down move between tabs here; Left and Right do in the horizontal strip above. You do not set aria-orientation — the behavior reads how the list is actually laid out and sets it, which matters because that answer changes with the container width.

The rail scrolls when it is taller than its box, and the fade moves to the top and bottom edges to match. Height comes from the panel beside it, so a rail with a few tabs simply doesn't scroll and is never dimmed. This demo pins 15rem to show the overflow; an app usually gets it from the surrounding screen.

Narrow containers get the strip back. Below 30rem — measured on the tab set's own width, not the window's, so a rail inside a widget collapses while the page around it is still wide — the rail becomes the horizontal strip, the panel moves underneath, and the arrow keys return to Left and Right. Resize this page to see it.

Markup

<div class="bo-tabs">
  <div class="bo-tabs__list" role="tablist" aria-label="Purchase order">
    <button class="bo-tabs__tab" role="tab" id="t-details"
            aria-selected="true" aria-controls="p-details">Details</button>
    <button class="bo-tabs__tab" role="tab" id="t-lines" tabindex="-1"
            aria-selected="false" aria-controls="p-lines">Lines</button>
  </div>
  <div class="bo-tabs__panel" role="tabpanel" id="p-details"
       aria-labelledby="t-details" tabindex="0">…</div>
  <div class="bo-tabs__panel" role="tabpanel" id="p-lines"
       aria-labelledby="t-lines" tabindex="0" hidden>…</div>
</div>
<script type="module">
  import { initTabs } from "@busy-office/ui/js";
  initTabs(); // once — arrow keys, Home/End, roving tabindex
</script>

Tabs require initTabs() — there is no CSS-only fallback. Without it the pre-set tabindex="-1" leaves tabs 2+ keyboard-unreachable. Arrow keys are direction-aware in RTL.

Quick reference generated

ClassRole
.bo-tabs--vertical variant: vertical
.bo-tabs__list part: list
.bo-tabs__panel part: panel
.bo-tabs__tab part: tab

State attributes: data-overflowaria-selected

API generated from CSS

Blocks bo-tabs
Parts bo-tabs__listbo-tabs__panelbo-tabs__tab
Variants bo-tabs--vertical
data-* hooks data-overflow="both|end|start"
ARIA styled aria-selected
Named containers bo-tabs
JS required REQUIRED: initTabs() — without it tabs 2+ are keyboard-unreachable and inert; there is no CSS-only fallback.
  • ARIA contract: role=tablist/tab/tabpanel, aria-controls/aria-labelledby, panels hidden via the hidden attribute; add role="list" where lists are styled.
  • Arrow keys are RTL-aware; activation is automatic on focus move.
Contrast — 17 AA pairs this component uses (generated, CI-gated)
TextonLightDark
text-primary bg-surface 17.74 16.15
text-primary bg-canvas 16.98 18.08
text-secondary bg-surface 10.31 11.46
text-secondary bg-muted 9.37 9.75
bg-surface text-primary 17.74 16.15
accent-text accent-subtle 7.27 8.38
accent bg-surface 5.47 9.07
text-primary bg-hover 16.12 13.75
text-primary bg-selected 17.01 11.86
text-secondary bg-selected 9.88 8.41
accent-text bg-selected 7.27 8.38
text-primary bg-surface-raised 17.74 14.51
text-secondary bg-canvas 9.86 12.82
accent bg-selected 5.25 6.66
accent accent-subtle 5.25 6.66
accent bg-muted 4.97 7.72
text-primary bg-muted 16.12 13.75

Maturity generated

Introduced
v0.1.0
CSS floor
Chrome/Edge 105 · Firefox 110 · Safari 16
Alignment scored
2026-08-21
Assistive-tech evidence
None recorded

Pin this component's first release with npm i @busy-office/ui@0.1.0. The floor is derived from this component's stylesheet alone — set by @container — so it sits at or below the whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5. No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour. What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see accessibility.

Design-system alignment generated

100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.

Rule Score Evidence
Typography 3 / 3 no raw font-size; sizes from density
Colour 3 / 3 the nine #000 are MASK alpha inside linear-gradient(), not theme colour — a mask fades to transparency precisely so no cover colour is assumed; reconciled, not a raw-hex defect
Spacing 3 / 3 the 2px selected underline states why it is heavier than --bo-border-width, and the 2.5rem fade distance is explained ONCE for the family of six rules that share it rather than six times (94.6b)
Interaction 3 / 3 ARIA tabs with roving tabindex + arrow keys from initTabs(); the overflow affordance was chosen after measuring that scrollbar-color and ::-webkit-scrollbar reserved no space on macOS (30.1)
Content 3 / 3 opener carries the wrong-choice clause: "Not for moving between pages" — tabs swap panels within one screen and keep the URL, so a browser Back that skips the whole screen is the tell; the clause names and links sidebar-nav and Breadcrumb for cross-screen movement
Fit 3 / 3 vertical variant is the ERP settings left-rail shape (36.1); used in 2 pattern screens
↑↓ move Enter open Esc close