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>
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)
})
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' })
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 })
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 }
]
})
Plain disabled attribute; arrows skip it.
Inbox. Press ArrowRight — Drafts is skipped.
Never shown.
Archive. Landed here directly.
<button disabled>Drafts</button>
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>
The complete public surface of tabs.js v1.0.0 — constructor,
options, methods, statics, events, declarative attributes, theming and
accessibility.
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.
| Name | Type | Default | Description |
|---|---|---|---|
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'.
| Method | Returns | Description |
|---|---|---|
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).
| Member | Description |
|---|---|
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). |
| Hook | Payload | When |
|---|---|---|
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)
})
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:
| Attribute | On | Meaning |
|---|---|---|
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. |
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):
| Property | Light default | Dark default | Purpose |
|---|---|---|---|
--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
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.
| Key | Action |
|---|---|
| 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.
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.
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
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; }