Tabs Rails Components

Navigate between multiple panels of content with keyboard-accessible tabs. Perfect for organizing information and creating intuitive user interfaces.

Installation

1. Stimulus Controller Setup

Start by adding the following controller to your project:

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static classes = ["activeTab", "inactiveTab"];
  static targets = ["tab", "panel", "select", "tabList", "progressBar", "template"];
  static values = {
    index: 0, // Index of the tab to show on load
    updateAnchor: Boolean, // Whether to update the anchor
    scrollToAnchor: Boolean, // Whether to scroll to the anchor
    scrollActiveTabIntoView: Boolean, // Whether to scroll the active tab into view
    autoSwitch: Boolean, // Whether to auto-switch tabs
    autoSwitchInterval: { type: Number, default: 5000 }, // Interval in ms for auto-switching
    pauseOnHover: { type: Boolean, default: true }, // Whether to pause auto-switching when hovering over a tab
    showProgressBar: Boolean, // Whether to show the progress bar
    lazyLoad: { type: Boolean, default: false }, // Whether to lazy load the tab content
    turboFrameSrc: { type: String, default: "" }, // URL for the turbo frame
  };

  initialize() {
    if (this.updateAnchorValue && this.anchor) {
      this.indexValue = this.tabTargets.findIndex((tab) => tab.id === this.anchor);
    }

    // Initialize auto-switch properties
    this.autoSwitchTimer = null;
    this.startTime = null;
    this.isPaused = false;
    this.remainingTime = 0;
    this.currentProgress = 0;

    // Initialize lazy loading tracking
    this.loadedPanels = new Set();
  }

  async connect() {
    await this.showTab();
    this.setInitialFocus();
    this.revealTabs();
    this.addKeyboardEventListeners();

    // Start auto-switching if enabled
    if (this.autoSwitchValue) {
      this.startAutoSwitch();
      this.addHoverEventListeners();
    }
  }

  disconnect() {
    this.removeKeyboardEventListeners();
    this.removeHoverEventListeners();
    this.stopAutoSwitch();
  }

  addKeyboardEventListeners() {
    this.keydownHandler = this.handleKeydown.bind(this);
    this.tabTargets.forEach((tab) => {
      tab.addEventListener("keydown", this.keydownHandler);
    });
  }

  removeKeyboardEventListeners() {
    if (this.keydownHandler) {
      this.tabTargets.forEach((tab) => {
        tab.removeEventListener("keydown", this.keydownHandler);
      });
    }
  }

  addHoverEventListeners() {
    if (this.pauseOnHoverValue) {
      this.mouseEnterHandler = this.pauseAutoSwitch.bind(this);
      this.mouseLeaveHandler = this.resumeAutoSwitch.bind(this);
      this.focusHandler = this.pauseAutoSwitch.bind(this);
      this.blurHandler = this.resumeAutoSwitch.bind(this);

      this.element.addEventListener("mouseenter", this.mouseEnterHandler);
      this.element.addEventListener("mouseleave", this.mouseLeaveHandler);
      this.element.addEventListener("focusin", this.focusHandler);
      this.element.addEventListener("focusout", this.blurHandler);
    }
  }

  removeHoverEventListeners() {
    if (this.mouseEnterHandler) {
      this.element.removeEventListener("mouseenter", this.mouseEnterHandler);
      this.element.removeEventListener("mouseleave", this.mouseLeaveHandler);
      this.element.removeEventListener("focusin", this.focusHandler);
      this.element.removeEventListener("focusout", this.blurHandler);
    }
  }

  handleKeydown(event) {
    // Only handle keyboard events when focus is on a tab
    if (!this.tabTargets.includes(event.target) && !event.target.closest('[data-tabs-target="tab"]')) {
      return;
    }

    switch (event.key) {
      case "ArrowLeft":
      case "ArrowUp":
        event.preventDefault();
        this.previousTab();
        break;
      case "ArrowRight":
      case "ArrowDown":
        event.preventDefault();
        this.nextTab();
        break;
      case "Home":
        event.preventDefault();
        this.firstTab();
        break;
      case "End":
        event.preventDefault();
        this.lastTab();
        break;
    }
  }

  // Changes to the clicked tab
  change(event) {
    // Restart auto-switch timer when user manually changes tabs
    if (this.autoSwitchValue) {
      this.restartAutoSwitch();
    }

    if (event.currentTarget.tagName === "SELECT") {
      this.indexValue = event.currentTarget.selectedIndex;

      // If target specifies an index, use that
    } else if (event.currentTarget.dataset.index) {
      this.indexValue = parseInt(event.currentTarget.dataset.index);

      // If target specifies an id, use that
    } else if (event.currentTarget.dataset.id) {
      this.indexValue = this.tabTargets.findIndex((tab) => tab.id === event.currentTarget.dataset.id);

      // Otherwise, use the index of the current target
    } else {
      this.indexValue = this.tabTargets.indexOf(event.currentTarget);
    }

    // Set focus to the newly active tab
    this.setFocusToActiveTab();
  }

  nextTab(shouldFocus = true) {
    if (this.indexValue === this.tabsCount - 1) {
      this.indexValue = 0; // Loop back to first tab for auto-switching
    } else {
      this.indexValue = this.indexValue + 1;
    }
    if (shouldFocus) {
      this.setFocusToActiveTab();
      // Restart auto-switch timer when user manually changes tabs via keyboard
      if (this.autoSwitchValue) {
        this.restartAutoSwitch();
      }
    }
  }

  previousTab() {
    if (this.indexValue === 0) {
      this.indexValue = this.tabsCount - 1; // Loop to last tab when on first tab
    } else {
      this.indexValue = this.indexValue - 1;
    }
    this.setFocusToActiveTab();
    // Restart auto-switch timer when user manually changes tabs via keyboard
    if (this.autoSwitchValue) {
      this.restartAutoSwitch();
    }
  }

  firstTab() {
    this.indexValue = 0;
    this.setFocusToActiveTab();
    // Restart auto-switch timer when user manually changes tabs via keyboard
    if (this.autoSwitchValue) {
      this.restartAutoSwitch();
    }
  }

  lastTab() {
    this.indexValue = this.tabsCount - 1;
    this.setFocusToActiveTab();
    // Restart auto-switch timer when user manually changes tabs via keyboard
    if (this.autoSwitchValue) {
      this.restartAutoSwitch();
    }
  }

  // Auto-switch methods
  startAutoSwitch() {
    if (!this.autoSwitchValue || this.tabsCount <= 1) return;

    this.stopAutoSwitch(); // Clear any existing timers
    this.startTime = Date.now();
    this.remainingTime = this.autoSwitchIntervalValue;
    this.currentProgress = 0; // Always start from 0 for a new cycle
    this.isPaused = false; // Ensure we're not in paused state

    this.autoSwitchTimer = setTimeout(() => {
      this.nextTab(false); // Don't focus when auto-switching
      this.startAutoSwitch(); // Restart for next cycle
    }, this.autoSwitchIntervalValue);

    if (this.showProgressBarValue) {
      this.startProgressBar();
    }
  }

  stopAutoSwitch() {
    if (this.autoSwitchTimer) {
      clearTimeout(this.autoSwitchTimer);
      this.autoSwitchTimer = null;
    }
    this.stopProgressBar();
    this.resetProgressBar();
  }

  stopProgressBar() {
    if (this.hasProgressBarTarget) {
      // Stop the transition by removing it and keeping current width
      const currentWidth = this.progressBarTarget.getBoundingClientRect().width;
      const containerWidth = this.progressBarTarget.parentElement.getBoundingClientRect().width;
      const currentProgress = (currentWidth / containerWidth) * 100;

      this.progressBarTarget.style.transition = "none";
      this.progressBarTarget.style.width = `${currentProgress}%`;
      this.currentProgress = currentProgress;
    }
  }

  pauseAutoSwitch() {
    if (!this.autoSwitchValue || this.isPaused) return;

    this.isPaused = true;
    const elapsed = Date.now() - this.startTime;
    this.remainingTime = Math.max(0, this.autoSwitchIntervalValue - elapsed);

    // Stop progress bar and calculate remaining time
    if (this.showProgressBarValue) {
      this.stopProgressBar();
    }

    if (this.autoSwitchTimer) {
      clearTimeout(this.autoSwitchTimer);
      this.autoSwitchTimer = null;
    }
  }

  resumeAutoSwitch() {
    if (!this.autoSwitchValue || !this.isPaused) return;

    this.isPaused = false;
    this.startTime = Date.now() - (this.autoSwitchIntervalValue - this.remainingTime); // Adjust start time to account for elapsed time

    this.autoSwitchTimer = setTimeout(() => {
      this.nextTab(false); // Don't focus when auto-switching
      this.startAutoSwitch(); // Restart for next cycle
    }, this.remainingTime);

    if (this.showProgressBarValue) {
      this.resumeProgressBar();
    }
  }

  restartAutoSwitch() {
    if (!this.autoSwitchValue) return;

    const wasAlreadyPaused = this.isPaused;
    this.stopAutoSwitch();
    this.currentProgress = 0;

    if (wasAlreadyPaused) {
      // If we were paused (e.g., due to hover), stay paused after manual tab change
      this.isPaused = true;
      this.remainingTime = this.autoSwitchIntervalValue;
      // Don't start the timer, just reset the progress bar
      if (this.showProgressBarValue) {
        this.resetProgressBar();
      }
    } else {
      // If we weren't paused, restart normally
      this.isPaused = false;
      this.startAutoSwitch();
    }
  }

  // Progress bar methods
  startProgressBar() {
    if (!this.hasProgressBarTarget) return;

    // Always start from 0% for a new cycle
    this.progressBarTarget.style.transition = "none";
    this.progressBarTarget.style.width = "0%";

    // Force a reflow to ensure the position is set
    this.progressBarTarget.offsetHeight;

    // Set up the linear transition and animate to 100%
    this.progressBarTarget.style.transition = `width ${this.autoSwitchIntervalValue}ms linear`;
    this.progressBarTarget.style.width = "100%";
  }

  resumeProgressBar() {
    if (!this.hasProgressBarTarget) return;

    // Continue from current progress
    const startProgress = this.currentProgress || 0;

    // Set starting position without transition
    this.progressBarTarget.style.transition = "none";
    this.progressBarTarget.style.width = `${startProgress}%`;

    // Force a reflow to ensure the position is set
    this.progressBarTarget.offsetHeight;

    // Set up the linear transition for remaining time
    this.progressBarTarget.style.transition = `width ${this.remainingTime}ms linear`;
    this.progressBarTarget.style.width = "100%";
  }

  updateProgressBar(progress) {
    // This method is no longer needed with CSS transitions, but keeping for compatibility
    if (this.hasProgressBarTarget && typeof progress === "number") {
      this.progressBarTarget.style.transition = "none";
      this.progressBarTarget.style.width = `${progress}%`;
    }
  }

  resetProgressBar() {
    if (this.hasProgressBarTarget) {
      this.progressBarTarget.style.transition = "none";
      this.progressBarTarget.style.width = "0%";
      this.currentProgress = 0;
    }
  }

  async indexValueChanged() {
    await this.showTab();
    this.dispatch("tab-change", {
      target: this.tabTargets[this.indexValue],
      detail: {
        activeIndex: this.indexValue,
      },
    });

    // Update URL with the tab ID if it has one
    if (this.updateAnchorValue) {
      const newTabId = this.tabTargets[this.indexValue].id;
      if (newTabId) {
        if (this.scrollToAnchorValue) {
          location.hash = newTabId;
        } else {
          const currentUrl = window.location.href;
          const newUrl = currentUrl.split("#")[0] + "#" + newTabId;
          if (typeof Turbo !== "undefined") {
            Turbo.navigator.history.replace(new URL(newUrl));
          } else {
            history.replaceState({}, document.title, newUrl);
          }
        }
      }
    }
  }

  async showTab() {
    this.panelTargets.forEach((panel, index) => {
      const tab = this.tabTargets[index];

      if (index === this.indexValue) {
        // Show active panel
        panel.classList.remove("hidden");

        // Set active tab attributes and classes
        tab.setAttribute("aria-selected", "true");
        tab.setAttribute("tabindex", "0");
        tab.dataset.active = "true";

        if (this.hasInactiveTabClass) {
          tab.classList.remove(...this.inactiveTabClasses);
        }
        if (this.hasActiveTabClass) {
          tab.classList.add(...this.activeTabClasses);
        }
      } else {
        // Hide inactive panels
        panel.classList.add("hidden");

        // Set inactive tab attributes and classes
        tab.setAttribute("aria-selected", "false");
        tab.setAttribute("tabindex", "-1");
        delete tab.dataset.active;

        if (this.hasActiveTabClass) {
          tab.classList.remove(...this.activeTabClasses);
        }
        if (this.hasInactiveTabClass) {
          tab.classList.add(...this.inactiveTabClasses);
        }
      }
    });

    // Update select element if present
    if (this.hasSelectTarget) {
      this.selectTarget.selectedIndex = this.indexValue;
    }

    // Scroll active tab into view if needed
    if (this.scrollActiveTabIntoViewValue) {
      this.scrollToActiveTab();
    }

    // Load content if lazy loading is enabled (after making panel visible)
    if (this.lazyLoadValue && !this.loadedPanels.has(this.indexValue)) {
      // Small delay to ensure panel is visible before loading
      requestAnimationFrame(() => {
        this.#loadPanelContent(this.indexValue);
      });
    }
  }

  setInitialFocus() {
    // Set initial tabindex values
    this.tabTargets.forEach((tab, index) => {
      if (index === this.indexValue) {
        tab.setAttribute("tabindex", "0");
        tab.setAttribute("aria-selected", "true");
      } else {
        tab.setAttribute("tabindex", "-1");
        tab.setAttribute("aria-selected", "false");
      }
    });
  }

  setFocusToActiveTab() {
    const activeTab = this.tabTargets[this.indexValue];
    if (activeTab) {
      // Find the focusable element within the tab (likely the <a> tag)
      const focusableElement = activeTab.querySelector("a") || activeTab;
      focusableElement.focus();
    }
  }

  scrollToActiveTab() {
    const activeTab = this.tabTargets[this.indexValue];
    if (activeTab) {
      activeTab.scrollIntoView({ inline: "center", behavior: "smooth" });
    }
  }

  revealTabs() {
    // Show the tabs after styling has been applied
    if (this.hasTabListTarget) {
      this.tabListTarget.classList.remove("opacity-0");
    }
  }

  get tabsCount() {
    return this.tabTargets.length;
  }

  get anchor() {
    return document.URL.split("#").length > 1 ? document.URL.split("#")[1] : null;
  }

  #loadPanelContent(panelIndex) {
    const panel = this.panelTargets[panelIndex];
    if (!panel) return;

    // Mark as loaded to prevent multiple load attempts
    this.loadedPanels.add(panelIndex);

    // Check if we should use Turbo Frame lazy loading
    if (this.turboFrameSrcValue) {
      const turboFrame = panel.querySelector("turbo-frame");

      if (turboFrame) {
        // Only set src if it hasn't been set yet
        if (!turboFrame.src || turboFrame.src === "" || turboFrame.src === "about:blank") {
          // Set the src with the panel index to load specific content
          const baseUrl = this.turboFrameSrcValue;
          const separator = baseUrl.includes("?") ? "&" : "?";
          turboFrame.src = `${baseUrl}${separator}tab=${panelIndex}`;
          // Turbo will handle the loading asynchronously
        }
      }
    } else if (this.hasTemplateTarget) {
      // Use template-based lazy loading (this is synchronous and fast)
      const templates = this.templateTargets;
      const template = templates[panelIndex];

      if (template) {
        const templateContent = template.content.cloneNode(true);

        // Clear any loading indicators or placeholder content
        panel.innerHTML = "";

        // Append the template content
        panel.appendChild(templateContent);
      }
    }
  }
}

