Tabs

Mode

Skin

Theme

Overview & Philosophy

ln-tabs is applied to a wrapper element via <section data-ln-tabs> enclosing trigger buttons (data-ln-tab="key") and content panels (data-ln-panel="key"). When a user clicks a tab, it sets data-ln-tabs-active="key" on the wrapper. A MutationObserver instantly synchronizes the DOM: toggling .hidden and aria-hidden on panels and updating aria-selected on tab controls. When configured with an id and anchor triggers (<a href="#id:key">), it synchronizes tab state with the URL hash for effortless deep-linking and browser back/forward history navigation.

In ln-ashlar, this paradigm provides an N-way exclusive view without cluttering the screen or loading separate pages, keeping state, ARIA, and visual styles strictly orthogonal.

The Tabs Doctrine

The contract is simple and absolute: the active tab key lives in data-ln-tabs-active on the wrapper element, and the wrapper is the sole source of truth. Click events, URL hashchanges, localStorage restorations, and external programmatic writes all funnel into a single setAttribute call on the wrapper. A single MutationObserver callback runs the entire update pipeline. There is no second path.

Why ln-tabs is NOT built on ln-toggle

It is common in frontend frameworks to squeeze tabs into N coordinated toggles. In ln-ashlar, we intentionally avoid this due to three major architectural differences:

  1. Exclusive by Design: ln-toggle is a binary open/closed primitive. Squeezing tabs into it would require a complex coordinator layer to handle mutual exclusivity. ln-tabs enforces N-way exclusive selection by construction at the root wrapper level.
  2. Accessibility (ARIA): ln-toggle operates as a disclosure widget using aria-expanded. Tabs represent a standard tablist using aria-selected="true/false" on buttons and aria-hidden="true/false" on panels.
  3. Persistence Scopes: While ln-toggle panels persist individually, tabs use namespace-scoped URL hashes (e.g. #user-tabs:settings&project-tabs:members) or page-level localStorage namespaces to allow multiple independent tabsets to coexist cleanly on a single page.

For the complete developer reference, see the ln-tabs README and the architecture reference.

Anatomy

A tabs section has three structural pieces — a wrapper, a list of tab buttons, and a list of matching panels. The pairing is by key, not by DOM position.

<section id="user-tabs" data-ln-tabs data-ln-tabs-default="info">
    <nav>
        <button data-ln-tab="info">Information</button>
        <button data-ln-tab="settings">Settings</button>
    </nav>

    <section data-ln-panel="info">…</section>
    <section data-ln-panel="settings" class="hidden">…</section>
</section>

The wrapper — [data-ln-tabs]

The element with data-ln-tabs is the tabs root. Presence of the attribute creates the instance; the value is unused. The wrapper hosts the active-key attribute (data-ln-tabs-active, written by the component), the default-key attribute (data-ln-tabs-default), and the namespace (id or data-ln-tabs-key). Use <section>, <div>, <article>; do NOT use <main>.

The tab buttons — [data-ln-tab="key"]

Each tab button is queried by a deep selector inside the wrapper. The buttons are typically inside a <nav> for grouping, but the position is not enforced. Each button SHOULD be a <button> — the global library style and the tabs-tab mixin assume it. Inside a form, every tab button needs type="button" to avoid submitting the form on click.

The panels — [data-ln-panel="key"]

Each panel matches a tab by key. Inactive panels MUST be shipped with class="hidden" so they start hidden — without it, the user sees a flash of all-panels-stacked before the component initializes and collapses to one.

The trigger type selects the mode. <a href="#nsKey:key"> triggers run the group in URL hash mode (shareable, back/forward aware); <button> triggers run it in localStorage persist mode (opt-in via data-ln-persist, URL untouched). Don't mix the two in one group — that falls back to persist with a console warning. Anchor triggers produce shareable URLs (right-click → copy link, middle-click → open in new tab) — the click handler calls event.preventDefault() so the browser's default navigation does not race with the JS state write. Use the boolean form (data-ln-tab with no value) on anchors so the key only appears in href; explicit data-ln-tab="key" works too if you prefer the redundancy. See the live demos below.

What state goes where

"Which tab is active?" lives in data-ln-tabs-active on the wrapper. "Is this tab visually selected?" lives in data-active + aria-selected on the button (synced from the wrapper's attribute). "Is this panel visible?" lives in the .hidden class + aria-hidden on the panel (also synced). The component instance carries caches (mapTabs, mapPanels) and configuration (defaultKey, nsKey, autoFocus), but every observable piece of state is on the DOM.

Single source of truth

Every consumer that wants to change the active tab writes data-ln-tabs-active on the wrapper. There is no second path — click handlers and the hashchange listener both end at setAttribute.

const tabs = document.getElementById('user-tabs');

// Canonical write — switches the active tab
tabs.setAttribute('data-ln-tabs-active', 'settings');

// Via hash (when hash sync is enabled)
location.hash = 'user-tabs:settings';
location.hash = 'user-tabs:settings&project-tabs:members';

All paths produce the same data-active / aria-selected flips on tab buttons, the same .hidden / aria-hidden toggles on panels, the same ln-tabs:change event, the same localStorage write (if persistence is opted in and hash sync is off).

Attributes

AttributeOnDescription
data-ln-tabswrapperCreates the instance. Value unused.
data-ln-tabs-activewrapperCurrently active key. Written by the component; watched by the observer.
data-ln-tabs-default="key"wrapperDefault key. Falls back to first [data-ln-tab] if absent.
data-ln-tabs-focus="false"wrapperOpt out of auto-focus on the panel's first focusable element. Default: enabled.
data-ln-tabs-key="name"wrapperHash namespace for anchor-trigger groups, when id is unsuitable. Falls back to id.
id="name"wrapperDoubles as the hash namespace for anchor-trigger groups if data-ln-tabs-key is absent. Does not select the mode — the trigger type does.
data-ln-tab="key"tab buttonMarks the button as a tab. Key is normalized lowercase.
data-ln-panel="key"panelMatches against data-ln-tab by key.
data-ln-persistwrapperOpt in to localStorage for button-trigger groups. Boolean form keys off id; explicit form is data-ln-persist="custom-key". No effect on anchor-trigger (hash) groups.

Live demo — plain tabs (no hash, no persistence)

No id, no data-ln-tabs-key, no data-ln-persist. Active key lives only in data-ln-tabs-active on the wrapper. Reload the page and the default tab is selected.

Overview

This is the default panel. The wrapper has data-ln-tabs-default="overview".

Live demo — hash-deep-linkable tabs

Hash sync is driven by anchor triggers. The wrapper's id="user-tabs" is the hash namespace; each <a href="#user-tabs:key"> writes the URL on click: #user-tabs:settings. Bookmark, share, hit the back button — all the URL-as-state benefits. (Plain <button> triggers never touch the URL — they use localStorage persist instead.)

Information

Hash format: #user-tabs:info

Live demo — anchor triggers (shareable URLs)

Triggers are <a href="#nsKey:key"> instead of <button>. Right-click any tab → copy link → paste into a new browser → the right tab activates on first paint. Middle-click opens the link in a new browser tab. data-ln-tab is boolean here; the key derives from href.

Install

Right-click this tab and copy the link — you'll see #docs-tabs:install in the URL.

npm install ln-ashlar

The click handler calls event.preventDefault() so the browser's default navigation does not race with the JS state write. Ctrl/Meta-click and middle-click skip preventDefault and bubble to the browser as expected — the standard "open in new tab" affordance.

Live demo — two independent sections, same page

Each instance reads only its own namespace from the hash. Hash format #user-tabs:settings&project-tabs:members activates Settings in the first section AND Members in the second. Both sections have a tab named info — they live in separate namespaces, no collision.

Project Overview

This section is completely independent from the one above. Hash format: #project-tabs:overview

Both sections have a tab key info — no collision because they live in different namespaces.

Live demo — persistent tabs without hash sync

No id (so no hash sync), explicit data-ln-persist="settings-tabs" (so the storage key doesn't depend on a generated id). The active tab is remembered across page loads via localStorage under key ln:settings-tabs:data-ln-tabs-active — global by default, so the same key restores this tab set anywhere it appears. Add data-ln-persist-scope="page" to scope it to the current URL instead. Useful for "settings page" tabs where the user expects "the last tab I had open is the one I'll see when I come back."

General

Switch to another tab, reload the page — that tab is restored.

Events

EventCancelabledetailDispatched when
ln-tabs:changeno{ key, tab, panel }After the active panel is swapped, ARIA synced, focus moved, and localStorage written.

There is no :before-change — the activation pipeline has no cancelable side-effects worth gating. If you need a "block this navigation" hook (unsaved form changes), wire it on the click side: listen on [data-ln-tab] clicks, check whether the current panel is dirty, and call event.preventDefault() before the click bubbles.

Live event log

No events yet — click a tab in either of the live demos above.
// Per-section listener
document.getElementById('user-tabs').addEventListener('ln-tabs:change', e => {
    console.log('Active key:', e.detail.key);
    console.log('Tab button:', e.detail.tab);   // DOM element
    console.log('Panel:',      e.detail.panel); // DOM element
});

// Document-level — every tabs instance on the page
document.addEventListener('ln-tabs:change', e => {
    analytics.track('tab_change', {
        section: e.target.id,
        tab:     e.detail.key,
    });
});

Common mistakes

1. Tab buttons inside a form without type="button"

The HTML default for <button> is type="submit". A tab click inside a form will submit the form before the tab handler runs. Every tab button inside a form needs type="button".

2. Forgetting class="hidden" on inactive panels

Until the component initializes, all panels are visible. The user sees a flash of all-panels-stacked before they collapse to one. Ship inactive panels with class="hidden".

3. Mixing hash sync and data-ln-persist

The two persistence mechanisms are mutually exclusive. id (or data-ln-tabs-key) enables hash sync, which silently disables data-ln-persist. Pick one.

4. Programmatically adding tabs after init

The instance's mapTabs / mapPanels are populated at _init — adding new [data-ln-tab] / [data-ln-panel] children later does NOT register them. To add tabs dynamically: el.lnTabs.destroy() + modify DOM + window.lnTabs(parent), or replace the entire wrapper subtree (the document-level observer auto-inits a new wrapper).

5. Expecting keyboard arrow nav

ln-tabs does not implement Arrow Left/Right or Home/End. Tab key cycles through tab buttons via the browser default; Enter/Space activates. Arrow nav is a WAI-ARIA enhancement, not baseline. Wire it on the consumer side if your audit requires it.

6. Putting data-ln-tabs on <main>

HTML spec allows only one <main> per page. Use <section>, <aside>, <article>, or <div>.