Tabs
Layered panels of content, with one panel displayed at a time.
@data-slot/tabsSource ↗bun add @data-slot/tabsnpm install @data-slot/tabspnpm add @data-slot/tabsAnatomy
Triggers and panels are paired by matching data-value. The indicator is optional.
<div data-slot="tabs" data-default-value="initial-tab">
<div data-slot="tabs-list">
<button data-slot="tabs-trigger" data-value="unique-id">Label</button>
<!-- Optional animated indicator -->
<div data-slot="tabs-indicator"></div>
</div>
<div data-slot="tabs-content" data-value="unique-id">Panel content</div>
</div>
Examples
Switch between panels
<div data-slot="tabs">
<div data-slot="tabs-list">
<button data-slot="tabs-trigger" data-value="one">Tab</button>
</div>
<div data-slot="tabs-content" data-value="one">...</div>
</div>/* Use data-state for styling */
[data-slot="tabs-trigger"][data-state="active"] {
font-weight: bold;
border-bottom: 2px solid;
}import { create } from "@data-slot/tabs";
const [tabs] = create();
tabs.select("css");
console.log(tabs.value); // "css"Show code
<div data-slot="tabs" data-default-value="one">
<div data-slot="tabs-list" class="tabs-list">
<button data-slot="tabs-trigger" data-value="one" class="tabs-trigger">Tab One</button>
<button data-slot="tabs-trigger" data-value="two" class="tabs-trigger">Tab Two</button>
</div>
<div data-slot="tabs-content" data-value="one" class="tabs-content">Content One</div>
<div data-slot="tabs-content" data-value="two" class="tabs-content">Content Two</div>
</div>
<style>
.tabs-list {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.tabs-trigger {
padding: 0.5rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
color: var(--muted);
margin-bottom: 0;
}
.tabs-trigger[data-state="active"] {
color: var(--text);
border-bottom-color: var(--text);
font-weight: 500;
}
.tabs-content { padding: 1rem 0; }
.tabs-content[hidden] { display: none; }
</style><div data-slot="tabs" data-default-value="one">
<div data-slot="tabs-list" class="flex border-b border-[var(--border)] mb-4">
<button
data-slot="tabs-trigger"
data-value="one"
class="px-4 py-2 border-b-2 border-transparent text-[var(--muted)] cursor-pointer
data-[state=active]:text-text data-[state=active]:border-text
data-[state=active]:font-medium"
>
Tab One
</button>
<button
data-slot="tabs-trigger"
data-value="two"
class="px-4 py-2 border-b-2 border-transparent text-[var(--muted)] cursor-pointer
data-[state=active]:text-text data-[state=active]:border-text
data-[state=active]:font-medium"
>
Tab Two
</button>
</div>
<div data-slot="tabs-content" data-value="one" class="py-4 hidden data-[state=active]:block">
Content One
</div>
<div data-slot="tabs-content" data-value="two" class="py-4 hidden data-[state=active]:block">
Content Two
</div>
</div>import { create } from "@data-slot/tabs";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());Animated indicator
The optional tabs-indicator receives --active-tab-left and --active-tab-width, so it can slide under the active trigger.
--active-tab-left and --active-tab-width.transition on transform and width. Works with keyboard navigation too.data-slot="tabs-indicator" inside your tabs-list. The component sets CSS variables automatically.Show code
<div data-slot="tabs" data-default-value="overview">
<div data-slot="tabs-list" class="tabs-list-indicator">
<div data-slot="tabs-indicator" class="tabs-indicator"></div>
<button data-slot="tabs-trigger" data-value="overview" class="tabs-trigger">Overview</button>
<button data-slot="tabs-trigger" data-value="features" class="tabs-trigger">Features</button>
<button data-slot="tabs-trigger" data-value="api" class="tabs-trigger">API</button>
</div>
<div data-slot="tabs-content" data-value="overview">Content</div>
</div>
<style>
.tabs-list-indicator {
position: relative;
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--code-bg);
overflow: auto;
}
.tabs-indicator {
position: absolute;
top: 0.25rem;
height: calc(100% - 0.5rem);
background: var(--surface-raised);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
transform: translateX(var(--active-tab-left, 0));
width: var(--active-tab-width, 0);
transition: transform 0.2s, width 0.2s;
}
.tabs-trigger {
position: relative;
z-index: 1;
padding: 0.375rem 0.75rem;
background: none;
border: none;
cursor: pointer;
margin-bottom: 0;
}
</style><div data-slot="tabs" data-default-value="overview">
<div
data-slot="tabs-list"
class="relative flex gap-1 p-1 bg-code-bg mb-4"
>
<div
data-slot="tabs-indicator"
class="absolute top-1 bottom-1 bg-[var(--surface-raised)] shadow
transition-all duration-200
translate-x-(--active-tab-left)
w-(--active-tab-width)"
></div>
<button
data-slot="tabs-trigger"
data-value="overview"
class="relative z-10 px-3 py-1.5 bg-transparent border-none cursor-pointer"
>
Overview
</button>
<button
data-slot="tabs-trigger"
data-value="features"
class="relative z-10 px-3 py-1.5 bg-transparent border-none cursor-pointer"
>
Features
</button>
<button
data-slot="tabs-trigger"
data-value="api"
class="relative z-10 px-3 py-1.5 bg-transparent border-none cursor-pointer"
>
API
</button>
</div>
<div data-slot="tabs-content" data-value="overview">Content</div>
</div>import { create } from "@data-slot/tabs";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());API reference
Initialization
create(scope?)
Auto-discover and bind all tabs instances in a scope (defaults to document).
import { create } from "@data-slot/tabs";
const controllers = create(); // Returns TabsController[]
createTabs(root, options?)
Create a controller for a specific element.
import { createTabs } from "@data-slot/tabs";
const tabs = createTabs(element, {
defaultValue: "one",
orientation: "horizontal",
onValueChange: (value) => console.log(value),
});
Slots
Runtime Slots
tabs- Root element that manages the selected tab and activation mode.tabs-list- Required tab-list container that receives tablist semantics and orientation.tabs-trigger- Tab button identified bydata-value; receives selected state and keyboard navigation. At least one trigger is required.tabs-content- Panel matched to its trigger bydata-value; receives tabpanel semantics and is shown when selected.tabs-indicator- Optional animated highlight whose position and size follow the selected trigger.
Markup
<div data-slot="tabs" data-default-value="initial-tab">
<div data-slot="tabs-list">
<button data-slot="tabs-trigger" data-value="unique-id">Label</button>
<!-- Optional animated indicator -->
<div data-slot="tabs-indicator"></div>
</div>
<div data-slot="tabs-content" data-value="unique-id">Panel content</div>
</div>
Data Attributes
Options can also be set via data attributes on the root element. JS options take precedence.
| Attribute | Type | Default | Description |
|---|---|---|---|
data-default-value |
string | first enabled tab | Initial selected tab |
data-orientation |
string | "horizontal" |
Tab orientation: horizontal, vertical |
data-activation-mode |
string | "auto" |
Activation mode: auto, manual |
<!-- Vertical tabs with manual activation -->
<div data-slot="tabs" data-orientation="vertical" data-activation-mode="manual">
...
</div>
Options
| Option | Type | Default | Description |
|---|---|---|---|
defaultValue |
string |
First enabled trigger’s value | Initial selected tab |
orientation |
"horizontal" | "vertical" |
"horizontal" |
Tab orientation for keyboard nav |
activationMode |
"auto" | "manual" |
"auto" |
How tabs are activated with keyboard |
onValueChange |
(value: string) => void |
undefined |
Callback when selected tab changes |
Controller
| Method/Property | Description |
|---|---|
select(value) |
Select a tab by value |
value |
Currently selected value (readonly string) |
updateIndicator() |
Recalculate indicator position after layout changes |
destroy() |
Cleanup all event listeners |
Events
Outbound Events
Listen for changes via custom events:
element.addEventListener("tabs:change", (e) => {
console.log("Selected tab:", e.detail.value);
});
Inbound Events
Control the tabs via events:
| Event | Detail | Description |
|---|---|---|
tabs:set |
{ value: string } |
Select a tab programmatically |
// Select a tab
element.dispatchEvent(
new CustomEvent("tabs:set", { detail: { value: "two" } })
);
Deprecated Events
The following event is deprecated and will be removed in v1.0:
// Deprecated: tabs:select event
element.dispatchEvent(
new CustomEvent("tabs:select", { detail: { value: "two" } })
);
// Deprecated: string detail
element.dispatchEvent(
new CustomEvent("tabs:select", { detail: "two" })
);
Use tabs:set with { value: string } instead.
Styling
Basic Styling
/* Hidden panels */
[data-slot="tabs-content"][hidden] {
display: none;
}
/* Active trigger */
[data-slot="tabs-trigger"][aria-selected="true"] {
font-weight: bold;
border-bottom: 2px solid currentColor;
}
/* Or use data-state */
[data-slot="tabs-trigger"][data-state="active"] {
color: blue;
}
[data-slot="tabs-trigger"][data-state="inactive"] {
color: gray;
}
Panel Activation Direction
Panels receive data-activation-direction after tab changes (not on initial mount):
- Horizontal:
left,right - Vertical:
up,down
Use it for directional content animations:
[data-slot="tabs-content"][data-activation-direction="right"] {
animation: slide-in-from-right 200ms ease;
}
[data-slot="tabs-content"][data-activation-direction="left"] {
animation: slide-in-from-left 200ms ease;
}
Animated Indicator
The indicator receives CSS variables for positioning:
[data-slot="tabs-indicator"] {
position: absolute;
left: var(--active-tab-left);
width: var(--active-tab-width);
height: 2px;
background: currentColor;
transition: left 0.2s, width 0.2s;
}
/* Vertical orientation */
[data-slot="tabs-list"][aria-orientation="vertical"] [data-slot="tabs-indicator"] {
top: var(--active-tab-top);
height: var(--active-tab-height);
width: 2px;
}
CSS Variables
| Variable | Description |
|---|---|
--active-tab-left |
Left offset of active trigger |
--active-tab-width |
Width of active trigger |
--active-tab-top |
Top offset of active trigger |
--active-tab-height |
Height of active trigger |
Tailwind Example
<div data-slot="tabs">
<div data-slot="tabs-list" class="relative flex border-b">
<button
data-slot="tabs-trigger"
data-value="one"
class="px-4 py-2 aria-selected:text-blue-600"
>
Tab One
</button>
<div
data-slot="tabs-indicator"
class="absolute bottom-0 h-0.5 bg-blue-600 transition-all"
style="left: var(--active-tab-left); width: var(--active-tab-width)"
></div>
</div>
<div data-slot="tabs-content" data-value="one" class="p-4">
Content
</div>
</div>
Keyboard Navigation
The tables below describe activationMode: "auto" (the default). In "manual" mode, arrow keys, Home, and End only move focus; Enter or Space selects the focused tab. Disabled triggers are skipped.
Horizontal Orientation
| Key | Action |
|---|---|
ArrowLeft |
Select previous tab |
ArrowRight |
Select next tab |
Home |
Select first tab |
End |
Select last tab |
Vertical Orientation
| Key | Action |
|---|---|
ArrowUp |
Select previous tab |
ArrowDown |
Select next tab |
Home |
Select first tab |
End |
Select last tab |
Accessibility
The component automatically handles:
role="tablist"on listrole="tab"on triggersrole="tabpanel"on contentaria-orientationon listaria-selectedon triggersaria-controlslinking triggers to panelsaria-labelledbylinking panels to triggerstabindexmanagement (only the selected enabled tab is in the tab order)