Examples

Basic tabs

A simple tabs component with text-only navigation.

Welcome Home

This is the home tab content. You can navigate between tabs using the keyboard arrow keys or by clicking on the tab headers.

<div
  class="w-full"
  data-controller="tabs"
  data-tabs-active-tab-class="bg-neutral-900 text-white shadow-sm dark:bg-white dark:text-neutral-900 dark:shadow-sm hover:ring-neutral-800 dark:hover:bg-neutral-50 dark:hover:ring-white"
  data-tabs-inactive-tab-class="hover:bg-white/75 ring-1 ring-transparent hover:ring-white dark:hover:bg-neutral-700 dark:hover:ring-neutral-600 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
  data-tabs-index-value="0">
  <ul class="opacity-0 transition-opacity duration-200 relative inline-grid gap-2 items-center justify-center w-full grid-cols-3 p-1 bg-neutral-100 border border-neutral-200 rounded-lg select-none dark:bg-neutral-800 dark:border-neutral-700" data-tabs-target="tabList">
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>Home</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>About</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>Contact</span>
      </a>
    </li>
  </ul>

  <div class="py-6 px-4" data-tabs-target="panel">
    <h3 class="text-lg font-semibold mb-2 flex items-center gap-2">Welcome Home</h3>
    <p class="text-neutral-600 dark:text-neutral-400">This is the home tab content. You can navigate between tabs using the keyboard arrow keys or by clicking on the tab headers.</p>
  </div>
  <div class="py-6 px-4 hidden" data-tabs-target="panel">
    <h3 class="text-lg font-semibold mb-2 flex items-center gap-2">About Us</h3>
    <p class="text-neutral-600 dark:text-neutral-400">Learn more about our company and mission. This tab demonstrates the seamless content switching functionality.</p>
  </div>
  <div class="py-6 px-4 hidden" data-tabs-target="panel">
    <h3 class="text-lg font-semibold mb-2 flex items-center gap-2">Get in Touch</h3>
    <p class="text-neutral-600 dark:text-neutral-400">Contact us for more information. Each tab panel can contain any type of content including forms, images, or interactive components.</p>
  </div>
</div>

Tabs with icons

Tabs enhanced with icons for better visual hierarchy and user experience.

Information

This tab contains important information about your account.

