Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Tabs

Tabbed panels on radios, with keyboard navigation.

Installation

pnpm dlx zazz-ui add tabs
npx zazz-ui add tabs
yarn dlx zazz-ui add tabs
bunx zazz-ui add tabs

Also adds fields, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Account

Manage your profile, email, and password.

Billing

Review invoices and update your payment method.

Notifications

Choose what we email you about and how often.

Account

Manage your profile, email, and password.

Billing

Review invoices and update your payment method.

Notifications

Choose what we email you about and how often.

<!-- Tabs horizontal START -->
<ui-tabs style="--w: 100%">
  <div data-tabs-slot="list" role="tablist">
    <div data-tabs-slot="indicator" aria-hidden="true"></div>

    <input type="radio" role="tab" name="tabs-group-1" id="tab-1" checked />
    <label for="tab-1" data-tabs-slot="label">
      <span data-tabs-slot="label-text">Account</span>
    </label>

    <input type="radio" role="tab" name="tabs-group-1" id="tab-2" />
    <label for="tab-2" data-tabs-slot="label">
      <span data-tabs-slot="label-text">Billing</span>
    </label>

    <input type="radio" role="tab" name="tabs-group-1" id="tab-3" />
    <label for="tab-3" data-tabs-slot="label">
      <span data-tabs-slot="label-text">Notifications</span>
    </label>
  </div>

  <div
    data-tabs-slot="panel"
    role="tabpanel"
    style="
      --p: 6;
      --w: 100%;
      --border-color: var(--color-border);
      --border-width: 1px;
      --rounded: var(--radius-md);
    "
  >
    <h2
      style="
        --font-size: var(--font-size-lg);
        --font-weight: strong;
        --leading: var(--leading-lg);
        --tracking: var(--tracking-lg);
      "
    >
      Account
    </h2>
    <p
      style="
        --mt: 2;
        --text: var(--color-muted-foreground);
      "
    >
      Manage your profile, email, and password.
    </p>
  </div>

  <div
    data-tabs-slot="panel"
    role="tabpanel"
    style="
      --p: 6;
      --w: 100%;
      --border-color: var(--color-border);
      --border-width: 1px;
      --rounded: var(--radius-md);
    "
  >
    <h2
      style="
        --font-size: var(--font-size-lg);
        --font-weight: strong;
        --leading: var(--leading-lg);
        --tracking: var(--tracking-lg);
      "
    >
      Billing
    </h2>
    <p
      style="
        --mt: 2;
        --text: var(--color-muted-foreground);
      "
    >
      Review invoices and update your payment method.
    </p>
  </div>

  <div
    data-tabs-slot="panel"
    role="tabpanel"
    style="
      --p: 6;
      --w: 100%;
      --border-color: var(--color-border);
      --border-width: 1px;
      --rounded: var(--radius-md);
    "
  >
    <h2
      style="
        --font-size: var(--font-size-lg);
        --font-weight: strong;
        --leading: var(--leading-lg);
        --tracking: var(--tracking-lg);
      "
    >
      Notifications
    </h2>
    <p
      style="
        --mt: 2;
        --text: var(--color-muted-foreground);
      "
    >
      Choose what we email you about and how often.
    </p>
  </div>
</ui-tabs>
<!-- Tabs horizontal END -->

