- Full keyboard navigation with arrow keys, Home, and End
- ARIA-compliant with proper roles, states, and focus management
- URL syncing for deep-linking to specific tabs
- Autoplay with progress indicator and pause-on-hover/focus
- Supports multiple triggers per panel (e.g., sidebar + content triggers)
- Works with nested tabs without conflicts
- Optional prev/next navigation buttons
- CSS custom properties for powerful styling hooks
- Native DOM events and instance API
Setup
Variants
Horizontal Tabs
Vertical Tabs
Autoplay Tabs
How It Works
This library takes a different approach from traditional tab implementations. Instead of relying on array indices or DOM order, tabs are linked by matching data-tabs-trigger-value and data-tabs-panel-value attributes.
This value-based linking gives you superpowers:
- Triggers and panels can live anywhere in the DOM (not just as siblings)
- Multiple triggers can control the same panel
- Order doesn't matter – matching is by value, not position
- Deep nesting works without special configuration
In the background, the library handles all the accessibility requirements (ARIA roles, states, keyboard navigation), sets up IntersectionObserver for intelligent autoplay pausing, and exposes CSS custom properties for animations that stay perfectly in sync.
Structure
Three elements are required:
[data-tabs="container"]– the outermost container, used for scoping[data-tabs-trigger-value="..."]– the tab trigger buttons[data-tabs-panel-value="..."]– the content panels
<div data-tabs="container">
<div role="tablist">
<button data-tabs-trigger-value="overview">Overview</button>
<button data-tabs-trigger-value=
Trigger and panel values are normalized to lowercase and hyphenated. So "Tab One", "tab-one", and "TAB ONE" all become "tab-one" internally.
Webflow CMS
The value-based approach works great with Webflow's Collection List structure:
[data-tabs="container"]– goes on an ancestor of theCollection List Wrapper, or on the wrapper itself if you don't need navigation buttons inside[data-tabs-trigger-value]– goes on trigger elements (pull the value from a CMS field)[data-tabs-panel-value]– goes on theCollection List Item(use the same CMS field)
Since triggers and panels are matched by value rather than position, you can have your tab triggers in one Collection List and your panels in another – as long as the values match, they'll connect.
Customization
Container Configuration
| Attribute | Values | Default | Description |
|---|---|---|---|
data-tabs | "container" | - | Required on container element |
data-tabs-default | any value | first trigger | Initial active tab value |
data-tabs-group-name | string | - | URL parameter name for deep linking |
data-tabs-orientation | "horizontal" / |
Autoplay Configuration
| Attribute | Values | Default | Description |
|---|---|---|---|
data-tabs-autoplay | "true" / "false" | "false" | Enable autoplay |
data-tabs-autoplay-duration | milliseconds | 5000 | Time per tab |
|
Content Linking
| Attribute | Description |
|---|---|
data-tabs-trigger-value | Value that links trigger to its panel |
data-tabs-panel-value | Value that links panel to its trigger(s) |
Navigation Elements
Optional controls that work automatically when placed inside the container:
| Attribute | Description |
|---|---|
data-tabs="prev" | Previous tab button |
data-tabs="next" | Next tab button |
data-tabs="play-pause" | Toggle autoplay button |
<div data-tabs="container" data-tabs-autoplay="true">
<!-- Triggers and panels here -->
<div>
<button data-tabs="prev">Previous</button>
<button data-tabs="next"
Keyboard Navigation
When enabled (default), arrow keys navigate between tabs based on data-tabs-orientation:
- Horizontal:
Arrow Left/Arrow Right - Vertical:
Arrow Up/Arrow Down
Additional keys:
Home– jump to first tabEnd– jump to last tabEnter/Space– activate focused tab (whenactivate-on-focusis false)
State Classes
The library applies state classes that you can style however you want.
| Class | Applied to |
|---|---|
.tabs-active | Active trigger and panel |
.tabs-inactive | Inactive triggers and panels |
.tabs-transitioning | Container during tab transitions |
.tabs-panel-entering | Panel that is becoming active |
.tabs-panel-leaving | Panel that is becoming inactive |
.tabs-button-disabled | Prev/next buttons at boundaries |
Here's an example styling the active trigger:
/* Base trigger styles */
.tabs_trigger {
background: transparent;
border-bottom: 2px solid transparent;
transition: border-color 200ms ease;
}
/* Active trigger */
CSS Custom Properties
| Property | Applied to | Description |
|---|---|---|
--tabs-progress | Active trigger | Autoplay progress (0-1) |
--tabs-count | Container | Total number of tabs |
--tabs-index | Triggers & panels | Zero-based index of each element |
--tabs-active-index | Container | Index of currently active tab |
--tabs-direction | Container | Navigation direction: 1 (forward), (backward), (initial) |
These enable powerful CSS-only effects:
/* Sliding indicator that follows the active tab */
.tabs_indicator {
transform: translateX(calc(var(--tabs-active-index) * 100%));
JavaScript API
Manual Initialization
// Auto-initializes on page load by default
// Or manually initialize:
const tabs = window.Tabs.init('[data-tabs="container"]');Instance Methods
tabs.goTo("pricing"); // Go to specific tab by value
tabs.next(); // Go to next tab
tabs.prev(); // Go to previous tab
tabs.
All methods are chainable except getActiveValue (returns data) and destroy (resets DOM to pre-init state):
tabs.goTo("overview").play();Events
Listen for events using native DOM addEventListener:
container.addEventListener("tabs:change", (e) => {
console.log(e.detail); // { tabs, value, previousValue }
});
container.
Available events:
| Event | Description | Event Data |
|---|---|---|
tabs:change | Active tab changed | { value, previousValue } |
tabs:autoplay-start | Autoplay started/resumed | { value } |
tabs:autoplay-pause | Autoplay paused | { value, progress } |
Global API
// Get instance by selector or element
const tabs = window.Tabs.get(".my-tabs");
// Get all instances
const allTabs = window.Tabs.getAll();
Dynamic Content
When adding/removing tabs, call refresh():
// After modifying the DOM
tabs.refresh();The refresh method attempts to maintain the current active tab if it still exists.
URL Deep Linking
Enable URL syncing with the data-tabs-group-name attribute:
<div data-tabs="container" data-tabs-group-name="section">
<!-- tabs... -->
</div>Now ?section=pricing in the URL will activate the "pricing" tab on page load, and clicking tabs will update the URL without a page reload.
Priority order for initial tab: URL parameter > data-tabs-default > first trigger