<div class="w-full" data-controller="tabs" data-tabs-active-tab-class="bg-neutral-900 text-white shadow-sm dark:bg-white dark:text-neutral-900 dark:shadow-sm hover:ring-neutral-800 dark:hover:bg-neutral-50 dark:hover:ring-white" data-tabs-inactive-tab-class="hover:bg-white/75 ring-1 ring-transparent hover:ring-white dark:hover:bg-neutral-700 dark:hover:ring-neutral-600 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200" data-tabs-index-value="0">
  <ul class="opacity-0 transition-opacity duration-200 relative inline-grid gap-2 items-center justify-center w-full grid-cols-1 sm:grid-cols-3 p-1 bg-neutral-100 border border-neutral-200 rounded-lg select-none dark:bg-neutral-800 dark:border-neutral-700" data-tabs-target="tabList">
    <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g stroke-width="1.5" fill="none" stroke="currentColor"><circle cx="9" cy="9" r="7.25" stroke-linecap="round" stroke-linejoin="round"></circle><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9 12.819L9 8.25"></path><path d="M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" stroke="none" fill="currentColor"></path></g></svg>
        <span>Info</span>
      </a>
    </li>
    <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g stroke-width="1.5" fill="none" stroke="currentColor"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M4.75 12L6.5 10 7.5 11.25 9.5 7.75 11 10.25 13.25 5.75"></path><rect x="1.75" y="2.75" width="14.5" height="12.5" rx="2" ry="2" transform="rotate(180 9 9)" stroke-linecap="round" stroke-linejoin="round"></rect><circle cx="4.25" cy="5.25" r=".75" fill="currentColor" stroke="none"></circle><circle cx="6.75" cy="5.25" r=".75" fill="currentColor" stroke="none"></circle></g></svg>
        <span>Activity</span>
      </a>
    </li>
    <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M9 11.2495C10.2426 11.2495 11.25 10.2422 11.25 8.99951C11.25 7.75687 10.2426 6.74951 9 6.74951C7.75736 6.74951 6.75 7.75687 6.75 8.99951C6.75 10.2422 7.75736 11.2495 9 11.2495Z"></path> <path d="M15.175 7.27802L14.246 6.95001C14.144 6.68901 14.027 6.42999 13.883 6.17999C13.739 5.92999 13.573 5.69999 13.398 5.48099L13.578 4.513C13.703 3.842 13.391 3.164 12.8 2.823L12.449 2.62C11.857 2.278 11.115 2.34699 10.596 2.79099L9.851 3.42801C9.291 3.34201 8.718 3.34201 8.148 3.42801L7.403 2.79001C6.884 2.34601 6.141 2.27699 5.55 2.61899L5.199 2.82199C4.607 3.16299 4.296 3.84099 4.421 4.51199L4.601 5.47699C4.241 5.92599 3.955 6.42299 3.749 6.95099L2.825 7.27701C2.181 7.50401 1.75 8.11299 1.75 8.79599V9.20099C1.75 9.88399 2.181 10.493 2.825 10.72L3.754 11.048C3.856 11.309 3.972 11.567 4.117 11.817C4.262 12.067 4.427 12.297 4.602 12.517L4.421 13.485C4.296 14.156 4.608 14.834 5.199 15.175L5.55 15.378C6.142 15.72 6.884 15.651 7.403 15.207L8.148 14.569C8.707 14.655 9.28 14.655 9.849 14.569L10.595 15.208C11.114 15.652 11.857 15.721 12.448 15.379L12.799 15.176C13.391 14.834 13.702 14.157 13.577 13.486L13.397 12.52C13.756 12.071 14.043 11.575 14.248 11.047L15.173 10.721C15.817 10.494 16.248 9.885 16.248 9.202V8.797C16.248 8.114 15.817 7.50502 15.173 7.27802H15.175Z"></path></g></svg>
        <span>Settings</span>
      </a>
    </li>
  </ul>

  <div class="py-4 px-4" data-tabs-target="panel">
    <div class="flex items-start space-x-3">
      <div>
        <h3 class="text-lg font-semibold mb-2 flex items-center gap-2">
          Information
        </h3>
        <p class="text-neutral-600 dark:text-neutral-400">This tab contains important information about your account.</p>
      </div>
    </div>
  </div>
  <div class="py-4 px-4 hidden" data-tabs-target="panel">
    <div class="flex items-start space-x-3">
      <div>
        <h3 class="text-lg font-semibold mb-2 flex items-center gap-2">Recent Activity</h3>
        <p class="text-neutral-600 dark:text-neutral-400">View your recent activities and track your progress over time.</p>
      </div>
    </div>
  </div>
  <div class="py-4 px-4 hidden" data-tabs-target="panel">
    <div class="flex items-start space-x-3">
      <div>
        <h3 class="text-lg font-semibold mb-2 flex items-center gap-2">Settings</h3>
        <p class="text-neutral-600 dark:text-neutral-400">Customize your experience with various settings and preferences.</p>
      </div>
    </div>
  </div>
</div>

Tabs with select dropdown

Tabs integrated with a select dropdown for additional navigation options and mobile-friendly interface.

Go to:

Dashboard Overview

Get a quick summary of your key metrics.