<!-- Tabs vertical START -->
<ui-tabs data-tabs-orientation="vertical" style="--w: 100%">
  <div data-tabs-slot="list" role="tablist">
    <div data-tabs-slot="indicator" aria-hidden="true"></div>

    <input type="radio" role="tab" name="tabs-group-2" id="tab-vertical-1" checked />
    <label for="tab-vertical-1" data-tabs-slot="label">
      <span data-tabs-slot="label-text">Account</span>
    </label>

    <input type="radio" role="tab" name="tabs-group-2" id="tab-vertical-2" />
    <label for="tab-vertical-2" data-tabs-slot="label">
      <span data-tabs-slot="label-text">Billing</span>
    </label>

    <input type="radio" role="tab" name="tabs-group-2" id="tab-vertical-3" />
    <label for="tab-vertical-3" data-tabs-slot="label">
      <span data-tabs-slot="label-text">Notifications</span>
    </label>
  </div>

  <div
    data-tabs-slot="panel"
    role="tabpanel"
    style="
      --p: 6;
      --w: 100%;
      --border-color: var(--color-border);
      --border-width: 1px;
      --rounded: var(--radius-md);
    "
  >
    <h2
      style="
        --font-size: var(--font-size-lg);
        --font-weight: strong;
        --leading: var(--leading-lg);
        --tracking: var(--tracking-lg);
      "
    >
      Account
    </h2>
    <p
      style="
        --mt: 2;
        --text: var(--color-muted-foreground);
      "
    >
      Manage your profile, email, and password.
    </p>
  </div>

  <div
    data-tabs-slot="panel"
    role="tabpanel"
    style="
      --p: 6;
      --w: 100%;
      --border-color: var(--color-border);
      --border-width: 1px;
      --rounded: var(--radius-md);
    "
  >
    <h2
      style="
        --font-size: var(--font-size-lg);
        --font-weight: strong;
        --leading: var(--leading-lg);
        --tracking: var(--tracking-lg);
      "
    >
      Billing
    </h2>
    <p
      style="
        --mt: 2;
        --text: var(--color-muted-foreground);
      "
    >
      Review invoices and update your payment method.
    </p>
  </div>

  <div
    data-tabs-slot="panel"
    role="tabpanel"
    style="
      --p: 6;
      --w: 100%;
      --border-color: var(--color-border);
      --border-width: 1px;
      --rounded: var(--radius-md);
    "
  >
    <h2
      style="
        --font-size: var(--font-size-lg);
        --font-weight: strong;
        --leading: var(--leading-lg);
        --tracking: var(--tracking-lg);
      "
    >
      Notifications
    </h2>
    <p
      style="
        --mt: 2;
        --text: var(--color-muted-foreground);
      "
    >
      Choose what we email you about and how often.
    </p>
  </div>
</ui-tabs>
<!-- Tabs vertical END -->
/**
 * tabs.css — Tabs (ui-tabs | [data-ui~="tabs"]) — CSS-only radio tab panels
 *
 * @layer      variables, components
 * @requires   _layers.css, _variables.css, fields.css (--ui-field-* metrics)
 * @uses       input[type="radio"] + :checked — panel visibility via :has() (no JS)
 * @uses       anchor-name, position-anchor, anchor() — sliding indicator pill
 *             (@supports gated; checked-label fallback paints card fill without pill)
 * @uses       anchor-scope — isolates --active-tab inside nested menus
 * @uses       text-box, color-mix() — label trim and focus ring
 * @tokens     --ui-tabs-* (@layer variables)
 */

@layer variables {
  :root {
    /* metrics default to the shared --ui-field-* family so the track matches
       adjacent fields and buttons; --ui-tabs-label-min-h is deliberately shorter */
    --ui-tabs-gap: var(--space-xs);
    --ui-tabs-track-padding: calc(var(--spacing) * 0.5);
    --ui-tabs-track-rounded: var(--ui-field-rounded);
    --ui-tabs-track-gap: calc(var(--spacing) * 0.5);
    --ui-tabs-track-bg: var(--color-muted);

    --ui-tabs-indicator-bg: var(--color-card);
    --ui-tabs-indicator-rounded: var(--ui-field-inset-child-radius);
    --ui-tabs-indicator-shadow: var(--shadow-xs);
    --ui-tabs-indicator-inset: var(--ui-tabs-track-padding);

    --ui-tabs-label-font-family: var(--font-family-body);
    --ui-tabs-label-font-size: var(--font-size-sm);
    --ui-tabs-label-font-weight: var(--font-weight-strong);
    --ui-tabs-label-leading: var(--leading-sm);

    --ui-tabs-label-bg: transparent;
    --ui-tabs-label-bg--hover: transparent;
    --ui-tabs-label-bg--active: transparent;

    --ui-tabs-label-text: var(--color-muted-foreground);
    --ui-tabs-label-text--hover: var(--color-foreground);
    --ui-tabs-label-text--active: var(--color-foreground);

    --ui-tabs-label-px: var(--ui-field-px);
    --ui-tabs-label-min-h: calc(var(--spacing) * 7);
    --ui-tabs-label-ring-color: var(--ui-field-ring-color);
  }
}

