Skip to content

Vanilla API

components/folder-tabs/vanilla/folder-tabs.js — one exported function, zero dependencies, a standard ES module.

js
import { initFolderTabs } from "./vanilla/folder-tabs.js";
initFolderTabs();

A default export is also available for convenience:

js
import initFolderTabs from "./vanilla/folder-tabs.js";

initFolderTabs(root?)

ts
function initFolderTabs(root?: ParentNode): () => void;
ParameterTypeDefaultDescription
rootParentNodedocumentThe subtree to scan for [data-folder-tabs] hosts

Returns a teardown function that removes every listener and observer attached by that call.

Three properties worth relying on:

It is idempotent. Hosts it has already wired are remembered and skipped, so calling it twice does not double-bind anything. After injecting new markup you can simply call it again with no argument.

It returns a teardown. In a single-page app that unmounts the markup, call it before removing the DOM:

js
const teardown = initFolderTabs(container);
// later
teardown();

Tearing down also forgets the hosts, so a later initFolderTabs() will wire them again if they are still on the page.

It applies the starting state rather than trusting it. After wiring, it runs a selection pass using whichever tab carries data-state="active" (or the first tab if none does), so aria-selected, the roving tabindex, and the panels' hidden attributes are guaranteed correct even if your server-rendered HTML got one of them wrong.

The markup contract

This build reads your markup rather than generating it, so the structure has to be right. Requirements marked direct child are enforced with :scope > selectors — that is what stops an outer control from adopting a nested control's tabs.

html
<div data-folder-tabs>
  <div class="fldr-rail" role="tablist" aria-label="Sections">
    <div class="fldr-scroll">
      <button
        class="fldr-tab"
        role="tab"
        id="t1"
        aria-controls="p1"
        data-state="active"
        aria-selected="true"
      >
        Rules
      </button>
      <button
        class="fldr-tab"
        role="tab"
        id="t2"
        aria-controls="p2"
        data-state="inactive"
        aria-selected="false"
      >
        Dates
      </button>
    </div>

    <div class="fldr-arrows">
      <button class="fldr-arrow" data-dir="-1" tabindex="-1" aria-label="Scroll tabs left">

      </button>
      <button class="fldr-arrow" data-dir="1" tabindex="-1" aria-label="Scroll tabs right">

      </button>
    </div>
  </div>

  <div class="fldr-panel" id="p1" role="tabpanel" aria-labelledby="t1" tabindex="0">…</div>
  <div class="fldr-panel" id="p2" role="tabpanel" aria-labelledby="t2" tabindex="0" hidden>…</div>
</div>
ElementRequirement
The hostAny element with data-folder-tabs
.fldr-railDirect child of the host. role="tablist" and an aria-label
.fldr-scrollDirect child of the rail
.fldr-tabDirect child of .fldr-scroll. A button with role="tab", a unique id, and aria-controls pointing at its panel's id
.fldr-arrowsOptional. Direct child of the rail, after the strip
.fldr-arrowTwo buttons inside it, with data-dir="-1" and data-dir="1", both tabindex="-1" and labelled
.fldr-panelAnywhere inside the host, with a matching id, role="tabpanel", aria-labelledby back to its tab, and tabindex="0"
Initial statedata-state="active" on exactly one tab; hidden on every panel except that one

If nothing matching [role="tab"] is found inside .fldr-scroll, setup exits quietly and that host is left alone. If .fldr-arrows is absent, everything else still works — you simply have no arrow buttons.

Why the pairing is not optional

aria-controls is not decoration here — it is the only way the script finds the panel to show. A tab wired without it will still select and style correctly, but no panel will be shown or hidden.

The build treats that as a deliberate case rather than an error: it only touches a panel a tab actually owns. An earlier version hid the outgoing panel unconditionally, which meant one tab missing aria-controls could leave the rail floating above nothing — the mechanic gone, which is precisely the failure this control exists to prevent.