124
Total Users
89%
Satisfaction
$12.4k
Revenue
<div class="w-full" data-controller="tabs" data-tabs-active-tab-class="bg-neutral-900 text-white shadow-sm dark:bg-white dark:text-neutral-900 dark:shadow-sm hover:ring-neutral-800 dark:hover:bg-neutral-50 dark:hover:ring-white" data-tabs-inactive-tab-class="hover:bg-white/75 ring-1 ring-transparent hover:ring-white dark:hover:bg-neutral-700 dark:hover:ring-neutral-600 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200" data-tabs-index-value="0">
  <div class="flex flex-col sm:flex-row items-center justify-between mb-4 gap-4">
    <div class="hidden sm:block ">
      <ul class="opacity-0 transition-opacity duration-200 relative inline-grid gap-2 items-center justify-center grid-cols-4 p-1 bg-neutral-100 border border-neutral-200 rounded-lg select-none dark:bg-neutral-800 dark:border-neutral-700" data-tabs-target="tabList">
        <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
          <a class="flex items-center justify-center py-2.5 px-3 w-full text-center" href="#">
            <span>Overview</span>
          </a>
        </li>
        <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
          <a class="flex items-center justify-center py-2.5 px-3 w-full text-center" href="#">
            <span>Analytics</span>
          </a>
        </li>
        <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
          <a class="flex items-center justify-center py-2.5 px-3 w-full text-center" href="#">
            <span>Reports</span>
          </a>
        </li>
        <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
          <a class="flex items-center justify-center py-2.5 px-3 w-full text-center" href="#">
            <span>Settings</span>
          </a>
        </li>
      </ul>
    </div>

    <div class="flex items-center space-x-2">
      <span class="text-sm text-neutral-600 dark:text-neutral-400 whitespace-nowrap">Go to:</span>
      <select data-action="tabs#change" data-tabs-target="select" class="text-sm border border-neutral-200 dark:border-neutral-700 rounded-md px-3 py-1.5 bg-white dark:bg-neutral-800 focus:ring-2 focus:ring-emerald-500 focus:border-transparent">
        <option>Overview</option>
        <option>Analytics</option>
        <option>Reports</option>
        <option>Settings</option>
      </select>
    </div>
  </div>

  <div class="py-6 px-4 border border-neutral-200 dark:border-neutral-700 rounded-lg" data-tabs-target="panel">
    <div class="space-y-4">
      <div class="flex items-center space-x-3">
        <div class="shrink-0 size-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg flex items-center justify-center">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g stroke-width="1.5" fill="none" stroke="currentColor"><circle cx="9" cy="9" r="7.25" stroke-linecap="round" stroke-linejoin="round"></circle><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9 12.819L9 8.25"></path><path d="M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" stroke="none" fill="currentColor"></path></g></svg>
        </div>
        <div>
          <h3 class="text-base sm:text-lg font-semibold">Dashboard Overview</h3>
          <p class="text-sm sm:text-base text-neutral-600 dark:text-neutral-400">Get a quick summary of your key metrics.</p>
        </div>
      </div>
      <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
        <div class="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
          <div class="text-lg sm:text-xl font-bold text-neutral-900 dark:text-neutral-50">124</div>
          <div class="text-sm text-neutral-500 dark:text-neutral-400">Total Users</div>
        </div>
        <div class="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
          <div class="text-lg sm:text-xl font-bold text-neutral-900 dark:text-neutral-50">89%</div>
          <div class="text-sm text-neutral-500 dark:text-neutral-400">Satisfaction</div>
        </div>
        <div class="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
          <div class="text-lg sm:text-xl font-bold text-neutral-900 dark:text-neutral-50">$12.4k</div>
          <div class="text-sm text-neutral-500 dark:text-neutral-400">Revenue</div>
        </div>
      </div>
    </div>
  </div>
  <div class="py-6 px-4 border border-neutral-200 dark:border-neutral-700 rounded-lg hidden" data-tabs-target="panel">
    <div class="space-y-4">
      <div class="flex items-center space-x-3">
        <div class="shrink-0 size-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg flex items-center justify-center">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g stroke-width="1.5" fill="none" stroke="currentColor"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M4.75 12L6.5 10 7.5 11.25 9.5 7.75 11 10.25 13.25 5.75"></path><rect x="1.75" y="2.75" width="14.5" height="12.5" rx="2" ry="2" transform="rotate(180 9 9)" stroke-linecap="round" stroke-linejoin="round"></rect><circle cx="4.25" cy="5.25" r=".75" fill="currentColor" stroke="none"></circle><circle cx="6.75" cy="5.25" r=".75" fill="currentColor" stroke="none"></circle></g></svg>
        </div>
        <div>
          <h3 class="text-base sm:text-lg font-semibold">Analytics Dashboard</h3>
          <p class="text-sm sm:text-base text-neutral-600 dark:text-neutral-400">Dive deep into your data with detailed analytics and insights.</p>
        </div>
      </div>
      <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
        <div class="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
          <div class="text-lg sm:text-xl font-bold text-emerald-500 dark:text-emerald-400 flex items-center gap-1.5">
            <span>+38%</span>
            <svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M1.75,12.25l3.646-3.646c.195-.195,.512-.195,.707,0l3.293,3.293c.195,.195,.512,.195,.707,0l6.146-6.146"></path><polyline points="11.25 5.75 16.25 5.75 16.25 10.75"></polyline></g></svg>
          </div>
          <div class="text-sm text-neutral-500 dark:text-neutral-400">Total Users</div>
        </div>
        <div class="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
          <div class="text-lg sm:text-xl font-bold text-emerald-500 dark:text-emerald-400 flex items-center gap-1.5">
            <span>+12%</span>
            <svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M1.75,12.25l3.646-3.646c.195-.195,.512-.195,.707,0l3.293,3.293c.195,.195,.512,.195,.707,0l6.146-6.146"></path><polyline points="11.25 5.75 16.25 5.75 16.25 10.75"></polyline></g></svg>
          </div>
          <div class="text-sm text-neutral-500 dark:text-neutral-400">Satisfaction</div>
        </div>
        <div class="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
          <div class="text-lg sm:text-xl font-bold text-red-500 dark:text-red-400 flex items-center gap-1.5">
            <span>-12%</span>
            <svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M1.75,5.75l3.646,3.646c.195,.195,.512,.195,.707,0l3.293-3.293c.195-.195,.512-.195,.707,0l6.146,6.146"></path><polyline points="11.25 12.25 16.25 12.25 16.25 7.25"></polyline></g></svg>
          </div>
          <div class="text-sm text-neutral-500 dark:text-neutral-400">Revenue</div>
        </div>
      </div>
    </div>
  </div>
  <div class="py-6 px-4 border border-neutral-200 dark:border-neutral-700 rounded-lg hidden" data-tabs-target="panel">
    <div class="space-y-4">
      <div class="flex items-center space-x-3">
        <div class="shrink-0 size-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg flex items-center justify-center">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g stroke-width="1.5" fill="none" stroke="currentColor"><path d="M9,15.051c.17,0,.339-.045,.494-.134,.643-.371,1.732-.847,3.141-.845,.899,.001,1.667,.197,2.27,.435,.648,.255,1.344-.24,1.344-.937V4.487c0-.354-.181-.68-.486-.86-.637-.376-1.726-.863-3.14-.863-1.89,0-3.198,.872-3.624,1.182" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></path><path d="M9,15.051c-.17,0-.339-.045-.494-.134-.643-.371-1.732-.847-3.141-.845-.899,.001-1.667,.197-2.27,.435-.648,.255-1.344-.237-1.344-.933,0-2.593,0-7.472,0-9.09,0-.354,.181-.676,.486-.856,.637-.376,1.726-.863,3.14-.863,1.89,0,3.198,.872,3.624,1.182h0s0,11.104,0,11.104Z" stroke-linecap="round" stroke-linejoin="round"></path></g></svg>
        </div>
        <div>
          <h3 class="text-base sm:text-lg font-semibold">Reports Center</h3>
          <p class="text-sm sm:text-base text-neutral-600 dark:text-neutral-400">Generate and download comprehensive reports for your data.</p>
        </div>
      </div>
      <div class="space-y-3">
        <div class="flex items-center justify-between p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
          <span class="text-sm font-medium">Monthly Report</span>
          <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3 py-2 text-xs font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Download</button>
        </div>
        <div class="flex items-center justify-between p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
          <span class="text-sm font-medium">User Analytics</span>
          <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3 py-2 text-xs font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Download</button>
        </div>
      </div>
    </div>
  </div>
  <div class="py-6 px-4 border border-neutral-200 dark:border-neutral-700 rounded-lg hidden" data-tabs-target="panel">
    <div class="space-y-4">
      <div class="flex items-center space-x-3">
        <div class="shrink-0 size-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg flex items-center justify-center">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M9 11.2495C10.2426 11.2495 11.25 10.2422 11.25 8.99951C11.25 7.75687 10.2426 6.74951 9 6.74951C7.75736 6.74951 6.75 7.75687 6.75 8.99951C6.75 10.2422 7.75736 11.2495 9 11.2495Z"></path> <path d="M15.175 7.27802L14.246 6.95001C14.144 6.68901 14.027 6.42999 13.883 6.17999C13.739 5.92999 13.573 5.69999 13.398 5.48099L13.578 4.513C13.703 3.842 13.391 3.164 12.8 2.823L12.449 2.62C11.857 2.278 11.115 2.34699 10.596 2.79099L9.851 3.42801C9.291 3.34201 8.718 3.34201 8.148 3.42801L7.403 2.79001C6.884 2.34601 6.141 2.27699 5.55 2.61899L5.199 2.82199C4.607 3.16299 4.296 3.84099 4.421 4.51199L4.601 5.47699C4.241 5.92599 3.955 6.42299 3.749 6.95099L2.825 7.27701C2.181 7.50401 1.75 8.11299 1.75 8.79599V9.20099C1.75 9.88399 2.181 10.493 2.825 10.72L3.754 11.048C3.856 11.309 3.972 11.567 4.117 11.817C4.262 12.067 4.427 12.297 4.602 12.517L4.421 13.485C4.296 14.156 4.608 14.834 5.199 15.175L5.55 15.378C6.142 15.72 6.884 15.651 7.403 15.207L8.148 14.569C8.707 14.655 9.28 14.655 9.849 14.569L10.595 15.208C11.114 15.652 11.857 15.721 12.448 15.379L12.799 15.176C13.391 14.834 13.702 14.157 13.577 13.486L13.397 12.52C13.756 12.071 14.043 11.575 14.248 11.047L15.173 10.721C15.817 10.494 16.248 9.885 16.248 9.202V8.797C16.248 8.114 15.817 7.50502 15.173 7.27802H15.175Z"></path></g></svg>
        </div>
        <div>
          <h3 class="text-base sm:text-lg font-semibold">System Settings</h3>
          <p class="text-sm sm:text-base text-neutral-600 dark:text-neutral-400">Configure your system preferences and account settings.</p>
        </div>
      </div>
      <div class="space-y-4">
        <label class="group flex items-center cursor-pointer justify-between w-full">
          <span class="mr-2 text-sm font-medium flex items-center">
            Email notifications
          </span>
          <div class="relative">
            <input type="checkbox" class="sr-only peer" name="tailwind_switch" id="tailwind_switch">
            <!-- Background element -->
            <div class="w-10 h-6 bg-neutral-200 border border-black/10 rounded-full transition-all duration-150 ease-in-out cursor-pointer group-hover:bg-[#dcdcdb] peer-checked:bg-[#404040] peer-checked:group-hover:bg-neutral-600 peer-checked:border-white/10 dark:bg-neutral-700 dark:border-white/10 dark:group-hover:bg-neutral-600 dark:peer-checked:bg-neutral-50 dark:peer-checked:group-hover:bg-neutral-100 dark:peer-checked:border-black/20 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-neutral-600 dark:peer-focus-visible:outline-neutral-200"></div>
            <!-- Round element with icons inside -->
            <div class="absolute top-[3px] left-[3px] w-[18px] h-[18px] bg-white rounded-full shadow-sm transition-all duration-150 ease-in-out peer-checked:translate-x-4 flex items-center justify-center dark:bg-neutral-200 dark:peer-checked:bg-neutral-800">
              <!-- X icon for unchecked state -->
              <svg class="w-3 h-3 text-neutral-400 transition-all duration-150 ease-in-out group-has-[:checked]:opacity-0 dark:text-neutral-500" fill="none" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
                <path d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
              </svg>
              <!-- Checkmark icon for checked state -->
              <svg class="absolute w-3 h-3 text-neutral-700 transition-all duration-150 ease-in-out opacity-0 group-has-[:checked]:opacity-100 dark:text-neutral-100" fill="currentColor" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
                <path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" fill="currentColor"/>
              </svg>
            </div>
          </div>
        </label>
        <label class="group flex items-center cursor-pointer justify-between w-full">
          <span class="mr-2 text-sm font-medium flex items-center">
            Dark mode
          </span>
          <div class="relative">
            <input type="checkbox" class="sr-only peer" name="tailwind_switch" id="tailwind_switch">
            <!-- Background element -->
            <div class="w-10 h-6 bg-neutral-200 border border-black/10 rounded-full transition-all duration-150 ease-in-out cursor-pointer group-hover:bg-[#dcdcdb] peer-checked:bg-[#404040] peer-checked:group-hover:bg-neutral-600 peer-checked:border-white/10 dark:bg-neutral-700 dark:border-white/10 dark:group-hover:bg-neutral-600 dark:peer-checked:bg-neutral-50 dark:peer-checked:group-hover:bg-neutral-100 dark:peer-checked:border-black/20 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-neutral-600 dark:peer-focus-visible:outline-neutral-200"></div>
            <!-- Round element with icons inside -->
            <div class="absolute top-[3px] left-[3px] w-[18px] h-[18px] bg-white rounded-full shadow-sm transition-all duration-150 ease-in-out peer-checked:translate-x-4 flex items-center justify-center dark:bg-neutral-200 dark:peer-checked:bg-neutral-800">
              <!-- X icon for unchecked state -->
              <svg class="w-3 h-3 text-neutral-400 transition-all duration-150 ease-in-out group-has-[:checked]:opacity-0 dark:text-neutral-500" fill="none" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
                <path d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
              </svg>
              <!-- Checkmark icon for checked state -->
              <svg class="absolute w-3 h-3 text-neutral-700 transition-all duration-150 ease-in-out opacity-0 group-has-[:checked]:opacity-100 dark:text-neutral-100" fill="currentColor" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
                <path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" fill="currentColor"/>
              </svg>
            </div>
          </div>
        </label>
      </div>
    </div>
  </div>
