Pill Variant (Default)

Default style with --muted background. Active tab gets --background fill and shadow.

Account

Make changes to your account here.

<div class="tabs">
  <div class="tab-list" role="tablist" aria-label="Account settings">
    <button class="tab-trigger" role="tab"
            aria-selected="true"
            aria-controls="pill-panel-account"
            id="pill-tab-account">Account</button>
    <button class="tab-trigger" role="tab"
            aria-selected="false"
            aria-controls="pill-panel-password"
            id="pill-tab-password"
            tabindex="-1">Password</button>
  </div>

  <div class="tab-content" role="tabpanel"
       id="pill-panel-account"
       aria-labelledby="pill-tab-account"
       tabindex="0">
    <div class="card" style="max-width:24rem;">
      <div class="card-header">
        <h3 class="card-title" style="font-size:1rem;">Account</h3>
        <p class="card-description">Make changes to your account here.</p>
      </div>
      <div class="card-content" style="display:flex;flex-direction:column;gap:0.75rem;">
        <div>
          <label class="label" for="demo-tab-name">Name</label>
          <input class="input" id="demo-tab-name" type="text" value="Cody Lindley">
        </div>
        <div>
          <label class="label" for="demo-tab-user">Username</label>
          <input class="input" id="demo-tab-user" type="text" value="@codylindley">
        </div>
      </div>
      <div class="card-footer">
        <button class="btn" data-variant="default">Save changes</button>
      </div>
    </div>
  </div>

  <div class="tab-content" role="tabpanel"
       id="pill-panel-password"
       aria-labelledby="pill-tab-password"
       tabindex="0" hidden>
    <div class="card" style="max-width:24rem;">
      <div class="card-header">
        <h3 class="card-title" style="font-size:1rem;">Password</h3>
        <p class="card-description">Change your password here.</p>
      </div>
      <div class="card-content" style="display:flex;flex-direction:column;gap:0.75rem;">
        <div>
          <label class="label" for="demo-tab-current">Current password</label>
          <input class="input" id="demo-tab-current" type="password">
        </div>
        <div>
          <label class="label" for="demo-tab-new">New password</label>
          <input class="input" id="demo-tab-new" type="password">
        </div>
      </div>
      <div class="card-footer">
        <button class="btn" data-variant="default">Save password</button>
      </div>
    </div>
  </div>
</div>

Line Variant with Disabled Tab

Add data-variant="line" to the .tab-list for an underline style. Disabled tabs use the native disabled attribute.

Overview dashboard content goes here. This is the default active panel.

<div class="tabs">
  <div class="tab-list" role="tablist" aria-label="Dashboard"
       data-variant="line">
    <button class="tab-trigger" role="tab"
            aria-selected="true"
            aria-controls="line-panel-overview"
            id="line-tab-overview">Overview</button>
    <button class="tab-trigger" role="tab"
            aria-selected="false"
            aria-controls="line-panel-analytics"
            id="line-tab-analytics"
            tabindex="-1">Analytics</button>
    <button class="tab-trigger" role="tab"
            aria-selected="false"
            aria-controls="line-panel-reports"
            id="line-tab-reports"
            tabindex="-1"
            disabled>Reports</button>
  </div>

  <div class="tab-content" role="tabpanel"
       id="line-panel-overview"
       aria-labelledby="line-tab-overview"
       tabindex="0">
    <p class="text-muted-foreground" style="font-size:0.875rem;" style="padding:1rem 0;">
      Overview dashboard content goes here.
    </p>
  </div>
  <div class="tab-content" role="tabpanel"
       id="line-panel-analytics"
       aria-labelledby="line-tab-analytics"
       tabindex="0" hidden>
    <p class="text-muted-foreground" style="font-size:0.875rem;" style="padding:1rem 0;">
      Analytics panel with charts and metrics.
    </p>
  </div>
  <div class="tab-content" role="tabpanel"
       id="line-panel-reports"
       aria-labelledby="line-tab-reports"
       tabindex="0" hidden>
    <p class="text-muted-foreground" style="font-size:0.875rem;" style="padding:1rem 0;">
      Reports panel with exportable data tables.
    </p>
  </div>