The aria-labelledby on the panel is what a screen reader reads. Without it the panel is an anonymous div.

Data attributes

AttributeOnWritten byValues
data-folder-tabsThe hostYouPresence only
data-state.fldr-tabThe script"active" | "inactive"
data-dir.fldr-arrowYou"-1" (left) | "1" (right)
data-overflowing.fldr-arrowsThe script"true" | "false"

data-state also seeds the initial selection, so it is both an input at startup and an output thereafter.

Disabled tabs

Two spellings, both honoured, and they are not the same thing:

disabledaria-disabled="true"
FocusableNoYes
Skipped by arrow keysYesYes
Refuses to activateYesYes
Styled opacity: 0.45YesYes

aria-disabled is the WAI-ARIA preference, because it keeps the tab reachable so a screen reader user can find it and be told why it is unavailable. Use native disabled when the tab is genuinely not part of the current task. See two kinds of disabled.

Internally both are collapsed into one check, so anything that skips a disabled tab skips either spelling.

Keyboard

Handled by one delegated keydown listener on the strip.

KeyBehaviour
Next usable tab, wrapping to the first
Previous usable tab, wrapping to the last
HomeFirst usable tab
EndLast usable tab
Enter / SpaceActivate the focused tab

Movement keys select and focus. Enter and Space select without moving focus, and are ignored on an aria-disabled tab — which can receive the keypress, since it is still focusable.

Every handled key calls preventDefault(), so Space does not also scroll the page.

The folder-tabs:change event

Every selection dispatches a bubbling CustomEvent on the host:

js
document.addEventListener("folder-tabs:change", (event) => {
  const { index, tab, panel } = event.detail;
  console.log("selected", index, tab.textContent);
});
detailTypeDescription
indexnumberZero-based index of the selected tab within its own strip
tabHTMLElementThe selected tab
panelHTMLElement | nullIts panel, or null if the tab has no aria-controls

It bubbles, so you can listen on document and handle every control on the page from one place. Note that it also fires once during setup, when the starting state is applied — if you only want user-driven changes, ignore the first event per host or attach the listener after initFolderTabs() returns.

Overflow behaviour

The arrows appear only on real overflow and disable at each end rather than disappearing:

js
const max = strip.scrollWidth - strip.clientWidth;
arrows.dataset.overflowing = String(max > 1);
left.disabled = strip.scrollLeft <= 1;
right.disabled = strip.scrollLeft >= max - 1;

A click scrolls by Math.max(140, strip.clientWidth * 0.7). Measurement is re-run on scroll, on window resize, and through a ResizeObserver watching the strip and every tab — throttled to one measurement per animation frame, because scrollWidth forces layout and reading it on every scroll event makes a smooth strip feel slow.

Full reasoning: Overflow.

Revealing the focused tab

js
const pad = 12;
const left = tab.offsetLeft - pad;
const right = tab.offsetLeft + tab.offsetWidth + pad;
if (left < strip.scrollLeft) strip.scrollLeft = left;
else if (right > strip.scrollLeft + strip.clientWidth) {
  strip.scrollLeft = right - strip.clientWidth;
}

scrollIntoView() is deliberately not used: it walks every scrollable ancestor including the document, so arrow-keying along a tab strip can jump the whole page. Moving the strip's own scrollLeft touches exactly one scroll container.

Nesting

Put another [data-folder-tabs] host inside a panel and add fldr-nested to it. initFolderTabs() finds nested hosts on its own, and the :scope > selectors keep each control's tabs to itself.

Give the nested role="tablist" its own aria-label. See Nesting.

Browser requirements

FeatureUsed for
ES modulesThe import itself
Element.closestDelegated click and keydown handling
CSS.escapeBuilding the #id selector for a panel safely
ResizeObserverRe-measuring overflow. Optional — guarded by if (window.ResizeObserver), and scroll/resize handling still works without it
WeakSetThe idempotence guard