</div>

Tabs with URL anchors

Bookmarkable tabs that update the browser URL, allowing users to share specific tab states and navigate with browser back/forward buttons.

To enable URL anchors, add data-tabs-update-anchor-value="true" and ensure each tab has a unique id attribute.

  • Monthly
  • Quarterly
  • Yearly

Monthly Billing

Pay monthly with no long-term commitment. Notice how the URL updates to #monthly when you select this tab.

Starter

$9/mo
  • Up to 5 projects
  • 10GB storage
  • Email support
  • Basic analytics
Popular

Pro

$29/mo
  • Unlimited projects
  • 100GB storage
  • Priority support
  • Advanced analytics

Enterprise

$99/mo
  • Everything in Pro
  • 1TB storage
  • 24/7 phone support
  • Custom integrations

URL Anchor Feature

Try clicking between pricing tabs and notice how the URL changes to #monthly, #quarterly, or #yearly. You can bookmark any pricing plan and share specific billing periods with others. The page will automatically load the correct tab when accessed with an anchor.

<div class="w-full"
    data-controller="tabs"
    data-tabs-active-tab-class="bg-neutral-900 text-white shadow-sm dark:bg-white dark:text-neutral-900 dark:shadow-sm hover:ring-neutral-800 dark:hover:bg-neutral-50 dark:hover:ring-white"
    data-tabs-inactive-tab-class="hover:bg-white/75 ring-1 ring-transparent hover:ring-white dark:hover:bg-neutral-700 dark:hover:ring-neutral-600 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
    data-tabs-index-value="0"
    data-tabs-update-anchor-value="true">
  <ul class="opacity-0 transition-opacity duration-200 relative inline-grid gap-2 items-center justify-center w-full grid-cols-1 sm:grid-cols-3 p-1 bg-neutral-100 border border-neutral-200 rounded-lg select-none dark:bg-neutral-800 dark:border-neutral-700" data-tabs-target="tabList">
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition outline-none" id="monthly" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <div class="cursor-pointer flex items-center justify-center py-2.5 px-4 w-full text-center" href="#monthly">
        <span>Monthly</span>
      </div>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition outline-none" id="quarterly" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <div class="cursor-pointer flex items-center justify-center py-2.5 px-4 w-full text-center" href="#quarterly">
        <span>Quarterly</span>
      </div>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition outline-none" id="yearly" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <div class="cursor-pointer flex items-center justify-center py-2.5 px-4 w-full text-center" href="#yearly">
        <span>Yearly</span>
      </div>
    </li>
  </ul>

  <div class="py-6 px-4" data-tabs-target="panel">
    <div class="space-y-6">
      <div class="text-center">
        <h3 class="text-2xl font-bold mb-4">Monthly Billing</h3>
        <p class="text-neutral-600 dark:text-neutral-400 mb-6">Pay monthly with no long-term commitment. Notice how the URL updates to #monthly when you select this tab.</p>
      </div>

      <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div class="p-6 border border-neutral-200 dark:border-neutral-700 rounded-lg text-center">
          <h4 class="font-bold text-lg mb-2">Starter</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-4">$9<span class="text-sm text-neutral-500">/mo</span></div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Up to 5 projects</li>
            <li>10GB storage</li>
            <li>Email support</li>
            <li>Basic analytics</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Get Started</button>
        </div>

        <div class="p-6 border-2 border-neutral-900 dark:border-neutral-100 rounded-lg text-center relative">
          <div class="absolute -top-3 left-1/2 transform -translate-x-1/2 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900 px-3 py-1 rounded-full text-xs">Popular</div>
          <h4 class="font-bold text-lg mb-2">Pro</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-4">$29<span class="text-sm text-neutral-500">/mo</span></div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Unlimited projects</li>
            <li>100GB storage</li>
            <li>Priority support</li>
            <li>Advanced analytics</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Get Started</button>
        </div>

        <div class="p-6 border border-neutral-200 dark:border-neutral-700 rounded-lg text-center">
          <h4 class="font-bold text-lg mb-2">Enterprise</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-4">$99<span class="text-sm text-neutral-500">/mo</span></div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Everything in Pro</li>
            <li>1TB storage</li>
            <li>24/7 phone support</li>
            <li>Custom integrations</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Contact Us</button>
        </div>
      </div>
    </div>
  </div>

  <div class="py-6 px-4 hidden" data-tabs-target="panel">
    <div class="space-y-6">
      <div class="text-center">
        <h3 class="text-2xl font-bold mb-4">Quarterly Billing</h3>
        <p class="text-neutral-600 dark:text-neutral-400 mb-6">Save 15% with quarterly billing. The URL is now #quarterly and can be bookmarked!</p>
      </div>

      <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div class="p-6 border border-neutral-200 dark:border-neutral-700 rounded-lg text-center">
          <h4 class="font-bold text-lg mb-2">Starter</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-1">$23<span class="text-sm text-neutral-500">/3mo</span></div>
          <div class="text-sm text-emerald-600 dark:text-emerald-400 mb-4">Save $4 per quarter</div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Up to 5 projects</li>
            <li>10GB storage</li>
            <li>Email support</li>
            <li>Basic analytics</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Get Started</button>
        </div>

        <div class="p-6 border-2 border-neutral-900 dark:border-neutral-100 rounded-lg text-center relative">
          <div class="absolute -top-3 left-1/2 transform -translate-x-1/2 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900 px-3 py-1 rounded-full text-xs">Popular</div>
          <h4 class="font-bold text-lg mb-2">Pro</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-1">$74<span class="text-sm text-neutral-500">/3mo</span></div>
          <div class="text-sm text-emerald-600 dark:text-emerald-400 mb-4">Save $13 per quarter</div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Unlimited projects</li>
            <li>100GB storage</li>
            <li>Priority support</li>
            <li>Advanced analytics</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Get Started</button>
        </div>

        <div class="p-6 border border-neutral-200 dark:border-neutral-700 rounded-lg text-center">
          <h4 class="font-bold text-lg mb-2">Enterprise</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-1">$252<span class="text-sm text-neutral-500">/3mo</span></div>
          <div class="text-sm text-emerald-600 dark:text-emerald-400 mb-4">Save $45 per quarter</div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Everything in Pro</li>
            <li>1TB storage</li>
            <li>24/7 phone support</li>
            <li>Custom integrations</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Contact Us</button>
        </div>
      </div>
    </div>
  </div>

  <div class="py-6 px-4 hidden" data-tabs-target="panel">
    <div class="space-y-6">
      <div class="text-center">
        <h3 class="text-2xl font-bold mb-4">Yearly Billing</h3>
        <p class="text-neutral-600 dark:text-neutral-400 mb-6">Save 25% with annual billing. This tab's URL is #yearly.</p>
      </div>

      <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div class="p-6 border border-neutral-200 dark:border-neutral-700 rounded-lg text-center">
          <h4 class="font-bold text-lg mb-2">Starter</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-1">$81<span class="text-sm text-neutral-500">/year</span></div>
          <div class="text-sm text-emerald-600 dark:text-emerald-400 mb-4">Save $27 per year</div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Up to 5 projects</li>
            <li>10GB storage</li>
            <li>Email support</li>
            <li>Basic analytics</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Get Started</button>
        </div>

        <div class="p-6 border-2 border-neutral-900 dark:border-neutral-100 rounded-lg text-center relative">
          <div class="absolute -top-3 left-1/2 transform -translate-x-1/2 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900 px-3 py-1 rounded-full text-xs">Popular</div>
          <h4 class="font-bold text-lg mb-2">Pro</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-1">$261<span class="text-sm text-neutral-500">/year</span></div>
          <div class="text-sm text-emerald-600 dark:text-emerald-400 mb-4">Save $87 per year</div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Unlimited projects</li>
            <li>100GB storage</li>
            <li>Priority support</li>
            <li>Advanced analytics</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Get Started</button>
        </div>

        <div class="p-6 border border-neutral-200 dark:border-neutral-700 rounded-lg text-center">
          <h4 class="font-bold text-lg mb-2">Enterprise</h4>
          <div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-1">$891<span class="text-sm text-neutral-500">/year</span></div>
          <div class="text-sm text-emerald-600 dark:text-emerald-400 mb-4">Save $297 per year</div>
          <ul class="space-y-2 text-sm text-neutral-600 dark:text-neutral-400 mb-6">
            <li>Everything in Pro</li>
            <li>1TB storage</li>
            <li>24/7 phone support</li>
            <li>Custom integrations</li>
          </ul>
          <button class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Contact Us</button>
        </div>
      </div>
    </div>
  </div>
</div>

<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
  <div class="flex items-start space-x-3">
    <div>
      <h4 class="font-medium text-blue-900 dark:text-blue-100 mb-1">URL Anchor Feature</h4>
      <p class="text-sm text-blue-700 dark:text-blue-300">Try clicking between pricing tabs and notice how the URL changes to #monthly, #quarterly, or #yearly. You can bookmark any pricing plan and share specific billing periods with others. The page will automatically load the correct tab when accessed with an anchor.</p>
    </div>
  </div>
</div>

Vertical tabs

Tabs arranged vertically for sidebar navigation or when you have longer tab labels. Supports both horizontal and vertical arrow key navigation for enhanced accessibility.

No Dashboard Data

Configure your dashboard first.