@layer ui.components {
  /* Panels are direct children after the list; their order must match tab order.
     Panel N shows when the Nth radio in the list is checked. */

  :where(ui-tabs, [data-ui~="tabs"]) {
    display: flex;
    flex-direction: column;
    align-items: start;
    gap: var(--ui-tabs-gap);
  }

  :where(ui-tabs, [data-ui~="tabs"]) [data-tabs-slot~="list"] {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: var(--ui-tabs-track-gap);
    padding: var(--ui-tabs-track-padding);
    border-radius: var(--ui-tabs-track-rounded);
    background-color: var(--ui-tabs-track-bg);

    /* Scoped so [data-tabs-slot~="indicator"] can resolve --active-tab even when an ancestor
       (e.g. [data-navigation-menu-slot~="item"]) sets anchor-scope to its own trigger name. */
    anchor-scope: --active-tab;
  }

  :where(ui-tabs, [data-ui~="tabs"]) [data-tabs-slot~="indicator"] {
    position: absolute;
    z-index: 0;
    pointer-events: none;
    border-radius: var(--ui-tabs-indicator-rounded);
    background-color: var(--ui-tabs-indicator-bg);
    box-shadow: var(--ui-tabs-indicator-shadow);
    transition-property: left, right, top, bottom;
    transition-duration: var(--default-transition-duration);
    transition-timing-function: var(--default-transition-timing-function);
    will-change: left, right, top, bottom;
  }

  :where(ui-tabs, [data-ui~="tabs"]) [data-tabs-slot~="list"] input[type="radio"] {
    position: absolute;
    clip-path: inset(50%);
    opacity: 0;
    pointer-events: none;
  }

  :where(ui-tabs, [data-ui~="tabs"]) [data-tabs-slot~="list"] [data-tabs-slot~="label"] {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;

    font-family: var(--ui-tabs-label-font-family);
    font-size: var(--ui-tabs-label-font-size);
    font-weight: var(--ui-tabs-label-font-weight);
    line-height: var(--ui-tabs-label-leading);

    min-block-size: var(--ui-tabs-label-min-h);
    padding-inline: var(--ui-tabs-label-px);

    color: var(--ui-tabs-label-text);
    background-color: var(--ui-tabs-label-bg);
    text-wrap: balance;

    cursor: pointer;
    user-select: none;
    text-box: trim-both ex alphabetic;

    border-radius: var(--ui-tabs-indicator-rounded);

    /* ring renders as box-shadow so it composes with the indicator shadow in
       the no-anchor fallback below; the ring widths animate from 0 so the
       ring grows in smoothly on focus */
    --_ring-offset-width: 0px;
    --_ring-width: 0px;
    --_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
    --_ring-fill: color-mix(
      in oklch,
      var(--ui-tabs-label-ring-color) var(--ring-opacity),
      transparent
    );
    box-shadow:
      0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
      0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
        var(--_ring-fill, var(--ring-shadow-color)),
      inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
      inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
        var(--_ring-fill, var(--ring-shadow-color));

    /* transparent twin of the ring for forced-colors / high-contrast modes */
    outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
    outline-offset: calc(
      var(--outline-offset) - var(--_focus-in) *
        (2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
    );
    transition: var(--default-transition);
  }

  @media (hover: hover) {
    :where(ui-tabs, [data-ui~="tabs"]) [data-tabs-slot~="list"] [data-tabs-slot~="label"]:hover {
      color: var(--ui-tabs-label-text--hover);
      background-color: var(--ui-tabs-label-bg--hover);
    }
  }

  :where(ui-tabs, [data-ui~="tabs"]) [data-tabs-slot~="list"] [data-tabs-slot~="label"]:active {
    color: var(--ui-tabs-label-text--active);
    background-color: var(--ui-tabs-label-bg--active);
  }

  :where(ui-tabs, [data-ui~="tabs"])
    [data-tabs-slot~="list"]
    input[type="radio"]:checked
    + [data-tabs-slot~="label"] {
    color: var(--ui-tabs-label-text--active);
    anchor-name: --active-tab;
  }

  :where(ui-tabs, [data-ui~="tabs"])
    [data-tabs-slot~="list"]
    input[type="radio"]:focus-visible
    + [data-tabs-slot~="label"] {
    --_ring-offset-width: var(--ring-offset-width);
    --_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));

    outline-color: transparent;
  }

  /* Indicator positioning: the pill sizes itself from the checked label (exposed
     as --active-tab) via anchor() insets. Without anchor positioning the insets
     are invalid and the pill collapses, leaving the active tab unmarked (label
     background is transparent), so the fallback hides the pill and paints the
     checked label directly, losing only the slide animation. */

  @supports (anchor-name: --active-tab) {
    :where(ui-tabs, [data-ui~="tabs"]) [data-tabs-slot~="indicator"] {
      position-anchor: --active-tab;
      /* physical top/left/right/bottom: anchor() only accepts physical side keywords */
      top: calc(anchor(--active-tab top) + var(--ui-tabs-indicator-inset));
      left: calc(anchor(--active-tab left) + var(--ui-tabs-indicator-inset));
      right: calc(anchor(--active-tab right) + var(--ui-tabs-indicator-inset));
      bottom: calc(anchor(--active-tab bottom) + var(--ui-tabs-indicator-inset));
    }

    :where(ui-tabs, [data-ui~="tabs"])
      [data-tabs-slot~="list"]
      input[type="radio"]:checked
      + [data-tabs-slot~="label"] {
      background-color: var(--ui-tabs-label-bg--active);
    }
  }

  @supports not (anchor-name: --active-tab) {
    :where(ui-tabs, [data-ui~="tabs"]) [data-tabs-slot~="indicator"] {
      display: none;
    }

    :where(ui-tabs, [data-ui~="tabs"])
      [data-tabs-slot~="list"]
      input[type="radio"]:checked
      + [data-tabs-slot~="label"] {
      border-radius: var(--ui-tabs-indicator-rounded);
      background-color: var(--ui-tabs-indicator-bg);
      box-shadow:
        0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in)))
          var(--ring-offset-shadow-color),
        0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
          var(--_ring-fill, var(--ring-shadow-color)),
        inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in))
          var(--ring-offset-shadow-color),
        inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
          var(--_ring-fill, var(--ring-shadow-color)),
        var(--ui-tabs-indicator-shadow);
    }
  }

  /* Nth radio in the list maps to the (N + 1)th child of the root. Selectors
     cannot do index arithmetic and sibling-index() is a value function, so the
     per-index enumeration (capped at 24 tabs) is needed. Hiding stays
     display:none so hidden panels are not focusable. */
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(1):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(2),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(2):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(3),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(3):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(4),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(4):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(5),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(5):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(6),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(6):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(7),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(7):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(8),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(8):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(9),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(9):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(10),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(10):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(11),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(11):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(12),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(12):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(13),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(13):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(14),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(14):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(15),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(15):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(16),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(16):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(17),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(17):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(18),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(18):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(19),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(19):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(20),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(20):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(21),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(21):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(22),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(22):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(23),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(23):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(24),
  :where(ui-tabs, [data-ui~="tabs"]):has(
      [data-tabs-slot~="list"] > input[type="radio"]:nth-of-type(24):not(:checked)
    )
    > [data-tabs-slot~="panel"]:nth-child(25) {
    display: none !important;
  }

  /* ===========================================================================
  DIRECTIONAL VARIANTS
  =========================================================================== */

  :where(ui-tabs, [data-ui~="tabs"])[data-tabs-orientation="vertical"] {
    flex-direction: row;
  }

  :where(ui-tabs, [data-ui~="tabs"])[data-tabs-orientation="vertical"] [data-tabs-slot~="list"] {
    flex-direction: column;
    align-items: stretch;
    min-inline-size: calc(var(--spacing) * 44);
  }

  :where(ui-tabs, [data-ui~="tabs"])[data-tabs-orientation="vertical"] [data-tabs-slot~="label"] {
    text-align: start;
    justify-content: start;
  }
}
// primitives/tabs/tabs.js
"use strict";
/**
 * @fileoverview `<ui-tabs>`: HTML web component for keyboard-enhanced tabs.
 * @description Light-DOM custom element that augments the CSS-only radio
 * tabs pattern with orientation-aware arrow-key navigation.
 *
 * Keyboard behavior on the focused tab radio:
 * - Horizontal (default): ArrowLeft / ArrowRight move between tabs.
 * - Vertical (`data-tabs-orientation="vertical"`): ArrowUp / ArrowDown move between tabs.
 * - Home / End jump to the first / last enabled tab.
 * - Navigation wraps around and skips disabled tabs.
 *
 * Native radio-group arrow keys already provide a baseline without
 * JavaScript; this element makes the keys match the tabs' visual
 * orientation and adds Home/End + wrap-around.
 *
 * @example
 * <ui-tabs>
 *   <div data-tabs-slot="list" role="tablist">
 *     <input type="radio" role="tab" name="tg" id="tg-1" checked />
 *     <label for="tg-1" data-tabs-slot="label">One</label>
 *     <input type="radio" role="tab" name="tg" id="tg-2" />
 *     <label for="tg-2" data-tabs-slot="label">Two</label>
 *   </div>
 *   <div data-tabs-slot="panel">…</div>
 *   <div data-tabs-slot="panel">…</div>
 * </ui-tabs>
 */