</div>

Vertical Orientation

Set aria-orientation="vertical" on the tablist. CSS uses :has() to switch to a side-by-side layout automatically.

General Settings

Manage your general account preferences.

<div class="tabs">
  <div class="tab-list" role="tablist"
       aria-label="Settings"
       aria-orientation="vertical">
    <button class="tab-trigger" role="tab"
            aria-selected="true"
            aria-controls="vert-panel-general"
            id="vert-tab-general">General</button>
    <button class="tab-trigger" role="tab"
            aria-selected="false"
            aria-controls="vert-panel-security"
            id="vert-tab-security"
            tabindex="-1">Security</button>
    <button class="tab-trigger" role="tab"
            aria-selected="false"
            aria-controls="vert-panel-notif"
            id="vert-tab-notif"
            tabindex="-1">Notifications</button>
  </div>

  <div class="tab-content" role="tabpanel"
       id="vert-panel-general"
       aria-labelledby="vert-tab-general"
       tabindex="0">
    <div class="card">
      <div class="card-header">
        <h3 class="card-title" style="font-size:1rem;">General Settings</h3>
        <p class="card-description">Manage your general account preferences.</p>
      </div>
      <div class="card-content" style="display:flex;flex-direction:column;gap:0.75rem;">
        <div>
          <label class="label" for="vert-display">Display name</label>
          <input class="input" id="vert-display" type="text" value="Cody Lindley">
        </div>
        <div>
          <label class="label" for="vert-email">Email</label>
          <input class="input" id="vert-email" type="email" value="cody@example.com">
        </div>
      </div>
    </div>
  </div>

  <div class="tab-content" role="tabpanel"
       id="vert-panel-security"
       aria-labelledby="vert-tab-security"
       tabindex="0" hidden>
    <div class="card">
      <div class="card-header">
        <h3 class="card-title" style="font-size:1rem;">Security</h3>
        <p class="card-description">Configure your security settings.</p>
      </div>
      <div class="card-content">
        <p class="text-muted-foreground" style="font-size:0.875rem;">Two-factor authentication, session management, and login history.</p>
      </div>
    </div>
  </div>

  <div class="tab-content" role="tabpanel"
       id="vert-panel-notif"
       aria-labelledby="vert-tab-notif"
       tabindex="0" hidden>
    <div class="card">
      <div class="card-header">
        <h3 class="card-title" style="font-size:1rem;">Notifications</h3>
        <p class="card-description">Choose what you want to be notified about.</p>
      </div>
      <div class="card-content">
        <p class="text-muted-foreground" style="font-size:0.875rem;">Email notifications, push alerts, and digest preferences.</p>
      </div>
    </div>
  </div>
</div>

CSS view file

/* -- Tabs component -------------------------------------------- */