<div class="w-full" data-controller="tabs" data-tabs-active-tab-class="bg-neutral-900 text-white shadow-sm dark:bg-white dark:text-neutral-900 dark:shadow-sm hover:ring-neutral-800 dark:hover:bg-neutral-50 dark:hover:ring-white" data-tabs-inactive-tab-class="hover:bg-white/75 ring-1 ring-transparent hover:ring-white dark:hover:bg-neutral-700 dark:hover:ring-neutral-600 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200" data-tabs-index-value="0">
  <div class="flex gap-6 flex-col sm:flex-row">
    <!-- Vertical Tab List -->
    <ul class="flex flex-col gap-2 sm:w-48 space-y-1 p-1 bg-neutral-50 border border-neutral-200 rounded-lg dark:bg-neutral-800 dark:border-neutral-700" data-tabs-target="tabList">
      <li class="rounded-md text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
        <a class="flex items-center space-x-3 py-3 px-4 w-full text-left" href="#">
          <div>
            <div class="font-medium">Dashboard</div>
            <div class="hidden sm:block text-xs text-neutral-400">Overview & metrics</div>
          </div>
        </a>
      </li>
      <li class="rounded-md text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
        <a class="flex items-center space-x-3 py-3 px-4 w-full text-left" href="#">
          <div>
            <div class="font-medium">Projects</div>
            <div class="hidden sm:block text-xs text-neutral-400">Manage your work</div>
          </div>
        </a>
      </li>
      <li class="rounded-md text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
        <a class="flex items-center space-x-3 py-3 px-4 w-full text-left" href="#">
          <div>
            <div class="font-medium">Team</div>
            <div class="hidden sm:block text-xs text-neutral-400">Members & roles</div>
          </div>
        </a>
      </li>
      <li class="rounded-md text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
        <a class="flex items-center space-x-3 py-3 px-4 w-full text-left" href="#">
          <div>
            <div class="font-medium">Settings</div>
            <div class="hidden sm:block text-xs text-neutral-400">Configuration</div>
          </div>
        </a>
      </li>
    </ul>

    <!-- Tab Content -->
    <div class="flex-1">
      <!-- Dashboard Empty State -->
      <div class="py-8 px-6 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900" data-tabs-target="panel">
        <div class="text-center max-w-md mx-auto">
          <div class="w-16 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-8 opacity-50" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><rect x="3.75" y="2.75" width="5.5" height="5.5" rx="1" ry="1"></rect><rect x="12.25" y="4.75" width="4" height="8.5" rx="1" ry="1"></rect><rect x="1.75" y="11.25" width="7.5" height="4" rx="1" ry="1"></rect></g></svg>
          </div>
          <h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">No Dashboard Data</h3>
          <p class="text-neutral-600 dark:text-neutral-400 mb-6">Configure your dashboard first.</p>
          <button class="mx-auto flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
            Set Up Dashboard
          </button>
        </div>
      </div>

      <!-- Projects Empty State -->
      <div class="py-8 px-6 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 hidden" data-tabs-target="panel">
        <div class="text-center max-w-md mx-auto">
          <div class="w-16 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-8 opacity-50" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><polyline points="14.983 5.53 9 9 3.017 5.53"></polyline><line x1="9" y1="15.938" x2="9" y2="9"></line><path d="M7.997,2.332L3.747,4.797c-.617,.358-.997,1.017-.997,1.73v4.946c0,.713,.38,1.372,.997,1.73l4.25,2.465c.621,.36,1.386,.36,2.007,0l4.25-2.465c.617-.358,.997-1.017,.997-1.73V6.527c0-.713-.38-1.372-.997-1.73l-4.25-2.465c-.621-.36-1.386-.36-2.007,0Z"></path></g></svg>
          </div>
          <h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">No Projects Yet</h3>
          <p class="text-neutral-600 dark:text-neutral-400 mb-6">Start organizing your work.</p>
          <button class="mx-auto flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
            Create Project
          </button>
        </div>
      </div>

      <!-- Team Empty State -->
      <div class="py-8 px-6 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 hidden" data-tabs-target="panel">
        <div class="text-center max-w-md mx-auto">
          <div class="w-16 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-8 opacity-50" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><circle cx="9" cy="7" r="2"></circle><path d="M5.801,15.776c-.489-.148-.818-.635-.709-1.135,.393-1.797,1.993-3.142,3.908-3.142s3.515,1.345,3.908,3.142c.109,.499-.219,.987-.709,1.135-.821,.248-1.911,.474-3.199,.474s-2.378-.225-3.199-.474Z"></path><circle cx="13.75" cy="3.25" r="1.5"></circle><path d="M13.584,7.248c.055-.002,.11-.004,.166-.004,1.673,0,3.079,1.147,3.473,2.697,.13,.511-.211,1.02-.718,1.167-.643,.186-1.457,.352-2.403,.385"></path><circle cx="4.25" cy="3.25" r="1.5"></circle><path d="M4.416,7.248c-.055-.002-.11-.004-.166-.004-1.673,0-3.079,1.147-3.473,2.697-.13,.511,.211,1.02,.718,1.167,.643,.186,1.457,.352,2.403,.385"></path></g></svg>
          </div>
          <h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">No Team Members</h3>
          <p class="text-neutral-600 dark:text-neutral-400 mb-6">Start building your team.</p>
          <button class="mx-auto flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
            Invite Members
          </button>
        </div>
      </div>

      <!-- Settings Empty State -->
      <div class="py-8 px-6 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 hidden" data-tabs-target="panel">
        <div class="text-center max-w-md mx-auto">
          <div class="w-16 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-8 opacity-50" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M9 11.2495C10.2426 11.2495 11.25 10.2422 11.25 8.99951C11.25 7.75687 10.2426 6.74951 9 6.74951C7.75736 6.74951 6.75 7.75687 6.75 8.99951C6.75 10.2422 7.75736 11.2495 9 11.2495Z"></path> <path d="M15.175 7.27802L14.246 6.95001C14.144 6.68901 14.027 6.42999 13.883 6.17999C13.739 5.92999 13.573 5.69999 13.398 5.48099L13.578 4.513C13.703 3.842 13.391 3.164 12.8 2.823L12.449 2.62C11.857 2.278 11.115 2.34699 10.596 2.79099L9.851 3.42801C9.291 3.34201 8.718 3.34201 8.148 3.42801L7.403 2.79001C6.884 2.34601 6.141 2.27699 5.55 2.61899L5.199 2.82199C4.607 3.16299 4.296 3.84099 4.421 4.51199L4.601 5.47699C4.241 5.92599 3.955 6.42299 3.749 6.95099L2.825 7.27701C2.181 7.50401 1.75 8.11299 1.75 8.79599V9.20099C1.75 9.88399 2.181 10.493 2.825 10.72L3.754 11.048C3.856 11.309 3.972 11.567 4.117 11.817C4.262 12.067 4.427 12.297 4.602 12.517L4.421 13.485C4.296 14.156 4.608 14.834 5.199 15.175L5.55 15.378C6.142 15.72 6.884 15.651 7.403 15.207L8.148 14.569C8.707 14.655 9.28 14.655 9.849 14.569L10.595 15.208C11.114 15.652 11.857 15.721 12.448 15.379L12.799 15.176C13.391 14.834 13.702 14.157 13.577 13.486L13.397 12.52C13.756 12.071 14.043 11.575 14.248 11.047L15.173 10.721C15.817 10.494 16.248 9.885 16.248 9.202V8.797C16.248 8.114 15.817 7.50502 15.173 7.27802H15.175Z"></path></g></svg>
          </div>
          <h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Default Settings Active</h3>
          <p class="text-neutral-600 dark:text-neutral-400 mb-6">Your account is using default settings.</p>
          <button class="mx-auto flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
            Configure Settings
          </button>
        </div>
      </div>
    </div>
  </div>
</div>

Auto-switching tabs with progress bar

Tabs that automatically cycle through content with a visual progress indicator, perfect for feature sections. Automatically pauses on hover or focus for better user control.

Performance Metrics

Monitor your application's performance with real-time metrics and insights.

Tabs switch automatically every 4 seconds. Hover or focus to pause.

<div
  data-controller="tabs"
  data-tabs-active-tab-class="bg-neutral-900 text-white shadow-sm dark:bg-white dark:text-neutral-900 dark:shadow-sm hover:ring-neutral-800 dark:hover:bg-neutral-50 dark:hover:ring-white"
  data-tabs-inactive-tab-class="hover:bg-white/75 ring-1 ring-transparent hover:ring-white dark:hover:bg-neutral-700 dark:hover:ring-neutral-600 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
  data-tabs-auto-switch-value="true"
  data-tabs-auto-switch-interval-value="4000"
  data-tabs-pause-on-hover-value="true"
  data-tabs-show-progress-bar-value="true"
  data-tabs-index-value="0"
  class="w-full max-w-2xl mx-auto"