import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";
class UiTabs extends ZazzElement {
    setup(signal) {
        this.addEventListener("keydown", (event) => this.#onKeydown(event), { signal });
    }
    /**
     * @description Handles arrow-key, Home, and End navigation between tab radios.
     *
     * @param event - The keydown event.
     */
    #onKeydown(event) {
        const target = event.target;
        if (!(target instanceof HTMLInputElement) || target.type !== "radio")
            return;
        const list = target.closest('[role="tablist"], [data-tabs-slot~="list"]');
        // ignore radios of a nested ui-tabs
        if (!list || list.closest('ui-tabs, [data-ui~="tabs"]') !== this)
            return;
        const tabs = Array.from(list.querySelectorAll('input[type="radio"]'))
            .filter((node) => node instanceof HTMLInputElement)
            .filter((tab) => !tab.disabled);
        if (tabs.length < 2)
            return;
        const vertical = this.getAttribute("data-tabs-orientation") === "vertical";
        const prevKey = vertical ? "ArrowUp" : "ArrowLeft";
        const nextKey = vertical ? "ArrowDown" : "ArrowRight";
        const index = tabs.indexOf(target);
        if (index === -1)
            return;
        let nextIndex;
        switch (event.key) {
            case prevKey:
                nextIndex = (index - 1 + tabs.length) % tabs.length;
                break;
            case nextKey:
                nextIndex = (index + 1) % tabs.length;
                break;
            case "Home":
                nextIndex = 0;
                break;
            case "End":
                nextIndex = tabs.length - 1;
                break;
            default:
                return;
        }
        event.preventDefault();
        const tab = tabs[nextIndex];
        tab.checked = true;
        tab.focus();
        tab.dispatchEvent(new Event("change", { bubbles: true }));
    }
}
defineZazzElement("ui-tabs", UiTabs);
export { UiTabs };

