Vanilla UI Kit Tabs v1.0.0 · toast → datepicker →

Tabs, one file.

Zero-dependency tabs that enhance the markup you already have — full ARIA pattern, arrow-key navigation, a sliding indicator, and automatic light/dark. Every card below is live.

<script src="https://cdn.jsdelivr.net/gh/vanilla-ui-kit/components/tabs/tabs.js"></script>

Basic

Canonical markup: strip first, panels after. Arrow keys move and select.

Overview. First child is the tab strip, following siblings are the panels. That's the whole contract.

Features. Roving tabindex, wrap-around arrows, Home/End, hidden panels.

Pricing. Free — it's one file, MIT licensed.

Active: 0

new Tabs('#basic', {
  onChange: (i) => console.log(i)
})

Manual activation

Arrows only move focus; Enter or Space selects.

Editor. Use activation: 'manual' when switching panels is expensive.

Preview. Focus me with ArrowRight, then press Enter.

History. Nothing loads until you commit.

new Tabs('#manual', { activation: 'manual' })

Vertical

Rail on the left, ArrowUp/ArrowDown, indicator on the rail edge.

Profile. The tablist gets aria-orientation="vertical".

Security. The ink bar slides along the rail instead of under the tabs.

Billing. Same markup, one option.

new Tabs('#vertical', { vertical: true })

Builder mode

No markup — labels and content are text-safe by default.

new Tabs('#built', {
  tabs: [
    { label: 'One', content: 'Plain text' },
    { label: 'Two', content: '<b>html</b>',
      html: true }
  ]
})

Disabled tab

Plain disabled attribute; arrows skip it.

Inbox. Press ArrowRight — Drafts is skipped.

Never shown.

Archive. Landed here directly.

<button disabled>Drafts</button>

Declarative

Zero JS: data-vtb containers init themselves.

HTML. No script written for this card.

Attributes. data-vtb-active chose this tab; options ride along as data-vertical, data-activation, …

<div data-vtb>…canonical markup…</div>

API reference

The complete public surface of tabs.js v1.0.0 — constructor, options, methods, statics, events, declarative attributes, theming and accessibility.

Constructor & usage

var tabs = new Tabs(target, options?)

target is a CSS selector string or an Element. The constructor throws if the target cannot be found, or if no tabs are detected in the container. Constructing on an element that already has an instance destroys the old instance first. Without a DOM (SSR), the constructor returns an inert no-op instance instead of throwing. The script registers a browser global Tabs, and also supports CommonJS and AMD.

Four ways to use it:

new Tabs('.my-tabs')                 // enhance canonical markup: first child = tab strip,
                                     // following siblings = panels, in order
new Tabs('#custom')                  // custom structure: pair explicitly with
                                     // data-vtb-tab / data-vtb-panel attributes
new Tabs('#host', { tabs: [ /* … */ ] })   // builder mode: renders strip + panels from data
<div data-vtb>…</div>                // zero JS: auto-initializes on DOMContentLoaded

In canonical markup the strip's <button>s, <a>s or [role=tab] elements become the tabs; element types don't matter otherwise and panel content is left untouched. With explicit pairing, a tab matches the panel carrying the same data-vtb-panel value (valueless attributes pair by document order) and the tablist becomes the nearest ancestor containing all the tabs. In builder mode, labels and string content render with textContent; innerHTML is used only behind the explicit html: true opt-in, and an Element passed as content is moved in as-is.

Options

NameTypeDefaultDescription
tabs array null Builder mode: [{label, content, html?, disabled?, active?}]. Tabs renders the strip and panels from this data instead of parsing existing markup. label and string content are rendered as text; html: true opts a panel into innerHTML; an Element as content is moved in as-is; disabled disables the tab; active marks it initially active. A missing label falls back to Tab N.
active number null Initial tab index. When null, the tab carrying data-vtb-active wins; otherwise index 0. Out-of-range or invalid values fall back to 0, and if the resulting tab is disabled the next enabled tab is chosen.
vertical boolean false Vertical rail on the left; arrow-key navigation becomes ArrowUp/ArrowDown and the tablist gets aria-orientation="vertical".
activation string 'auto' 'auto': moving focus with the arrow keys also selects the tab. 'manual': arrows only move focus and Enter/Space selects.
theme string 'auto' 'auto' | 'light' | 'dark'. Auto follows the page theme (see Theming) and re-resolves live; 'light'/'dark' pin the instance.
styles boolean true false = headless: no CSS is injected; you style the .vtb-* markup contract yourself (see Theming).
onChange function null fn(index, tabs) — called after a tab is activated and the active index actually changed (see Events).