>
  <!-- Progress Bar -->
  <div class="w-full h-1 bg-neutral-200 dark:bg-neutral-700 rounded-full mb-4 overflow-hidden">
    <div
      data-tabs-target="progressBar"
      class="h-full bg-neutral-900 dark:bg-white rounded-full"
      style="width: 0%"
    ></div>
  </div>

  <!-- Tab List -->
  <ul class="opacity-0 transition-opacity duration-200 flex gap-2 bg-neutral-100 dark:bg-neutral-800 p-1 rounded-lg border border-neutral-200 dark:border-neutral-700" data-tabs-target="tabList">
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition w-full" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex items-center justify-center gap-2 py-3 px-4 w-full text-center text-sm font-medium rounded-md" href="#">
        <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
        </svg>
        <span class="hidden sm:block">Performance</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition w-full" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex items-center justify-center gap-2 py-3 px-4 w-full text-center text-sm font-medium rounded-md" href="#">
        <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M12 2L2 7l10 5 10-5-10-5z"/>
          <path d="M2 17l10 5 10-5"/>
          <path d="M2 12l10 5 10-5"/>
        </svg>
        <span class="hidden sm:block">Analytics</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition w-full" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex items-center justify-center gap-2 py-3 px-4 w-full text-center text-sm font-medium rounded-md" href="#">
        <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
        </svg>
        <span class="hidden sm:block">Security</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition w-full" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex items-center justify-center gap-2 py-3 px-4 w-full text-center text-sm font-medium rounded-md" href="#">
        <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
          <polyline points="14,2 14,8 20,8"/>
        </svg>
        <span class="hidden sm:block">Reports</span>
      </a>
    </li>
  </ul>

  <!-- Tab Panels -->
  <div class="mt-6 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg p-6" data-tabs-target="panel">
    <div class="text-center py-8 max-w-md mx-auto">
      <div class="mb-4">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-8 mx-auto text-neutral-800 dark:text-neutral-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
        </svg>
      </div>
      <h3 class="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Performance Metrics</h3>
      <p class="text-neutral-600 dark:text-neutral-400 mb-4">Monitor your application's performance with real-time metrics and insights.</p>
    </div>
  </div>

  <div class="mt-6 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg p-6 hidden" data-tabs-target="panel">
    <div class="text-center py-8 max-w-md mx-auto">
      <div class="mb-4">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-8 mx-auto text-neutral-800 dark:text-neutral-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M12 2L2 7l10 5 10-5-10-5z"/>
          <path d="M2 17l10 5 10-5"/>
          <path d="M2 12l10 5 10-5"/>
        </svg>
      </div>
      <h3 class="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Analytics Dashboard</h3>
      <p class="text-neutral-600 dark:text-neutral-400 mb-4">Comprehensive analytics to understand user behavior and traffic patterns.</p>
    </div>
  </div>

  <div class="mt-6 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg p-6 hidden" data-tabs-target="panel">
    <div class="text-center py-8 max-w-md mx-auto">
      <div class="mb-4">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-8 mx-auto text-neutral-800 dark:text-neutral-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
        </svg>
      </div>
      <h3 class="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Security Overview</h3>
      <p class="text-neutral-600 dark:text-neutral-400 mb-4">Keep your application secure with advanced monitoring and threat detection.</p>
    </div>
  </div>

  <div class="mt-6 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg p-6 hidden" data-tabs-target="panel">
    <div class="text-center py-8 max-w-md mx-auto">
      <div class="mb-4">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-8 mx-auto text-neutral-800 dark:text-neutral-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
          <polyline points="14,2 14,8 20,8"/>
        </svg>
      </div>
      <h3 class="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Generated Reports</h3>
      <p class="text-neutral-600 dark:text-neutral-400 mb-4">Download comprehensive reports and export your data in various formats.</p>
    </div>
  </div>

  <!-- Info Text -->
  <div class="mt-4 text-center">
    <p class="text-sm text-neutral-500 dark:text-neutral-400">
      Tabs switch automatically every 4 seconds. Hover or focus to pause.
    </p>
  </div>
</div>

Lazy loading tabs

Load tab content only when needed to improve initial page load performance. Perfect for heavy content like product details or complex forms.

Loading product overview...

<!-- Lazy loading tabs example -->
<div class="w-full" data-controller="tabs"
     data-tabs-lazy-load-value="true"
     data-tabs-active-tab-class="bg-neutral-900 text-white shadow-sm dark:bg-white dark:text-neutral-900 dark:shadow-sm hover:ring-neutral-800 dark:hover:bg-neutral-50 dark:hover:ring-white"
     data-tabs-inactive-tab-class="hover:bg-white/75 ring-1 ring-transparent hover:ring-white dark:hover:bg-neutral-700 dark:hover:ring-neutral-600 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
     data-tabs-index-value="0">

  <ul class="opacity-0 transition-opacity duration-200 relative inline-grid gap-2 items-center justify-center w-full grid-cols-1 sm:grid-cols-3 p-1 bg-neutral-100 border border-neutral-200 rounded-lg select-none dark:bg-neutral-800 dark:border-neutral-700" data-tabs-target="tabList">
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>Product</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>Specs</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>Reviews</span>
      </a>
    </li>
  </ul>

  <!-- Panel 1: Product Overview -->
  <div class="py-6 px-4" data-tabs-target="panel">
    <div class="flex items-center justify-center py-12">
      <div class="text-center">
        <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
        <p class="text-neutral-600 dark:text-neutral-400">Loading product overview...</p>
      </div>
    </div>
  </div>
  <template data-tabs-target="template">
    <h3 class="text-lg font-semibold mb-4 flex items-center gap-2">Premium Wireless Headphones</h3>
    <div class="grid md:grid-cols-2 gap-6">
      <div class="bg-neutral-100 dark:bg-neutral-800 rounded-lg flex items-center justify-center aspect-[4/3]">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-20 opacity-75" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M6.20016 14.5681L4.8641 8.67916C4.26207 6.02581 6.27897 3.5 8.99979 3.5C11.7205 3.5 13.7375 6.02581 13.1355 8.67915L11.7994 14.5682C11.7706 14.6838 11.7702 14.8088 11.7971 14.9224C11.8488 15.1421 11.9949 15.3251 12.1979 15.424C12.3026 15.4753 12.4245 15.5025 12.5435 15.5H13.9142C15.2081 15.5 16.3272 14.5976 16.6022 13.3329L16.9372 11.7879C17.2025 10.5645 16.6072 9.3145 15.4889 8.75034L14.7085 8.35627C15.0766 5.01469 12.4582 2 8.99979 2C5.54133 2 2.92302 5.0147 3.29111 8.35627L2.51034 8.7505C1.39203 9.31466 0.797101 10.5645 1.06241 11.7879L1.3975 13.3333C1.67243 14.598 2.79148 15.5 4.08538 15.5H5.45608C5.57534 15.5025 5.69753 15.4751 5.80238 15.4237C6.11328 15.2717 6.28365 14.9031 6.20016 14.5681Z"></path></g></svg>
      </div>
      <div class="space-y-4">
        <p class="text-neutral-600 dark:text-neutral-400">
          Experience audio like never before with our premium wireless headphones. Featuring industry-leading noise cancellation, 30-hour battery life, and crystal-clear sound quality.
        </p>
        <ul class="list-disc list-inside space-y-2 text-sm text-neutral-600 dark:text-neutral-400">
          <li>Active Noise Cancellation (ANC)</li>
          <li>30-hour battery life</li>
          <li>Premium comfort fit</li>
          <li>Multi-device connectivity</li>
        </ul>
        <div class="flex items-baseline gap-4">
          <span class="text-2xl font-bold text-neutral-900 dark:text-white">$299.99</span>
          <span class="text-sm text-neutral-500 line-through">$399.99</span>
        </div>
      </div>
    </div>
  </template>

  <!-- Panel 2: Specifications -->
  <div class="py-6 px-4 hidden" data-tabs-target="panel">
    <div class="flex items-center justify-center py-12">
      <div class="text-center">
        <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
        <p class="text-neutral-600 dark:text-neutral-400">Loading specifications...</p>
      </div>
    </div>
  </div>
  <template data-tabs-target="template">
    <h3 class="text-lg font-semibold mb-4 flex items-center gap-2">Technical Specifications</h3>
    <div class="overflow-x-auto">
      <table class="w-full text-sm">
        <tbody class="divide-y divide-neutral-200 dark:divide-neutral-700">
          <tr>
            <td class="py-3 pr-8 font-medium text-neutral-700 dark:text-neutral-300">Driver Size</td>
            <td class="py-3 text-neutral-600 dark:text-neutral-400">40mm Dynamic</td>
          </tr>
          <tr>
            <td class="py-3 pr-8 font-medium text-neutral-700 dark:text-neutral-300">Frequency Response</td>
            <td class="py-3 text-neutral-600 dark:text-neutral-400">20Hz - 20kHz</td>
          </tr>
          <tr>
            <td class="py-3 pr-8 font-medium text-neutral-700 dark:text-neutral-300">Impedance</td>
            <td class="py-3 text-neutral-600 dark:text-neutral-400">32Ω</td>
          </tr>
          <tr>
            <td class="py-3 pr-8 font-medium text-neutral-700 dark:text-neutral-300">Bluetooth Version</td>
            <td class="py-3 text-neutral-600 dark:text-neutral-400">5.3</td>
          </tr>
          <tr>
            <td class="py-3 pr-8 font-medium text-neutral-700 dark:text-neutral-300">Battery Life</td>
            <td class="py-3 text-neutral-600 dark:text-neutral-400">30 hours (ANC on), 45 hours (ANC off)</td>
          </tr>
          <tr>
            <td class="py-3 pr-8 font-medium text-neutral-700 dark:text-neutral-300">Weight</td>
            <td class="py-3 text-neutral-600 dark:text-neutral-400">250g</td>
          </tr>
        </tbody>
      </table>
    </div>
  </template>

  <!-- Panel 3: Reviews -->
  <div class="py-6 px-4 hidden" data-tabs-target="panel">
    <div class="flex items-center justify-center py-12">
      <div class="text-center">
        <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
        <p class="text-neutral-600 dark:text-neutral-400">Loading reviews...</p>
      </div>
    </div>
  </div>
  <template data-tabs-target="template">
    <h3 class="text-lg font-semibold mb-4 flex items-center gap-2">Customer Reviews</h3>
    <div class="space-y-4">
      <div class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-lg border border-black/5 dark:border-white/10">
        <div class="flex items-center gap-2 mb-2">
          <div class="flex text-yellow-500">
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
          </div>
          <span class="font-medium text-sm">John D.</span>
        </div>
        <p class="text-sm text-neutral-600 dark:text-neutral-400">
          Amazing sound quality! The noise cancellation is incredible, and the battery lasts forever. Worth every penny.
        </p>
      </div>

      <div class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-lg border border-black/5 dark:border-white/10">
        <div class="flex items-center gap-2 mb-2">
          <div class="flex text-yellow-500">
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
            <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
            <svg class="w-4 h-4 text-neutral-300 dark:text-neutral-600 fill-current" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
          </div>
          <span class="font-medium text-sm">Sarah M.</span>
        </div>
        <p class="text-sm text-neutral-600 dark:text-neutral-400">
          Great headphones overall. Comfortable for long listening sessions. Only wish the case was a bit smaller.
        </p>
      </div>
    </div>
  </template>
