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.
| Item | Qty | Total |
|---|---|---|
| Pallet jack | 2 | $5,600.00 |
| Shrink wrap (roll) | 40 | $1,800.00 |
| Labels | 12 | $5,000.00 |
- R. Vance submitted
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.
Lines 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.
Schedule 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.
Invoices 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.
Receipts 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.
Approvals 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.
Attachments 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.
Audit trail 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.
Settings 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.
Addresses settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Contacts settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Payment terms settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Bank details settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Tax settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Purchasing settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Invoicing settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Approvals settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Documents settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Notes settings. Twelve sections in a 15rem box: the tab list scrolls on its own, and this panel stays put.
Audit trail 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
| Class | Role |
|---|---|
| .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
hiddenattribute; addrole="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)
| Text | on | Light | Dark |
|---|---|---|---|
| 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 |