Change a default once for the whole page: Tabs.defaults.activation = 'manual'.

Methods

MethodReturnsDescription
select(index) Tabs Activates the tab at index: updates aria-selected, the roving tabindex, panel visibility and the sliding indicator, then fires the change callback/event if the index changed. No-op for disabled or out-of-range indexes. Returns the instance for chaining.
getActive() number The current active tab index (same value as the active property).
destroy() Tabs Unbinds all listeners, removes the indicator element, removes builder-mode DOM it created, restores every attribute it changed to its original value, and removes its classes. Returns the instance.

Readable instance properties: el (the container), list (the tablist element), tabs and panels (arrays of elements, index-aligned), and active (current index).

Statics & helpers

MemberDescription
Tabs.create(target, options) Factory — identical to new Tabs(target, options).
Tabs.get(target) The instance attached to an element (selector or Element), or null.
Tabs.autoInit(root?) Initializes every [data-vtb] container under root (default: document) that doesn't already have an instance; returns the array of created instances. One failing container logs an error and doesn't abort the rest. Runs automatically once on DOMContentLoaded (or immediately if the document has already loaded).
Tabs.defaults The live defaults object — mutate it to change a default for all subsequent instances (e.g. Tabs.defaults.styles = false).
Tabs.version Version string, '1.0.0'.
Tabs.salt CSS salt token used to namespace structural rules (default 'vc1'). Set your own token or false (no salting) before the first instance is created.
Tabs.css The full embedded stylesheet as a string, always rendered with the current salt — a starting point for headless styling (also shipped as dist/tabs.css).
Tabs.displayName 'Tabs' — family convergence contract.
Tabs.rootClass 'vtb' — the class applied to every container.
Tabs.themeVars Map of shared theme tokens to CSS custom property names: { accent: '--vtb-accent', radius: '--vtb-radius', font: '--vtb-font' }. Used by VC.config() when the VC core is loaded.
Tabs.varScopes ['.vtb', '.vtb[data-theme=dark]'] — the selectors where the theme variables are defined (deliberately unsalted so page overrides keep working).

Events & callbacks

HookPayloadWhen
onChange (option) (index, tabs) — the new active index and the Tabs instance After a tab is activated and the active index actually changed — by click, keyboard, or select(). Not called for the initial selection.
tabs:change (DOM event) CustomEvent with detail: { index, tabs } Dispatched from the container, bubbling, on every change — same conditions as onChange (which runs first).
document.addEventListener('tabs:change', function (e) {
  console.log(e.detail.index, e.detail.tabs)
})

Declarative init (data-* attributes)

Add data-vtb to a container and it initializes itself with no JavaScript, on DOMContentLoaded. Options ride along as data attributes on the same container:

AttributeOnMeaning
data-vtb container Auto-initialize this container (via Tabs.autoInit).
data-active container Initial tab index (number); ignored when empty.
data-vertical container Boolean — "false" and "0" are false, anything else (including a valueless attribute) is true.
data-activation container auto or manual.
data-theme container auto, light or dark.
data-styles container Boolean (same parsing as data-vertical) — data-styles="false" goes headless.
data-vtb-tab tab element Explicit pairing: marks this element as a tab. With a value, it pairs with the panel whose data-vtb-panel matches; valueless attributes pair by document order. A tab with no matching panel is skipped.
data-vtb-panel panel element Explicit pairing: marks this element as a panel (any order in the container).
data-vtb-active tab element Marks the initially active tab; an explicit active option takes precedence.
disabled tab element Plain HTML attribute — the tab can't be selected and is skipped by arrow-key navigation.

Theming

Automatic light/dark with the family's resolution order: <html data-theme> / data-bs-theme → .dark/.light class → prefers-color-scheme, re-resolved live as any of them change. Pin one instance with { theme: 'dark' }. All colors are CSS custom properties, defined on .vtb (light) and .vtb[data-theme=dark] (dark):