</div>

Turbo Frame lazy loading tabs

Load tab content asynchronously from the server using Turbo Frames. Ideal for user dashboards, account settings, or any dynamic content that requires server-side rendering.

Loading account information from server...

<!-- Turbo Frame lazy loading tabs example -->
<div class="w-full" data-controller="tabs"
     data-tabs-lazy-load-value="true"
     data-tabs-turbo-frame-src-value="<%= tabs_content_path %>"
     data-tabs-active-tab-class="bg-neutral-900 text-white shadow-sm dark:bg-white dark:text-neutral-900 dark:shadow-sm hover:ring-neutral-800 dark:hover:bg-neutral-50 dark:hover:ring-white"
     data-tabs-inactive-tab-class="hover:bg-white/75 ring-1 ring-transparent hover:ring-white dark:hover:bg-neutral-700 dark:hover:ring-neutral-600 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
     data-tabs-index-value="0">

  <ul class="opacity-0 transition-opacity duration-200 relative inline-grid gap-2 items-center justify-center w-full grid-cols-1 sm:grid-cols-3 p-1 bg-neutral-100 border border-neutral-200 rounded-lg select-none dark:bg-neutral-800 dark:border-neutral-700" data-tabs-target="tabList">
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>Account</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>Security</span>
      </a>
    </li>
    <li class="rounded-md whitespace-nowrap text-sm font-medium transition" data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a class="flex gap-1.5 items-center justify-center py-2.5 px-4 w-full text-center" href="#">
        <span>Notifications</span>
      </a>
    </li>
  </ul>

  <!-- Panel 1: Account Info -->
  <div class="py-6 px-4" data-tabs-target="panel">
    <turbo-frame id="tab-content-0" loading="lazy">
      <div class="flex items-center justify-center py-12">
        <div class="text-center">
          <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
          </svg>
          <p class="text-neutral-600 dark:text-neutral-400">Loading account information from server...</p>
        </div>
      </div>
    </turbo-frame>
  </div>

  <!-- Panel 2: Security -->
  <div class="py-6 px-4 hidden" data-tabs-target="panel">
    <turbo-frame id="tab-content-1" loading="lazy">
      <div class="flex items-center justify-center py-12">
        <div class="text-center">
          <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
          </svg>
          <p class="text-neutral-600 dark:text-neutral-400">Loading security settings from server...</p>
        </div>
      </div>
    </turbo-frame>
  </div>

  <!-- Panel 3: Notifications -->
  <div class="py-6 px-4 hidden" data-tabs-target="panel">
    <turbo-frame id="tab-content-2" loading="lazy">
      <div class="flex items-center justify-center py-12">
        <div class="text-center">
          <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
          </svg>
          <p class="text-neutral-600 dark:text-neutral-400">Loading notification preferences from server...</p>
        </div>
      </div>
    </turbo-frame>
  </div>
</div>
# Tabs content route for Turbo Frame lazy loading
get "/tabs_content", to: "pages#tabs_content", as: "tabs_content"
# Tabs content action for Turbo Frame lazy loading
def tabs_content
  tab_index = params[:tab].to_i
  render partial: "components/tabs/tab_content_#{tab_index}", layout: false
end

Configuration

The tabs component is powered by a Stimulus controller that provides keyboard navigation, accessibility features, URL anchor support, and flexible configuration options.

Lazy Loading

The tabs controller supports two types of lazy loading to improve performance:

Template-based Lazy Loading

<div data-controller="tabs"
     data-tabs-lazy-load-value="true">
  <ul data-tabs-target="tabList">
    <li data-tabs-target="tab">Tab 1</li>
    <li data-tabs-target="tab">Tab 2</li>
  </ul>

  <!-- Panel with loading indicator -->
  <div data-tabs-target="panel">Loading...</div>
  <!-- Template with actual content -->
  <template data-tabs-target="template">
    <h3>Tab 1 Content</h3>
    <p>This content loads when the tab is first opened</p>
  </template>

  <div data-tabs-target="panel" class="hidden">Loading...</div>
  <template data-tabs-target="template">
    <h3>Tab 2 Content</h3>
    <p>This content also loads on demand</p>
  </template>
</div>

Turbo Frame Lazy Loading

<div data-controller="tabs"
     data-tabs-lazy-load-value="true"
     data-tabs-turbo-frame-src-value="/tabs_content">
  <ul data-tabs-target="tabList">
    <li data-tabs-target="tab">Tab 1</li>
    <li data-tabs-target="tab">Tab 2</li>
  </ul>

  <!-- Each panel contains a turbo-frame -->
  <div data-tabs-target="panel">
    <turbo-frame id="tab-content-0" loading="lazy">
      Loading...
    </turbo-frame>
  </div>

  <div data-tabs-target="panel" class="hidden">
    <turbo-frame id="tab-content-1" loading="lazy">
      Loading...
    </turbo-frame>
  </div>
</div>

Controller Setup

Basic tabs structure with required data attributes:

<div data-controller="tabs" data-tabs-active-tab-class="bg-blue-600 text-white" data-tabs-inactive-tab-class="text-neutral-600 hover:text-neutral-900">
  <ul data-tabs-target="tabList">
    <li data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a href="#">Tab 1</a>
    </li>
    <li data-tabs-target="tab" data-action="click->tabs#change:prevent">
      <a href="#">Tab 2</a>
    </li>
  </ul>

  <div data-tabs-target="panel">Panel 1 content</div>
  <div data-tabs-target="panel" class="hidden">Panel 2 content</div>
</div>

Configuration Values

Prop Description Type Default
index
The initially active tab index (0-based) Number 0
updateAnchor
Whether to update the browser URL with tab anchors Boolean false
scrollToAnchor
Whether to scroll to the tab when URL changes (works with updateAnchor) Boolean false
scrollActiveTabIntoView
Whether to scroll the active tab into view (useful for horizontal scrollable tabs) Boolean false
autoSwitch
Enable automatic tab switching Boolean false
autoSwitchInterval
Time in milliseconds between automatic tab switches Number 5000
pauseOnHover
Whether to pause auto-switching when hovering or focusing the component Boolean true
showProgressBar
Whether to show a progress bar indicating time until next switch Boolean false
lazyLoad
Enable lazy loading of tab content Boolean false
turboFrameSrc
URL for loading tab content via Turbo Frames (enables server-side lazy loading) String ""

Targets

Target Description Required
tab
The clickable tab elements (usually <li> elements containing the tab buttons) Required
panel
The content panels that show/hide based on the active tab Required
select
Optional select dropdown element for alternative navigation Optional
tabList
Optional container for the tab list (used for opacity animations) Optional
progressBar
Optional progress bar element for auto-switching tabs (shows countdown until next switch) Optional
template
Optional template elements containing lazy-loaded content (one per panel for template-based lazy loading) Optional

Actions

Action Description Usage
change
Switches to the clicked tab or selected option data-action="click->tabs#change:prevent"

CSS Classes

activeTab

CSS classes applied to the active tab element

data-tabs-active-tab-class="bg-blue-600 text-white shadow-sm"

inactiveTab

CSS classes applied to inactive tab elements

data-tabs-inactive-tab-class="text-neutral-600 hover:text-neutral-900"

Events

tab-change

Dispatched when the active tab changes. Contains the active tab element and index in the detail.

document.addEventListener('tab-change', (event) => {
  console.log('Active tab:', event.detail.activeIndex);
  console.log('Tab element:', event.target);
});

Accessibility Features

  • Enhanced Keyboard Navigation: Use or to navigate between tabs, Home/End for first/last tab (works for both horizontal and vertical orientations)
  • ARIA Support: Automatic aria-selected and tabindex management
  • Focus Management: Proper focus handling when switching tabs via keyboard or mouse
  • Screen Reader Friendly: ARIA attributes and semantic structure for accessibility

Advanced Features

  • URL Integration: Bookmarkable tab states with browser back/forward support
  • Select Integration: Works seamlessly with HTML select elements for alternative navigation
  • Dynamic Tab Control: Change tabs programmatically using data attributes or JavaScript
  • Auto-switching: Configurable automatic tab cycling with optional progress bar and hover pause functionality
  • Responsive Design: Optional horizontal scrolling support for tabs that overflow
  • Lazy Loading: Load tab content on-demand with template-based or Turbo Frame server-side loading

Table of contents