Skip to content
data-slotv1.0.0
Esc
↑↓navigate↵open⌘Jpreview
On this page

Tabs

Layered panels of content, with one panel displayed at a time.

@data-slot/tabsSource ↗
bun add @data-slot/tabs
npm install @data-slot/tabs
pnpm add @data-slot/tabs

Anatomy

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

Preview
<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 codeHide 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>

Animated indicator

The optional tabs-indicator receives --active-tab-left and --active-tab-width, so it can slide under the active trigger.

Preview
The indicator slides smoothly to the active tab using CSS transitions. Position is set via CSS variables: --active-tab-left and --active-tab-width.
Zero JavaScript for animation — just CSS transition on transform and width. Works with keyboard navigation too.
Add data-slot="tabs-indicator" inside your tabs-list. The component sets CSS variables automatically.
Show codeHide 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>

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 by data-value; receives selected state and keyboard navigation. At least one trigger is required.
  • tabs-content - Panel matched to its trigger by data-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 list
  • role="tab" on triggers
  • role="tabpanel" on content
  • aria-orientation on list
  • aria-selected on triggers
  • aria-controls linking triggers to panels
  • aria-labelledby linking panels to triggers
  • tabindex management (only the selected enabled tab is in the tab order)