PropertyLight defaultDark defaultPurpose
--vtb-accent #5b5bd6 #7b7bea Active tab color, sliding indicator, focus rings.
--vtb-text #1c1d21 #e9eaf0 Base text color; tab hover color.
--vtb-muted #72747e #989aa6 Inactive and disabled tab labels.
--vtb-faint #e7e7ec #31343f Rail border under (or beside) the tab strip.
--vtb-radius 8px same Tab corner radius.
--vtb-font system-ui stack same Font family for the widget.
.vtb {
  --vtb-accent: #b45309;   /* active tab + sliding indicator */
}

With the VC core loaded, VC.config({ accent: '#b45309' }) themes tabs and every other family component in one call.

CSS isolation. Containers render as class="vtb vc1" and all structural rules ship salted (.vtb.vc1 .vtb-tab { … }), so host-page design systems can't override the widget — while --vtb-* variable overrides keep working (variable definitions are deliberately unsalted). Custom token: Tabs.salt = 'acme' before the first instance; disable with Tabs.salt = false.

Headless. Tabs.defaults.styles = false (or { styles: false } per instance) never injects CSS. You keep the full behavior — ARIA wiring, roving tabindex, keyboard navigation, hidden panels — and the markup contract below, styled entirely from your own CSS. The stock stylesheet is available as a starting point via Tabs.css (string) or dist/tabs.css (file).

.vtb.vtb-vertical[data-theme="dark"]     ← your container
  .vtb-list[role="tablist"]
    .vtb-tab[role="tab"][aria-selected]  ← your buttons/links
    .vtb-ink                             ← the sliding indicator (JS sets
                                            transform + width/height inline)
  .vtb-panel[role="tabpanel"][hidden]    ← your panels

Accessibility

Implements the full WAI-ARIA tabs pattern: role="tablist" (plus aria-orientation="vertical" when vertical), role="tab" with aria-selected and aria-controls, and role="tabpanel" with aria-labelledby and tabindex="0" so panel content is reachable. Inactive panels get the hidden attribute; enhanced <button>s get type="button" so they never submit a surrounding form; ids are generated only where missing.

KeyAction
Tab Into the strip — only the active tab is tabbable (roving tabindex) — then onward into the active panel.
ArrowRight / ArrowLeft Move through tabs, wrapping, skipping disabled ones. With activation: 'auto' focus also selects; with 'manual' it only moves focus. (ArrowDown / ArrowUp when vertical.)
Enter / Space Select the focused tab ('manual' mode).
Home First enabled tab.
End Last enabled tab.

Key presses with Alt, Ctrl or Meta held are left alone. Focus rings use :focus-visible; the indicator animation is disabled under prefers-reduced-motion: reduce.


Headless mode

styles: false injects nothing — behavior, ARIA, keyboard navigation and the stable .vtb-* class hooks all remain; you bring your own CSS. The demos above have already injected the kit stylesheet into this page, so a headless instance here would still match those rules — each demo below therefore runs inside its own <iframe>, a genuinely clean document.

Unstyled (styles: false)

Raw browser rendering, zero CSS — yet arrow keys, roving tabindex, Home/End and hidden panels all work.

new Tabs('#tabs', { styles: false })
// no CSS injected — semantic markup,
// ARIA + keyboard + .vtb-* hooks intact

Bring your own design (Tailwind)

The same hooks mapped to a pill design with Tailwind @apply. Loads cdn.tailwindcss.com only when you ask.

.vtb-list { @apply inline-flex gap-1 rounded-full
  border border-emerald-900/10 bg-emerald-900/5 p-1; }
.vtb-tab { @apply cursor-pointer rounded-full border-0
  bg-transparent px-4 py-1.5 text-sm font-medium
  text-emerald-950/60 transition hover:text-emerald-950; }
.vtb-tab[aria-selected="true"] { @apply bg-white
  text-emerald-800 shadow; }
.vtb-tab:disabled { @apply cursor-not-allowed opacity-40; }
.vtb-tab:focus-visible { @apply outline outline-2
  outline-offset-2 outline-emerald-600; }
.vtb-ink { @apply hidden; }  /* pills, no sliding ink bar */
.vtb-panel { @apply mt-4 rounded-2xl border
  border-emerald-900/10 bg-white p-5 text-sm
  leading-6 shadow-sm; }