Behavior

primitives/tabs/tabs.js

<ui-tabs>: HTML web component for keyboard-enhanced tabs. Light-DOM custom element that augments the CSS-only radio tabs pattern with orientation-aware arrow-key navigation.

Keyboard behavior on the focused tab radio:

  • Horizontal (default): ArrowLeft / ArrowRight move between tabs.
  • Vertical (data-tabs-orientation="vertical"): ArrowUp / ArrowDown move between tabs.
  • Home / End jump to the first / last enabled tab.
  • Navigation wraps around and skips disabled tabs.

Native radio-group arrow keys already provide a baseline without JavaScript; this element makes the keys match the tabs' visual orientation and adds Home/End + wrap-around.

Attributes

tabs attributes
AttributeKindValuesNotes
data-ui="tabs"Identityfree valueIdentity token in data-ui.
<ui-tabs>Tag formfree valueTag form; styled and, when scripted, registered under this name.
data-tabs-orientationPresetverticalPreset: a named bundle of values.
data-tabs-slotSlotindicator label label-text list panelSlot: a part inside the primitive.

Requires: fields.

Hooks

Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.

tabs hooks
HookDefaultState
--ui-tabs-gapvar(--space-xs)—
--ui-tabs-indicator-bgvar(--color-card)—
--ui-tabs-indicator-insetvar(--ui-tabs-track-padding)—
--ui-tabs-indicator-roundedvar(--ui-field-inset-child-radius)—
--ui-tabs-indicator-shadowvar(--shadow-xs)—
--ui-tabs-label-bgtransparent—
--ui-tabs-label-bg--activetransparentactive
--ui-tabs-label-bg--hovertransparenthover
--ui-tabs-label-font-familyvar(--font-family-body)—
--ui-tabs-label-font-sizevar(--font-size-sm)—
--ui-tabs-label-font-weightvar(--font-weight-strong)—
--ui-tabs-label-leadingvar(--leading-sm)—
--ui-tabs-label-min-hcalc(var(--spacing) * 7)—
--ui-tabs-label-pxvar(--ui-field-px)—
--ui-tabs-label-ring-colorvar(--ui-field-ring-color)—
--ui-tabs-label-textvar(--color-muted-foreground)—
--ui-tabs-label-text--activevar(--color-foreground)active
--ui-tabs-label-text--hovervar(--color-foreground)hover
--ui-tabs-track-bgvar(--color-muted)—
--ui-tabs-track-gapcalc(var(--spacing) * 0.5)—
--ui-tabs-track-paddingcalc(var(--spacing) * 0.5)—
--ui-tabs-track-roundedvar(--ui-field-rounded)—