@layer components {
  /* -- Default (pill) variant ----------------------------------- */
  .tab-list {
    display: inline-flex;
    align-items: center;
    background: var(--muted);
    border-radius: var(--radius-lg);
    padding: 0.25rem;
    gap: 0.125rem;

    /* -- Line variant --------------------------------------------- */
    &[data-variant="line"] {
      background: transparent;
      border-radius: 0;
      padding: 0;
      gap: 0;
      border-bottom: 1px solid var(--border);

      & .tab-trigger {
        border-radius: 0;
        padding: 0.5rem 1rem;
        border-bottom: 2px solid transparent;
        margin-bottom: -1px;

        &[aria-selected="true"] {
          background: transparent;
          color: var(--foreground);
          border-bottom-color: var(--primary);
          box-shadow: none;
        }
      }
    }

    /* -- Vertical orientation ------------------------------------ */
    &[aria-orientation="vertical"] {
      flex-direction: column;
      width: auto;

      & .tab-trigger {
        justify-content: flex-start;
        width: 100%;
      }
    }
  }

  .tab-trigger {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    padding: 0.375rem 0.75rem;
    border-radius: calc(var(--radius) * 0.6);
    font-size: 0.875rem;
    font-weight: 500;
    border: none;
    background: transparent;
    color: var(--muted-foreground);
    cursor: pointer;
    transition: all 150ms ease;
    white-space: nowrap;
    outline: none;

    &[aria-selected="true"] {
      background: var(--background);
      color: var(--foreground);
      box-shadow: 0 1px 3px oklch(0 0 0 / 0.08), 0 0 0 1px var(--border);
    }

    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }

    &:disabled {
      pointer-events: none;
      opacity: 0.5;
    }
  }

  /* -- Vertical layout container ----------------------------- */
  .tabs:has(.tab-list[aria-orientation="vertical"]) {
    display: flex;
    gap: 1rem;
    align-items: flex-start;

    & > .tab-content {
      margin-top: 0;
      flex: 1;
    }
  }

  /* -- Panel ---------------------------------------------------- */
  .tab-content {
    margin-top: 0.75rem;

    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }

    &[hidden] { display: none; }
  }

  /* -- Accessibility ------------------------------------------ */

  @media (prefers-reduced-motion: reduce) {
    .tab-trigger { transition: none; }
  }

  @media (forced-colors: active) {
    .tab-trigger[aria-selected="true"] {
      border: 2px solid Highlight;
      box-shadow: none;
    }

    .tab-list[data-variant="line"] .tab-trigger[aria-selected="true"] {
      border: none;
      border-bottom: 2px solid Highlight;
    }
  }
}

JavaScript view file

Panel switching + WAI-ARIA keyboard navigation. Arrow keys cycle tabs, Home/End jump to first/last, disabled tabs are skipped automatically.

// -- Tabs -----------------------------------------------------
// ARIA-compliant keyboard navigation for [role="tablist"] elements.

const activateTab = (tab, triggers) => {
  triggers.forEach((t) => {
    t.setAttribute('aria-selected', 'false');
    t.setAttribute('tabindex', '-1');
    const panel = document.getElementById(t.getAttribute('aria-controls'));
    if (panel) panel.hidden = true;
  });
  tab.setAttribute('aria-selected', 'true');
  tab.removeAttribute('tabindex');
  const panel = document.getElementById(tab.getAttribute('aria-controls'));
  if (panel) panel.hidden = false;
};

function init() {
document.querySelectorAll('[role="tablist"]:not([data-init])').forEach((tablist) => {
    tablist.dataset.init = '';
    if (!tablist.querySelector('.tab-trigger')) return;
    const triggers = Array.from(tablist.querySelectorAll('[role="tab"]'));
    const orientation = tablist.getAttribute('aria-orientation') || 'horizontal';

    triggers.forEach((trigger) => {
      trigger.addEventListener('click', () => { activateTab(trigger, triggers); });
      trigger.addEventListener('keydown', (e) => {
        const current = triggers.indexOf(trigger);
        let next;
        const forward = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
        const backward = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
        switch (e.key) {
          case forward:
            e.preventDefault();
            for (let i = 1; i <= triggers.length; i++) {
              const c = triggers[(current + i) % triggers.length];
              if (!c.disabled) { next = c; break; }
            }
            break;
          case backward:
            e.preventDefault();
            for (let i = 1; i <= triggers.length; i++) {
              const c = triggers[(current - i + triggers.length) % triggers.length];
              if (!c.disabled) { next = c; break; }
            }
            break;
          case 'Home':
            e.preventDefault();
            next = triggers.find((t) => !t.disabled);
            break;
          case 'End':
            e.preventDefault();
            next = triggers.slice().reverse().find((t) => !t.disabled);
            break;
        }
        if (next && !next.disabled) {
          activateTab(next, triggers);
          next.focus();
        }
      });
    });
});}

init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });