Skip to content

Tabs

ui

Preview

Account

Manage your account settings.

<ui-tabs value="account" class="w-full max-w-md">
  <ui-tabs-list>
    <ui-tabs-trigger value="account">Account</ui-tabs-trigger>
    <ui-tabs-trigger value="password">Password</ui-tabs-trigger>
  </ui-tabs-list>
  <ui-tabs-content value="account" class="${cardClass()} mt-3 p-6">
    <h3 class=${cardTitleClass()}>Account</h3>
    <p class="${cardDescriptionClass()} mt-1">Manage your account settings.</p>
  </ui-tabs-content>
  <ui-tabs-content value="password" class="${cardClass()} mt-3 p-6">
    <h3 class=${cardTitleClass()}>Password</h3>
    <p class="${cardDescriptionClass()} mt-1">Change your password here.</p>
  </ui-tabs-content>
</ui-tabs>

Installation

webjs ui add tabs

Variants

Default

<ui-tabs value="account" class="w-full max-w-sm">
  <ui-tabs-list variant="default">
    <ui-tabs-trigger value="account">Account</ui-tabs-trigger>
    <ui-tabs-trigger value="password">Password</ui-tabs-trigger>
  </ui-tabs-list>
</ui-tabs>

Underline

<ui-tabs value="account" class="w-full max-w-sm">
  <ui-tabs-list variant="underline">
    <ui-tabs-trigger value="account">Account</ui-tabs-trigger>
    <ui-tabs-trigger value="password">Password</ui-tabs-trigger>
  </ui-tabs-list>
</ui-tabs>

API Reference

Parts

Name Description
<ui-tabs> Owns the active value + orientation.
<ui-tabs-list> Trigger row. Accepts variant="default | underline".
<ui-tabs-trigger> Tab button, value prop links to a matching content.
<ui-tabs-content> Tab panel, value prop matches its trigger.
tabsListClass({ variant }) Class helper for the list.

Props

Prop Type Default Description
value string On <ui-tabs>, controlled active value.
orientation "horizontal" | "vertical" "horizontal" On <ui-tabs>.
variant "default" | "underline" "default" On <ui-tabs-list>, webjs name for shadcn’s underline-style list.

Events

Name Detail Description
ui-value-change { value: string } Fired on <ui-tabs> when the active value changes.

Source: components/ui/tabs.ts

/**
 * Tabs: sectioned content with keyboard navigation. Tier-2. The parent
 * <ui-tabs> owns `value` + `orientation`; triggers and panels read from
 * it via `closest('ui-tabs')` and re-render when value changes.
 *
 * APG pattern: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
 *
 * shadcn parity:
 *   Tabs           → <ui-tabs value orientation>
 *   TabsList       → <ui-tabs-list variant>
 *   TabsTrigger    → <ui-tabs-trigger value>
 *   TabsContent    → <ui-tabs-content value>
 *
 * Attributes on <ui-tabs>:
 *   `value`:       string (reflected, controlled). Active tab value.
 *   `orientation`: "horizontal" (default) | "vertical".
 *
 * Attributes on <ui-tabs-list>:
 *   `variant`: "default" (default, pill-on-muted) | "underline".
 *
 * Attributes on <ui-tabs-trigger> / <ui-tabs-content>:
 *   `value`: string. Identifier this trigger / panel pair shares.
 *
 * Events:
 *   `ui-value-change` on <ui-tabs>: `{ detail: { value } }` after a change.
 *
 * Keyboard (on focused trigger):
 *   ArrowRight / ArrowLeft   next / previous (horizontal)
 *   ArrowDown / ArrowUp      next / previous (vertical)
 *   Home / End               first / last
 *   Enter / Space            activate (native button activation)
 *
 * Design tokens used: --muted, --muted-foreground, --foreground, --background,
 * --input, --ring.
 *
 * @example
 * ```html
 * <ui-tabs value="account">
 *   <ui-tabs-list>
 *     <ui-tabs-trigger value="account">Account</ui-tabs-trigger>
 *     <ui-tabs-trigger value="password">Password</ui-tabs-trigger>
 *   </ui-tabs-list>
 *   <ui-tabs-content value="account">
 *     <p class="text-sm">Update your name and email here.</p>
 *   </ui-tabs-content>
 *   <ui-tabs-content value="password">
 *     <p class="text-sm">Change your password here.</p>
 *   </ui-tabs-content>
 * </ui-tabs>
 * ```
 */
import { WebComponent, html, prop } from '@webjsdev/core';
import { cn, ensureId } from '../lib/utils.ts';

// A tab `value` becomes part of an id, so reduce it to id-safe characters.
const idSafe = (s: string): string => s.replace(/[^A-Za-z0-9_-]/g, '-');

// --------------------------------------------------------------------------
// Class helpers
// --------------------------------------------------------------------------

const TABS_BASE = 'group/tabs flex gap-2 data-[orientation=horizontal]:flex-col';

const TABS_LIST_BASE =
  'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=underline]:rounded-none';

const TABS_LIST_VARIANTS = {
  default: 'bg-muted',
  underline: 'gap-1 bg-transparent',
} as const;

export type TabsListVariant = keyof typeof TABS_LIST_VARIANTS;

/** Optional helper exposing the tabs-list class for advanced overrides. */
export function tabsListClass(opts: { variant?: TabsListVariant } = {}): string {
  const variant = opts.variant ?? 'default';
  return cn(TABS_LIST_BASE, TABS_LIST_VARIANTS[variant]);
}

// The trigger is rendered as a native <button role="tab">, which gives
// us cursor-pointer + Enter/Space activation + focus for free. The class
// here is essentially shadcn's tabs.tsx trigger output.
const TABS_TRIGGER_CLASS = [
  "relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer select-none items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=underline]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  // These mirror shadcn's tabs.tsx (modulo variant=line → variant=underline).
  // Light-mode active state uses bg-background + shadow only; dark mode
  // additionally borders. Adding light-mode border made the underline
  // variant look like outline and made default look like two buttons.
  'group-data-[variant=underline]/tabs-list:bg-transparent group-data-[variant=underline]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=underline]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=underline]/tabs-list:data-[state=active]:bg-transparent',
  'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground',
  'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=underline]/tabs-list:data-[state=active]:after:opacity-100',
].join(' ');

const TABS_CONTENT_CLASS = 'flex-1 outline-none';

// --------------------------------------------------------------------------
// <ui-tabs> owns active `value` + `orientation`. Children read its state
// via closest('ui-tabs'); when value changes, the parent fires a
// `tabs-value-change-internal` event that descendants listen for via
// the bubbling event on their own host. Each descendant then calls
// requestUpdate() to re-render against the new parent value.
// --------------------------------------------------------------------------

export class UiTabs extends WebComponent({
  value: prop(String, { reflect: true }),
  orientation: prop<'horizontal' | 'vertical'>(String, { reflect: true }),
}) {
  constructor() {
    super();
    this.value = '';
    this.orientation = 'horizontal';
  }

  // Assign the scope id BEFORE render so it is serialized onto this host's
  // opening tag at SSR (willUpdate now runs server-side). Without this, the id
  // was minted lazily during a CHILD trigger's render, AFTER this host's tag
  // was already serialized without an id, so the host shipped no id; on
  // hydration the client re-minted a DIFFERENT counter id and the trigger /
  // panel ids mismatched SSR vs client, breaking hydration (#730).
  willUpdate(): void {
    ensureId(this, 'ui-tabs');
  }

  // Stable per-instance scope so a trigger and its panel agree on the
  // shared id stem (`<scope>-trigger-<value>` / `<scope>-panel-<value>`)
  // even when several tab sets reuse the same `value` names on one page.
  get scopeId(): string {
    return ensureId(this, 'ui-tabs');
  }

  // Builds the trigger / panel id pair a child derives from its `value`.
  idsFor(value: string): { trigger: string; panel: string } {
    const v = idSafe(value);
    return { trigger: `${this.scopeId}-trigger-${v}`, panel: `${this.scopeId}-panel-${v}` };
  }

  render() {
    return html`<div
      data-slot="tabs"
      data-orientation=${this.orientation}
      class=${TABS_BASE}
    ><slot></slot></div>`;
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (!changedProperties.has('value')) return;
    const prev = (changedProperties.get('value') ?? '') as string;
    // Skip the initial undefined -> '' (or '' -> '') no-op set; only
    // dispatch + broadcast on real value transitions.
    if (prev === '' && this.value === '') return;
    queueMicrotask(() => {
      this.dispatchEvent(
        new CustomEvent('ui-value-change', { detail: { value: this.value }, bubbles: true }),
      );
      this._broadcast();
    });
  }

  _broadcast(): void {
    this.querySelectorAll<WebComponent>(
      'ui-tabs-list, ui-tabs-trigger, ui-tabs-content',
    ).forEach((el) => el.requestUpdate?.());
  }
}
UiTabs.register('ui-tabs');

// --------------------------------------------------------------------------
// <ui-tabs-list>
// --------------------------------------------------------------------------

export class UiTabsList extends WebComponent({
  variant: prop<TabsListVariant>(String, { reflect: true }),
}) {
  constructor() {
    super();
    this.variant = 'default';
  }

  get _tabs(): UiTabs | null {
    if (typeof this.closest !== 'function') return null;
    return this.closest('ui-tabs') as UiTabs | null;
  }

  render() {
    const orientation = this._tabs?.orientation ?? 'horizontal';
    return html`<div
      data-slot="tabs-list"
      role="tablist"
      aria-orientation=${orientation}
      data-variant=${this.variant}
      class=${tabsListClass({ variant: this.variant })}
    ><slot></slot></div>`;
  }
}
UiTabsList.register('ui-tabs-list');

// --------------------------------------------------------------------------
// <ui-tabs-trigger value="...">
// --------------------------------------------------------------------------

export class UiTabsTrigger extends WebComponent({
  value: prop(String, { reflect: true }),
}) {
  constructor() {
    super();
    this.value = '';
  }

  // render() runs server-side too. webjs resolves closest() at SSR against
  // the enclosing-element ancestor chain, so the active tab is marked in the
  // first paint (no hydration flash). The typeof guard stays defensive.
  get _tabs(): UiTabs | null {
    if (typeof this.closest !== 'function') return null;
    return this.closest('ui-tabs') as UiTabs | null;
  }

  render() {
    const tabs = this._tabs;
    const active = !!tabs && tabs.value === this.value && this.value !== '';
    const ids = tabs && this.value ? tabs.idsFor(this.value) : null;
    return html`<button
      type="button"
      role="tab"
      data-slot="tabs-trigger"
      id=${ids ? ids.trigger : ''}
      aria-controls=${ids ? ids.panel : ''}
      data-state=${active ? 'active' : 'inactive'}
      aria-selected=${String(active)}
      tabindex=${active ? '0' : '-1'}
      class=${TABS_TRIGGER_CLASS}
      @click=${this._onClick}
      @keydown=${this._onKeyDown}
    ><slot></slot></button>`;
  }

  _onClick = (): void => {
    if (this.value) this._tabs?.setAttribute('value', this.value);
  };

  _onKeyDown = (e: KeyboardEvent): void => {
    const tabs = this._tabs;
    if (!tabs) return;
    const orientation = tabs.orientation;
    // Scope to triggers belonging to THIS group: querySelectorAll crosses into
    // a nested <ui-tabs> inside a panel, and arrow nav at the boundary would
    // jump into the inner group and set this group's value to an inner-only
    // value (hiding every panel). Same scoping dropdown-menu applies to items.
    const triggers = Array.from(tabs.querySelectorAll<UiTabsTrigger>('ui-tabs-trigger')).filter(
      (t) => t._tabs === tabs,
    );
    const idx = triggers.indexOf(this);
    const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
    const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';

    let target: UiTabsTrigger | null = null;
    if (e.key === nextKey) target = triggers[(idx + 1) % triggers.length] ?? null;
    else if (e.key === prevKey) target = triggers[(idx - 1 + triggers.length) % triggers.length] ?? null;
    else if (e.key === 'Home') target = triggers[0] ?? null;
    else if (e.key === 'End') target = triggers[triggers.length - 1] ?? null;
    // Enter/Space already handled natively by <button>; no preventDefault needed.

    if (target) {
      e.preventDefault();
      const v = target.value;
      if (v) tabs.setAttribute('value', v);
      // Focus the inner <button role="tab">, NOT the <ui-tabs-trigger> host.
      // The host carries no tabindex so it is not focusable; calling focus()
      // on it is a no-op that leaves the focus ring stranded on the previous
      // trigger while a different panel shows. The roving tabindex lives on
      // the inner button, so that is what APG focus-follows-selection targets.
      const btn = target.querySelector<HTMLButtonElement>('[role="tab"]');
      (btn ?? target).focus();
    }
  };
}
UiTabsTrigger.register('ui-tabs-trigger');

// --------------------------------------------------------------------------
// <ui-tabs-content value="...">
// --------------------------------------------------------------------------

export class UiTabsContent extends WebComponent({
  value: prop(String, { reflect: true }),
}) {
  constructor() {
    super();
    this.value = '';
  }

  get _tabs(): UiTabs | null {
    if (typeof this.closest !== 'function') return null;
    return this.closest('ui-tabs') as UiTabs | null;
  }

  render() {
    const tabs = this._tabs;
    const active = !!tabs && tabs.value === this.value && this.value !== '';
    const ids = tabs && this.value ? tabs.idsFor(this.value) : null;
    // The host needs to be hidden when inactive so its rendered <section>
    // is removed from layout (light DOM has no :host CSS to scope this).
    // Use the native `hidden` IDL property rather than imperative
    // setAttribute, so it reads as a property assignment. `inert` on the
    // host additionally pulls an inactive panel (and anything focusable
    // inside it) out of the tab order and the accessibility tree.
    this.hidden = !active;
    this.inert = !active;
    return html`<section
      data-slot="tabs-content"
      role="tabpanel"
      id=${ids ? ids.panel : ''}
      aria-labelledby=${ids ? ids.trigger : ''}
      tabindex="0"
      data-state=${active ? 'active' : 'inactive'}
      class=${TABS_CONTENT_CLASS}
    ><slot></slot></section>`;
  }
}
UiTabsContent.register('ui-tabs-content');